diff --git a/MAINTENANCE.md b/MAINTENANCE.md new file mode 100644 index 0000000..8217b7d --- /dev/null +++ b/MAINTENANCE.md @@ -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 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]] diff --git a/app.js b/app.js index 5639c17..0ad6a3d 100644 --- a/app.js +++ b/app.js @@ -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: { diff --git a/pages/add-anniversary/add-anniversary.js b/pages/add-anniversary/add-anniversary.js index d2669ad..895ba2d 100644 --- a/pages/add-anniversary/add-anniversary.js +++ b/pages/add-anniversary/add-anniversary.js @@ -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 } }) } }) diff --git a/pages/add-anniversary/add-anniversary.wxml b/pages/add-anniversary/add-anniversary.wxml index bd9dc9f..1cf75b0 100644 --- a/pages/add-anniversary/add-anniversary.wxml +++ b/pages/add-anniversary/add-anniversary.wxml @@ -1,16 +1,20 @@ - + - 关联人员 - - - {{selectedPerson}} - 请选择关联人员 - - - + 为谁记录 + + + {{item.name}} + @@ -49,6 +53,14 @@ + + + 对应农历:{{lunarText}} + ⚠ 闰月 + + + 该日期属于闰月。无闰月的年份将按对应普通月份提醒(例如闰二月初一 → 二月初一)。 + diff --git a/pages/add-anniversary/add-anniversary.wxss b/pages/add-anniversary/add-anniversary.wxss index 810aa07..33001c9 100644 --- a/pages/add-anniversary/add-anniversary.wxss +++ b/pages/add-anniversary/add-anniversary.wxss @@ -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; diff --git a/pages/index/index.js b/pages/index/index.js index 7c67069..b855045 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -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' }) } }) diff --git a/pages/person-detail/person-detail.js b/pages/person-detail/person-detail.js index 397ace4..abe0089 100644 --- a/pages/person-detail/person-detail.js +++ b/pages/person-detail/person-detail.js @@ -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] || '一般' - }, - /** * 编辑人员 */ diff --git a/server/src/db.js b/server/src/db.js index 52c0e1e..2bf4f0c 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -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 diff --git a/server/src/index.js b/server/src/index.js index b0705db..6fcffad 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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 { diff --git a/server/src/lunar.js b/server/src/lunar.js new file mode 100644 index 0000000..cd86e11 --- /dev/null +++ b/server/src/lunar.js @@ -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 +} diff --git a/server/src/reminder.js b/server/src/reminder.js index 38b051e..f4b43a9 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -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) { diff --git a/utils/api.js b/utils/api.js index 6c26ac0..72dacbc 100644 --- a/utils/api.js +++ b/utils/api.js @@ -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 } diff --git a/utils/format.js b/utils/format.js new file mode 100644 index 0000000..daefc05 --- /dev/null +++ b/utils/format.js @@ -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 +} diff --git a/utils/lunar.js b/utils/lunar.js index c189129..cd86e11 100644 --- a/utils/lunar.js +++ b/utils/lunar.js @@ -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日是农历正月初一(庚子年) diff --git a/utils/storage.js b/utils/storage.js index 833a038..230ebf1 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -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 } diff --git a/utils/sync.js b/utils/sync.js new file mode 100644 index 0000000..03cd601 --- /dev/null +++ b/utils/sync.js @@ -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 }