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]]
+17 -9
View File
@@ -1,4 +1,6 @@
const api = require('./utils/api') const api = require('./utils/api')
const storage = require('./utils/storage')
const sync = require('./utils/sync')
App({ App({
onLaunch() { onLaunch() {
@@ -20,20 +22,26 @@ App({
// 获取 openid:调自建后端 /api/login // 获取 openid:调自建后端 /api/login
async getUserOpenId() { async getUserOpenId() {
// 已缓存就直接复用,避免每次启动都登录 let openid = wx.getStorageSync('openid')
const cached = wx.getStorageSync('openid') if (!openid) {
if (cached) {
this.globalData.openid = cached
return
}
try { try {
const res = await api.login() const res = await api.login()
this.globalData.openid = res.openid openid = res.openid
wx.setStorageSync('openid', res.openid) wx.setStorageSync('openid', openid)
console.log('获取openid成功:', res.openid) console.log('获取openid成功:', openid)
} catch (err) { } catch (err) {
console.error('获取openid失败:', err) console.error('获取openid失败:', err)
return
} }
}
this.globalData.openid = openid
// 拿到 openid 后,本地无数据时从云端拉一次(新设备/重装恢复场景)
const pulled = await storage.pullFromCloudIfEmpty()
if (pulled) console.log('已从云端恢复数据')
// flush 之前同步失败的待重试队列
sync.flush()
}, },
globalData: { globalData: {
+91 -39
View File
@@ -1,21 +1,24 @@
// add-anniversary.js // add-anniversary.js
const storage = require('../../utils/storage') const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date') const dateUtils = require('../../utils/date')
const api = require('../../utils/api') const sync = require('../../utils/sync')
const lunar = require('../../utils/lunar')
Page({ Page({
data: { data: {
anniversaryId: null, anniversaryId: null,
personId: null, personId: null,
personList: [], personList: [],
personIndex: 0, inputName: '',
selectedPerson: '',
typeList: ['公历生日', '农历生日', '结婚纪念日', '订婚纪念日', '其他纪念日'], typeList: ['公历生日', '农历生日', '结婚纪念日', '订婚纪念日', '其他纪念日'],
typeIndex: 0, typeIndex: 0,
showCustomType: false, showCustomType: false,
dateValue: '', dateValue: '',
remindDaysList: ['提前3天', '提前7天', '提前14天', '提前30天', '自定义'], remindDaysList: ['提前3天', '提前7天', '提前14天', '提前30天', '自定义'],
remindDaysIndex: 0, remindDaysIndex: 0,
// UI 反馈:选了农历日期时展示对应农历文本 + 闰月警示
lunarText: '',
isLeapMonth: false,
formData: { formData: {
isLunar: false, isLunar: false,
type: 'birthday', type: 'birthday',
@@ -26,6 +29,7 @@ Page({
lunarYear: '', lunarYear: '',
lunarMonth: '', lunarMonth: '',
lunarDay: '', lunarDay: '',
isLeapMonth: false,
importance: 'low', importance: 'low',
remindEnabled: true, remindEnabled: true,
remindDays: 7, remindDays: 7,
@@ -34,18 +38,17 @@ Page({
}, },
onLoad(options) { onLoad(options) {
// 获取人员列表 // 获取人员列表用于快捷选择
const persons = storage.getPersons() const persons = storage.getPersons()
this.setData({ personList: persons }) this.setData({ personList: persons })
if (options.personId) { if (options.personId) {
// 从人员详情页进入 // 从人员详情页进入,预选关联人员
const index = persons.findIndex(p => p.id === options.personId) const person = persons.find(p => p.id === options.personId)
if (index !== -1) { if (person) {
this.setData({ this.setData({
personIndex: index, personId: person.id,
personId: options.personId, inputName: person.name
selectedPerson: persons[index].name
}) })
} }
} }
@@ -76,16 +79,22 @@ Page({
'YYYY-MM-DD' 'YYYY-MM-DD'
) )
// 设置类型
const typeIndex = this.getTypeIndex(anniversary.type) const typeIndex = this.getTypeIndex(anniversary.type)
// 编辑时不允许改关联人员,姓名展示用 personName 或从 personList 查
const person = this.data.personList.find(p => p.id === anniversary.personId)
this.setData({ this.setData({
formData: anniversary, formData: anniversary,
dateValue: date, dateValue: date,
typeIndex, 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: '编辑纪念日' }) wx.setNavigationBarTitle({ title: '编辑纪念日' })
} }
}, },
@@ -105,18 +114,26 @@ Page({
}, },
/** /**
* 选择人员 * 姓名输入:用户打字时实时更新;点 chip 时也会触发
* 输入框值变了就清掉已绑定的 personId,提交时再按姓名查找/创建
*/ */
onPersonChange(e) { onNameInput(e) {
const index = parseInt(e.detail.value) const name = e.detail.value
const person = this.data.personList[index] const matched = this.data.personList.find(p => p.name === name.trim())
this.setData({ this.setData({
personIndex: index, inputName: name,
personId: person.id, personId: matched ? matched.id : null
selectedPerson: person.name
}) })
}, },
/**
* 点已有人员快捷 chip
*/
onPickPerson(e) {
const { id, name } = e.currentTarget.dataset
this.setData({ personId: id, inputName: name })
},
/** /**
* 选择类型 * 选择类型
*/ */
@@ -146,9 +163,8 @@ Page({
*/ */
onDateTypeChange(e) { onDateTypeChange(e) {
const isLunar = e.detail.value === 'lunar' const isLunar = e.detail.value === 'lunar'
this.setData({ this.setData({ 'formData.isLunar': isLunar })
'formData.isLunar': isLunar this._refreshLunar()
})
}, },
/** /**
@@ -164,6 +180,29 @@ Page({
'formData.solarMonth': parseInt(parts[1]), 'formData.solarMonth': parseInt(parts[1]),
'formData.solarDay': parseInt(parts[2]) '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() { async onSubmit() {
const { formData, personId, anniversaryId, personList } = this.data const { formData, anniversaryId, inputName } = this.data
let { personId } = this.data
// 验证关联人员 // 验证姓名
if (!personId) { const name = (inputName || '').trim()
wx.showToast({ title: '请选择关联人员', icon: 'none' }) if (!name) {
wx.showToast({ title: '请输入姓名', icon: 'none' })
return return
} }
@@ -263,13 +304,30 @@ Page({
await this.requestSubscribe() await this.requestSubscribe()
} catch (err) { } catch (err) {
console.log('用户拒绝订阅消息') console.log('用户拒绝订阅消息')
// 继续保存,即使用户拒绝订阅
} }
} }
// 获取人员名称 // 新增模式且没绑定 personId → 按姓名查找或自动创建
const person = personList.find(p => p.id === personId) if (!anniversaryId && !personId) {
const personName = person ? person.name : '' 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) { if (anniversaryId) {
// 编辑模式 // 编辑模式
@@ -328,21 +386,15 @@ Page({
}, },
/** /**
* 同步到自建后端 * 同步纪念日到后端(失败自动入队,启动时 flush)
*/ */
async syncToCloud(id, data, action) { syncToCloud(id, data, action) {
try {
const openid = wx.getStorageSync('openid') const openid = wx.getStorageSync('openid')
if (!openid) { if (!openid) {
console.log('未获取到openid,跳过云端同步') console.log('未获取到openid,跳过云端同步')
return return
} }
const res = await api.anniversary(action, { id, ...data }) sync.syncOrEnqueue({ kind: 'anniversary', action, data: { id, ...data } })
console.log('云端同步成功:', res)
} catch (err) {
console.error('云端同步失败:', err)
// 不影响本地保存
}
} }
}) })
+20 -8
View File
@@ -1,16 +1,20 @@
<!--add-anniversary.wxml--> <!--add-anniversary.wxml-->
<view class="container"> <view class="container">
<view class="form"> <view class="form">
<!-- 关联人员 --> <!-- 关联人员(姓名输入 + 已有人员快捷选择) -->
<view class="form-item"> <view class="form-item">
<text class="label required">关联人员</text> <text class="label required">为谁记录</text>
<picker mode="selector" range="{{personList}}" range-key="name" value="{{personIndex}}" bindchange="onPersonChange"> <input class="input" placeholder="输入姓名,例如:妈妈" value="{{inputName}}" bindinput="onNameInput" disabled="{{!!anniversaryId}}" />
<view class="picker"> <view wx:if="{{personList.length > 0 && !anniversaryId}}" class="chips">
<text wx:if="{{selectedPerson}}" class="picker-text">{{selectedPerson}}</text> <view
<text wx:else class="picker-placeholder">请选择关联人员</text> wx:for="{{personList}}"
<text class="picker-arrow"></text> 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>
</picker>
</view> </view>
<!-- 纪念日类型 --> <!-- 纪念日类型 -->
@@ -49,6 +53,14 @@
<text class="picker-arrow"></text> <text class="picker-arrow"></text>
</view> </view>
</picker> </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> </view>
<!-- 重要程度 --> <!-- 重要程度 -->
+60 -1
View File
@@ -59,13 +59,72 @@
} }
.input { .input {
margin-top: 16rpx; margin-top: 0;
background-color: #f5f5f5; background-color: #f5f5f5;
border-radius: 8rpx; border-radius: 8rpx;
padding: 24rpx; padding: 24rpx;
font-size: 28rpx; 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 { .radio-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+4 -34
View File
@@ -1,7 +1,7 @@
// index.js // index.js
const storage = require('../../utils/storage') const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date') const dateUtils = require('../../utils/date')
const { TYPE_NAMES } = require('../../utils/constants') const fmt = require('../../utils/format')
Page({ Page({
data: { data: {
@@ -45,10 +45,10 @@ Page({
const next = upcoming[0] const next = upcoming[0]
nextAnniversary = { nextAnniversary = {
type: next.type, type: next.type,
typeName: this.getTypeName(next.type, next.customTypeName), typeName: fmt.getTypeName(next.type, next.customTypeName),
dateText: dateUtils.formatDate(next.date, 'MM月DD日'), dateText: dateUtils.formatDate(next.date, 'MM月DD日'),
daysUntil: next.daysUntil, 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,23 +159,12 @@ Page({
}, },
/** /**
* 点击添加按钮 * 点击添加按钮:直接进添加纪念日(方案 B:流程合并,姓名不存在自动建人)
*/ */
onAddTap() { 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({ wx.navigateTo({
url: '/pages/add-anniversary/add-anniversary' url: '/pages/add-anniversary/add-anniversary'
}) })
} }
}
})
}
}) })
+5 -26
View File
@@ -1,7 +1,7 @@
// person-detail.js // person-detail.js
const storage = require('../../utils/storage') const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date') const dateUtils = require('../../utils/date')
const { TYPE_NAMES, TYPE_ICONS, IMPORTANCE_TEXTS } = require('../../utils/constants') const fmt = require('../../utils/format')
Page({ Page({
data: { data: {
@@ -58,10 +58,10 @@ Page({
date, date,
dateText: dateUtils.formatDate(date, 'YYYY年MM月DD日'), dateText: dateUtils.formatDate(date, 'YYYY年MM月DD日'),
daysUntil, daysUntil,
daysUntilAbs: Math.abs(daysUntil), // 添加绝对值 daysUntilAbs: Math.abs(daysUntil),
typeIcon: this.getTypeIcon(a.type), typeIcon: fmt.getTypeIcon(a.type),
typeName: a.customTypeName || this.getTypeName(a.type), typeName: fmt.getTypeName(a.type, a.customTypeName),
importanceText: this.getImportanceText(a.importance) importanceText: fmt.getImportanceText(a.importance)
} }
}) })
@@ -71,27 +71,6 @@ Page({
this.setData({ anniversaries: formatted }) 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, solarYear INTEGER,
solarMonth INTEGER, solarMonth INTEGER,
solarDay INTEGER, solarDay INTEGER,
lunarYear INTEGER,
lunarMonth INTEGER,
lunarDay INTEGER,
isLeapMonth INTEGER DEFAULT 0,
importance TEXT, importance TEXT,
remindEnabled INTEGER DEFAULT 0, remindEnabled INTEGER DEFAULT 0,
remindDays 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_openid ON anniversaries(openid);
CREATE INDEX IF NOT EXISTS idx_anniv_remind ON anniversaries(remindEnabled); 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 ( CREATE TABLE IF NOT EXISTS remind_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
anniversaryId TEXT, anniversaryId TEXT,
@@ -48,4 +64,18 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate); 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 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 协议) // 纪念日操作(兼容原云函数 syncAnniversary 的 action 协议)
app.post('/api/anniversary', (req, res) => { app.post('/api/anniversary', (req, res) => {
try { try {
@@ -51,8 +72,10 @@ app.post('/api/anniversary', (req, res) => {
const FIELDS = [ const FIELDS = [
'id', 'openid', 'personId', 'personName', 'type', 'customTypeName', 'id', 'openid', 'personId', 'personName', 'type', 'customTypeName',
'isLunar', 'solarYear', 'solarMonth', 'solarDay', 'importance', 'isLunar', 'solarYear', 'solarMonth', 'solarDay',
'remindEnabled', 'remindDays', 'remark', 'createTime', 'updateTime' 'lunarYear', 'lunarMonth', 'lunarDay', 'isLeapMonth',
'importance', 'remindEnabled', 'remindDays', 'remark',
'createTime', 'updateTime'
] ]
function normalize(row) { function normalize(row) {
@@ -60,7 +83,8 @@ function normalize(row) {
return { return {
...row, ...row,
isLunar: !!row.isLunar, isLunar: !!row.isLunar,
remindEnabled: !!row.remindEnabled remindEnabled: !!row.remindEnabled,
isLeapMonth: !!row.isLeapMonth
} }
} }
@@ -77,6 +101,10 @@ function addAnniversary(openid, anniv) {
solarYear: anniv.solarYear ?? null, solarYear: anniv.solarYear ?? null,
solarMonth: anniv.solarMonth, solarMonth: anniv.solarMonth,
solarDay: anniv.solarDay, solarDay: anniv.solarDay,
lunarYear: anniv.lunarYear ?? null,
lunarMonth: anniv.lunarMonth ?? null,
lunarDay: anniv.lunarDay ?? null,
isLeapMonth: anniv.isLeapMonth ? 1 : 0,
importance: anniv.importance || 'normal', importance: anniv.importance || 'normal',
remindEnabled: anniv.remindEnabled ? 1 : 0, remindEnabled: anniv.remindEnabled ? 1 : 0,
remindDays: anniv.remindDays ?? 0, remindDays: anniv.remindDays ?? 0,
@@ -99,6 +127,7 @@ function updateAnniversary(openid, anniv) {
openid, openid,
isLunar: ('isLunar' in anniv ? (anniv.isLunar ? 1 : 0) : existing.isLunar), isLunar: ('isLunar' in anniv ? (anniv.isLunar ? 1 : 0) : existing.isLunar),
remindEnabled: ('remindEnabled' in anniv ? (anniv.remindEnabled ? 1 : 0) : existing.remindEnabled), remindEnabled: ('remindEnabled' in anniv ? (anniv.remindEnabled ? 1 : 0) : existing.remindEnabled),
isLeapMonth: ('isLeapMonth' in anniv ? (anniv.isLeapMonth ? 1 : 0) : existing.isLeapMonth),
updateTime: Date.now() updateTime: Date.now()
} }
const sets = FIELDS.filter(f => f !== 'id' && f !== 'openid' && f !== 'createTime') 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) } 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) => { app.post('/api/reminder/run', async (req, res) => {
try { 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 cron = require('node-cron')
const db = require('./db') const db = require('./db')
const wx = require('./wx') const wx = require('./wx')
const lunar = require('./lunar')
const TEMPLATE_ID = process.env.WX_TEMPLATE_ID const TEMPLATE_ID = process.env.WX_TEMPLATE_ID
const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal' const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal'
@@ -26,9 +27,24 @@ function formatDate(date) {
} }
// 算出"距离今年(或明年)这个纪念日还有多少天" // 算出"距离今年(或明年)这个纪念日还有多少天"
// 农历纪念日按 lunarMonth/lunarDay 计算每年对应的公历日期,否则按 solarMonth/solarDay
function getThisYearDate(anniv) { function getThisYearDate(anniv) {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) 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) let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay)
target.setHours(0, 0, 0, 0) target.setHours(0, 0, 0, 0)
if (target < today) { if (target < today) {
+6 -1
View File
@@ -67,4 +67,9 @@ function anniversary(action, data) {
return request({ url: '/api/anniversary', data: { 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
}
+15 -13
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 = [ const lunarInfo = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
0x06566,0x0d4a0,0x0ea50,0x06e95,0x05ad0,0x02b60,0x186e3,0x092e0,0x1c8d7,0x0c950, // 1930-1939 0x06566, 0x0d4a0, 0x0ea50, 0x16a95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
0x04af5,0x04970,0x064b0,0x074a3,0x0ea50,0x06aa0,0x0a6b6,0x056a0,0x02b60,0x09570, // 1990-1999 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
0x049b0,0x0a4b0,0x0aa50,0x1b255,0x06d40,0x0ad50,0x14b55,0x056a0,0x0a6d0,0x055d4, // 2000-2009 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
0x052d0,0x0a9b8,0x0a950,0x0b4a0,0x0b6a6,0x0ad50,0x055a0,0x0aba4,0x0a5b0,0x052b0, // 2010-2019 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
0x0b273,0x06930,0x07337,0x06aa0,0x0ad50,0x14b55,0x04b60,0x0a570,0x054e4,0x0d160, // 2020-2029 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
0x0e968,0x0d520,0x0daa0,0x16aa6,0x056d0,0x04ae0,0x0a9d4,0x0a2d0,0x0d150,0x0f252, // 2030-2039 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
0x0d520,0x0daa0,0x16aa6,0x056d0,0x04ae0,0x0a9d4,0x0a2d0,0x0d150,0x0f252,0x0d520, // 2040-2049 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
0x0b54f,0x0d6a0,0x0ada0,0x14955,0x056a0,0x0a6d0,0x0155b,0x025d0,0x092d0,0x0d954, // 2050-2059 0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, // 2050-2059
0x0d4a0,0x0b550,0x0b4a9,0x04da0,0x0a5b0,0x15176,0x052b0,0x0a930,0x07954,0x06aa0, // 2060-2069 0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
0x0ad50,0x05b52,0x04b60,0x0a6e6,0x0a4e0,0x0d260,0x0ea65,0x0d530,0x05aa0,0x076a3, // 2070-2079 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
0x096d0,0x04afb,0x04ad0,0x0a4d0,0x1d0b6,0x0d250,0x0d520,0x0dd45,0x0b5a0,0x056d0, // 2080-2089 0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
0x055b2,0x049b0,0x0a577,0x0a4b0,0x0aa50,0x1b255,0x06d20,0x0ada0,0x14b63,0x09370 // 2090-2099 0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
0x0d520 // 2100
] ]
// 1900年1月31日是农历正月初一(庚子年) // 1900年1月31日是农历正月初一(庚子年)
+66 -7
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 = { const _cache = {
persons: null, persons: null,
@@ -61,7 +71,9 @@ function addPerson(person) {
} }
persons.push(newPerson) persons.push(newPerson)
const result = savePersons(persons) 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) { function updatePerson(id, updates) {
const persons = getPersons() const persons = getPersons()
const index = persons.findIndex(p => p.id === id) const index = persons.findIndex(p => p.id === id)
if (index !== -1) { if (index === -1) return false
persons[index] = { ...persons[index], ...updates, updateTime: new Date().getTime() } persons[index] = { ...persons[index], ...updates, updateTime: new Date().getTime() }
return savePersons(persons).success const ok = savePersons(persons).success
if (ok) _syncPerson('update', persons[index])
return ok
} }
return false
/**
* 按姓名查找已有人员;找不到则创建一个新的 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) { function deletePerson(id) {
const persons = getPersons() 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) wx.setStorageSync('anniversaries', anniversaries)
_cache.persons = persons _cache.persons = persons
_cache.anniversaries = anniversaries _cache.anniversaries = anniversaries
// 后端 delete person 会级联删除该 personId 的所有 anniversaries(在事务里),无需单独再调
_syncPerson('delete', { id: personId })
return true return true
} catch (e) { } catch (e) {
console.error('删除人员及纪念日失败', 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, getPersons,
getPersonById, getPersonById,
addPerson, addPerson,
ensurePerson,
updatePerson, updatePerson,
deletePerson, deletePerson,
deletePersonWithAnniversaries, deletePersonWithAnniversaries,
@@ -273,5 +331,6 @@ module.exports = {
// 工具函数 // 工具函数
exportData, exportData,
importData, 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 }