- 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:
+241
@@ -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:8080,Host 透传
|
||||
frps (vhost_http_port: 8080;公开 vhost_https_port: 4430 但本项目不用)
|
||||
↓ frp 隧道(subdomain=wxserver)
|
||||
frpc (软路由 192.168.1.119,UCI `/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:3000,trial/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` — 微信 API(access_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 → 自动部署 → 开发者工具上传新版 → 设体验版
|
||||
|
||||
### 场景 11:DB 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=http,IP=127.0.0.1,Port=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]]
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
this.setData({
|
||||
personIndex: index,
|
||||
personId: options.personId,
|
||||
selectedPerson: persons[index].name
|
||||
// 从人员详情页进入,预选关联人员
|
||||
const person = persons.find(p => p.id === options.personId)
|
||||
if (person) {
|
||||
this.setData({
|
||||
personId: person.id,
|
||||
inputName: person.name
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -75,17 +78,23 @@ Page({
|
||||
new Date(anniversary.solarYear, anniversary.solarMonth - 1, anniversary.solarDay),
|
||||
'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()
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -157,13 +173,36 @@ Page({
|
||||
onDateChange(e) {
|
||||
const dateStr = e.detail.value
|
||||
const parts = dateStr.split('-')
|
||||
|
||||
|
||||
this.setData({
|
||||
dateValue: dateStr,
|
||||
'formData.solarYear': parseInt(parts[0]),
|
||||
'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 } })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 重要程度 -->
|
||||
|
||||
@@ -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
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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] || '一般'
|
||||
},
|
||||
|
||||
/**
|
||||
* 编辑人员
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user