- 新增 server/:Node + Express + SQLite + node-cron 实现登录、纪念日 CRUD 和定时订阅消息推送 - 新增 .gitea/workflows/deploy.yml:推送即触发群晖 Docker 部署,监听 15002 - utils/api.js:自动按 envVersion 切换本地/线上 BASE_URL - app.js 与 add-anniversary.js 移除 wx.cloud 调用,改走自建后端 - cloudfunctions/ 暂保留以便回滚 - 一并提交此前未入库的首页 / 设置页 / 日历 / 万年历等改造 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
name: 部署到群晖
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 安装 Docker CLI
|
||||||
|
run: |
|
||||||
|
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||||
|
sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker.io
|
||||||
|
|
||||||
|
- name: 拉取代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 构建镜像
|
||||||
|
run: docker build -t birthday-server:latest ./server
|
||||||
|
|
||||||
|
- name: 停止旧容器
|
||||||
|
run: |
|
||||||
|
docker stop birthday-server 2>/dev/null || true
|
||||||
|
docker rm birthday-server 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: 准备持久化目录
|
||||||
|
run: mkdir -p /volume1/docker/apps/birthday-server/data
|
||||||
|
|
||||||
|
- name: 启动新容器
|
||||||
|
env:
|
||||||
|
WX_APPID: ${{ secrets.WX_APPID }}
|
||||||
|
WX_APPSECRET: ${{ secrets.WX_APPSECRET }}
|
||||||
|
WX_TEMPLATE_ID: ${{ secrets.WX_TEMPLATE_ID }}
|
||||||
|
WX_MINIPROGRAM_STATE: ${{ secrets.WX_MINIPROGRAM_STATE }}
|
||||||
|
REMINDER_CRON: ${{ secrets.REMINDER_CRON }}
|
||||||
|
run: |
|
||||||
|
docker run -d \
|
||||||
|
--name birthday-server \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 15002:3000 \
|
||||||
|
-v /volume1/docker/apps/birthday-server/data:/app/data \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-e WX_APPID="$WX_APPID" \
|
||||||
|
-e WX_APPSECRET="$WX_APPSECRET" \
|
||||||
|
-e WX_TEMPLATE_ID="${WX_TEMPLATE_ID:-6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw}" \
|
||||||
|
-e WX_MINIPROGRAM_STATE="${WX_MINIPROGRAM_STATE:-formal}" \
|
||||||
|
-e REMINDER_CRON="${REMINDER_CRON:-0 9 * * *}" \
|
||||||
|
birthday-server:latest
|
||||||
|
|
||||||
|
- name: 部署完成提示
|
||||||
|
run: echo "部署完成,访问 http://192.168.1.66:15002/api/health"
|
||||||
@@ -12,3 +12,8 @@ $RECYCLE.BIN/
|
|||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# 后端运行时数据 & 敏感配置
|
||||||
|
server/.env
|
||||||
|
server/data/
|
||||||
|
server/node_modules/
|
||||||
|
|||||||
@@ -1,27 +1,44 @@
|
|||||||
|
const api = require('./utils/api')
|
||||||
|
|
||||||
App({
|
App({
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
// 展示本地存储能力
|
|
||||||
const logs = wx.getStorageSync('logs') || []
|
const logs = wx.getStorageSync('logs') || []
|
||||||
logs.unshift(Date.now())
|
logs.unshift(Date.now())
|
||||||
wx.setStorageSync('logs', logs)
|
wx.setStorageSync('logs', logs)
|
||||||
|
|
||||||
// 初始化数据
|
|
||||||
this.initData()
|
this.initData()
|
||||||
|
this.getUserOpenId()
|
||||||
},
|
},
|
||||||
|
|
||||||
// 初始化数据结构
|
|
||||||
initData() {
|
initData() {
|
||||||
// 检查是否有旧数据
|
|
||||||
const persons = wx.getStorageSync('persons') || []
|
const persons = wx.getStorageSync('persons') || []
|
||||||
const anniversaries = wx.getStorageSync('anniversaries') || []
|
const anniversaries = wx.getStorageSync('anniversaries') || []
|
||||||
|
|
||||||
if (persons.length === 0 && anniversaries.length === 0) {
|
if (persons.length === 0 && anniversaries.length === 0) {
|
||||||
console.log('初始化数据结构')
|
console.log('初始化数据结构')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 获取 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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
globalData: {
|
globalData: {
|
||||||
userInfo: null
|
userInfo: null,
|
||||||
|
openid: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,27 +16,21 @@
|
|||||||
},
|
},
|
||||||
"tabBar": {
|
"tabBar": {
|
||||||
"color": "#7A7E83",
|
"color": "#7A7E83",
|
||||||
"selectedColor": "#3cc51f",
|
"selectedColor": "#07c160",
|
||||||
"borderStyle": "black",
|
"borderStyle": "black",
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "#ffffff",
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"pagePath": "pages/index/index",
|
"pagePath": "pages/index/index",
|
||||||
"iconPath": "/images/home.png",
|
"text": "🏠 首页"
|
||||||
"selectedIconPath": "/images/home-active.png",
|
|
||||||
"text": "首页"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pagePath": "pages/calendar/calendar",
|
"pagePath": "pages/calendar/calendar",
|
||||||
"iconPath": "/images/calendar.png",
|
"text": "📅 日历"
|
||||||
"selectedIconPath": "/images/calendar-active.png",
|
|
||||||
"text": "日历"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pagePath": "pages/settings/settings",
|
"pagePath": "pages/settings/settings",
|
||||||
"iconPath": "/images/settings.png",
|
"text": "⚙️ 设置"
|
||||||
"selectedIconPath": "/images/settings-active.png",
|
|
||||||
"text": "设置"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
/* 全局样式 */
|
/* 全局样式 */
|
||||||
page {
|
page {
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
@@ -23,44 +24,53 @@ page {
|
|||||||
|
|
||||||
/* 按钮样式 */
|
/* 按钮样式 */
|
||||||
.btn {
|
.btn {
|
||||||
border-radius: 8rpx;
|
border-radius: 12rpx;
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
padding: 24rpx 48rpx;
|
padding: 24rpx 48rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: #07c160;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
color: #333;
|
color: #333;
|
||||||
border: 1px solid #ddd;
|
border: 2rpx solid #e8e8e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 卡片样式 */
|
/* 卡片样式 */
|
||||||
.card {
|
.card {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 16rpx;
|
border-radius: 24rpx;
|
||||||
padding: 32rpx;
|
padding: 32rpx;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 输入框样式 */
|
/* 输入框样式 */
|
||||||
.input {
|
.input {
|
||||||
background-color: #fff;
|
background-color: #f8f9fa;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: 12rpx;
|
||||||
border: 1px solid #e0e0e0;
|
border: 2rpx solid transparent;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分割线 */
|
/* 分割线 */
|
||||||
.divider {
|
.divider {
|
||||||
height: 1rpx;
|
height: 1rpx;
|
||||||
background-color: #f0f0f0;
|
background: linear-gradient(90deg, transparent, #e0e0e0, transparent);
|
||||||
margin: 32rpx 0;
|
margin: 32rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,17 +78,20 @@ page {
|
|||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 120rpx 40rpx;
|
padding: 120rpx 40rpx;
|
||||||
color: #999;
|
color: rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state .icon {
|
.empty-state .icon {
|
||||||
font-size: 120rpx;
|
font-size: 140rpx;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: 32rpx;
|
||||||
|
filter: drop-shadow(0 4rpx 8rpx rgba(0, 0, 0, 0.1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state .text {
|
.empty-state .text {
|
||||||
font-size: 28rpx;
|
font-size: 32rpx;
|
||||||
color: #999;
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 浮动按钮 */
|
/* 浮动按钮 */
|
||||||
@@ -86,16 +99,22 @@ page {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 120rpx;
|
bottom: 120rpx;
|
||||||
right: 40rpx;
|
right: 40rpx;
|
||||||
width: 100rpx;
|
width: 112rpx;
|
||||||
height: 100rpx;
|
height: 112rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: #07c160;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 60rpx;
|
font-size: 64rpx;
|
||||||
box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
|
box-shadow: 0 12rpx 40rpx rgba(102, 126, 234, 0.5);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"openapi": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// 云函数:获取用户openid
|
||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
|
||||||
|
cloud.init({
|
||||||
|
env: cloud.DYNAMIC_CURRENT_ENV
|
||||||
|
})
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const wxContext = cloud.getWXContext()
|
||||||
|
|
||||||
|
return {
|
||||||
|
openid: wxContext.OPENID,
|
||||||
|
appid: wxContext.APPID,
|
||||||
|
unionid: wxContext.UNIONID,
|
||||||
|
env: wxContext.ENV
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "login",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "获取用户openid",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"wx-server-sdk": "~2.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"openapi": [
|
||||||
|
"subscribeMessage.send"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"name": "dailyReminder",
|
||||||
|
"type": "timer",
|
||||||
|
"config": "0 0 9 * * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// 云函数:发送生日提醒
|
||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
|
||||||
|
cloud.init({
|
||||||
|
env: cloud.DYNAMIC_CURRENT_ENV
|
||||||
|
})
|
||||||
|
|
||||||
|
const db = cloud.database()
|
||||||
|
|
||||||
|
// 模板ID
|
||||||
|
const TEMPLATE_ID = '6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个日期之间的天数差
|
||||||
|
*/
|
||||||
|
function getDaysUntil(targetDate) {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const target = new Date(targetDate)
|
||||||
|
target.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const diffTime = target - today
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
return diffDays
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期
|
||||||
|
*/
|
||||||
|
function formatDate(date) {
|
||||||
|
const d = new Date(date)
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${year}年${month}月${day}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_NAMES = {
|
||||||
|
birthday: '公历生日',
|
||||||
|
lunar_birthday: '农历生日',
|
||||||
|
wedding: '结婚纪念日',
|
||||||
|
engagement: '订婚纪念日',
|
||||||
|
other: '其他纪念日'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取类型名称
|
||||||
|
*/
|
||||||
|
function getTypeName(type, customName) {
|
||||||
|
if (type === 'other' && customName) return customName
|
||||||
|
return TYPE_NAMES[type] || '纪念日'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今年的纪念日日期
|
||||||
|
*/
|
||||||
|
function getThisYearDate(anniversary) {
|
||||||
|
const today = new Date()
|
||||||
|
const currentYear = today.getFullYear()
|
||||||
|
|
||||||
|
// 使用公历日期计算
|
||||||
|
let thisYearDate = new Date(currentYear, anniversary.solarMonth - 1, anniversary.solarDay)
|
||||||
|
|
||||||
|
// 如果今年的已经过了,计算明年的
|
||||||
|
const daysUntil = getDaysUntil(thisYearDate)
|
||||||
|
if (daysUntil < 0) {
|
||||||
|
thisYearDate = new Date(currentYear + 1, anniversary.solarMonth - 1, anniversary.solarDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return thisYearDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主函数
|
||||||
|
*/
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
console.log('开始执行提醒任务...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取所有启用提醒的纪念日
|
||||||
|
const anniversariesRes = await db.collection('anniversaries')
|
||||||
|
.where({
|
||||||
|
remindEnabled: true
|
||||||
|
})
|
||||||
|
.get()
|
||||||
|
|
||||||
|
console.log(`找到 ${anniversariesRes.data.length} 条启用提醒的纪念日`)
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
|
|
||||||
|
// 遍历每个纪念日
|
||||||
|
for (const anniversary of anniversariesRes.data) {
|
||||||
|
try {
|
||||||
|
// 计算今年的纪念日日期
|
||||||
|
const thisYearDate = getThisYearDate(anniversary)
|
||||||
|
const daysUntil = getDaysUntil(thisYearDate)
|
||||||
|
|
||||||
|
console.log(`${anniversary.personName} 的 ${getTypeName(anniversary.type, anniversary.customTypeName)},还有 ${daysUntil} 天`)
|
||||||
|
|
||||||
|
// 判断是否需要提醒
|
||||||
|
const shouldRemind = (
|
||||||
|
daysUntil === 0 || // 当天
|
||||||
|
daysUntil === anniversary.remindDays // 提前N天
|
||||||
|
)
|
||||||
|
|
||||||
|
if (shouldRemind) {
|
||||||
|
// 检查今天是否已经发送过提醒
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const logRes = await db.collection('remind_logs')
|
||||||
|
.where({
|
||||||
|
anniversaryId: anniversary._id,
|
||||||
|
sendDate: db.command.gte(today)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
|
||||||
|
if (logRes.total > 0) {
|
||||||
|
console.log(`今天已发送过提醒,跳过: ${anniversary.personName}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送订阅消息
|
||||||
|
const sendRes = await cloud.openapi.subscribeMessage.send({
|
||||||
|
touser: anniversary.openid,
|
||||||
|
page: 'pages/index/index',
|
||||||
|
data: {
|
||||||
|
name1: {
|
||||||
|
value: anniversary.personName
|
||||||
|
},
|
||||||
|
thing2: {
|
||||||
|
value: daysUntil === 0 ? '今天' : `还有${daysUntil}天`
|
||||||
|
},
|
||||||
|
thing6: {
|
||||||
|
value: formatDate(thisYearDate)
|
||||||
|
},
|
||||||
|
thing5: {
|
||||||
|
value: anniversary.remark || '别忘了准备一份礼物哦!'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
templateId: TEMPLATE_ID,
|
||||||
|
miniprogramState: 'formal' // 正式版
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`发送成功: ${anniversary.personName}`, sendRes)
|
||||||
|
|
||||||
|
// 记录提醒日志
|
||||||
|
await db.collection('remind_logs').add({
|
||||||
|
data: {
|
||||||
|
anniversaryId: anniversary._id,
|
||||||
|
personName: anniversary.personName,
|
||||||
|
typeName: getTypeName(anniversary.type, anniversary.customTypeName),
|
||||||
|
daysUntil: daysUntil,
|
||||||
|
sendDate: new Date(),
|
||||||
|
status: 'success'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`发送失败: ${anniversary.personName}`, err)
|
||||||
|
failCount++
|
||||||
|
|
||||||
|
// 记录失败日志
|
||||||
|
await db.collection('remind_logs').add({
|
||||||
|
data: {
|
||||||
|
anniversaryId: anniversary._id,
|
||||||
|
personName: anniversary.personName,
|
||||||
|
sendDate: new Date(),
|
||||||
|
status: 'failed',
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`提醒任务完成: 成功${successCount}条,失败${failCount}条`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
total: anniversariesRes.data.length,
|
||||||
|
successCount,
|
||||||
|
failCount
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('提醒任务执行失败:', err)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+1525
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "sendReminder",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "发送生日提醒订阅消息",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"wx-server-sdk": "~2.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"openapi": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// 云函数:同步纪念日数据到云端
|
||||||
|
const cloud = require('wx-server-sdk')
|
||||||
|
|
||||||
|
cloud.init({
|
||||||
|
env: cloud.DYNAMIC_CURRENT_ENV
|
||||||
|
})
|
||||||
|
|
||||||
|
const db = cloud.database()
|
||||||
|
|
||||||
|
exports.main = async (event, context) => {
|
||||||
|
const wxContext = cloud.getWXContext()
|
||||||
|
const openid = wxContext.OPENID
|
||||||
|
|
||||||
|
const { action, data } = event
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case 'add':
|
||||||
|
// 添加纪念日
|
||||||
|
return await addAnniversary(openid, data)
|
||||||
|
|
||||||
|
case 'update':
|
||||||
|
// 更新纪念日
|
||||||
|
return await updateAnniversary(openid, data)
|
||||||
|
|
||||||
|
case 'delete':
|
||||||
|
// 删除纪念日
|
||||||
|
return await deleteAnniversary(openid, data.id)
|
||||||
|
|
||||||
|
case 'sync':
|
||||||
|
// 批量同步
|
||||||
|
return await syncAnniversaries(openid, data)
|
||||||
|
|
||||||
|
case 'get':
|
||||||
|
// 获取用户所有纪念日
|
||||||
|
return await getAnniversaries(openid)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '未知操作'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('操作失败:', err)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加纪念日
|
||||||
|
*/
|
||||||
|
async function addAnniversary(openid, anniversary) {
|
||||||
|
const res = await db.collection('anniversaries').add({
|
||||||
|
data: {
|
||||||
|
...anniversary,
|
||||||
|
openid,
|
||||||
|
createTime: db.serverDate(),
|
||||||
|
updateTime: db.serverDate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
id: res._id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新纪念日
|
||||||
|
*/
|
||||||
|
async function updateAnniversary(openid, anniversary) {
|
||||||
|
const { id, ...updateData } = anniversary
|
||||||
|
|
||||||
|
await db.collection('anniversaries')
|
||||||
|
.where({
|
||||||
|
_id: id,
|
||||||
|
openid
|
||||||
|
})
|
||||||
|
.update({
|
||||||
|
data: {
|
||||||
|
...updateData,
|
||||||
|
updateTime: db.serverDate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除纪念日
|
||||||
|
*/
|
||||||
|
async function deleteAnniversary(openid, id) {
|
||||||
|
await db.collection('anniversaries')
|
||||||
|
.where({
|
||||||
|
_id: id,
|
||||||
|
openid
|
||||||
|
})
|
||||||
|
.remove()
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量同步纪念日
|
||||||
|
*/
|
||||||
|
async function syncAnniversaries(openid, anniversaries) {
|
||||||
|
// 先删除用户的所有纪念日
|
||||||
|
await db.collection('anniversaries')
|
||||||
|
.where({
|
||||||
|
openid
|
||||||
|
})
|
||||||
|
.remove()
|
||||||
|
|
||||||
|
// 批量添加
|
||||||
|
const tasks = anniversaries.map(anniversary => {
|
||||||
|
return db.collection('anniversaries').add({
|
||||||
|
data: {
|
||||||
|
...anniversary,
|
||||||
|
openid,
|
||||||
|
createTime: db.serverDate(),
|
||||||
|
updateTime: db.serverDate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(tasks)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
count: anniversaries.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有纪念日
|
||||||
|
*/
|
||||||
|
async function getAnniversaries(openid) {
|
||||||
|
const res = await db.collection('anniversaries')
|
||||||
|
.where({
|
||||||
|
openid
|
||||||
|
})
|
||||||
|
.get()
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: res.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "syncAnniversary",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "同步纪念日数据",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"wx-server-sdk": "~2.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// add-anniversary.js
|
// add-anniversary.js
|
||||||
const storage = require('../../utils/storage')
|
const storage = require('../../utils/storage')
|
||||||
const dateUtils = require('../../utils/date')
|
const dateUtils = require('../../utils/date')
|
||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
@@ -188,11 +189,32 @@ Page({
|
|||||||
*/
|
*/
|
||||||
onRemindDaysChange(e) {
|
onRemindDaysChange(e) {
|
||||||
const index = parseInt(e.detail.value)
|
const index = parseInt(e.detail.value)
|
||||||
const days = [3, 7, 14, 30, 7][index]
|
this.setData({ remindDaysIndex: index })
|
||||||
this.setData({
|
if (index === 4) {
|
||||||
remindDaysIndex: index,
|
// 自定义天数
|
||||||
'formData.remindDays': days
|
wx.showModal({
|
||||||
})
|
title: '自定义提前天数',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入天数(1-365)',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
const days = parseInt(res.content)
|
||||||
|
if (!days || days < 1 || days > 365) {
|
||||||
|
wx.showToast({ title: '请输入1-365之间的天数', icon: 'none' })
|
||||||
|
this.setData({ remindDaysIndex: 1, 'formData.remindDays': 7 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ 'formData.remindDays': days })
|
||||||
|
} else {
|
||||||
|
// 取消则回到默认7天
|
||||||
|
this.setData({ remindDaysIndex: 1, 'formData.remindDays': 7 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const days = [3, 7, 14, 30][index]
|
||||||
|
this.setData({ 'formData.remindDays': days })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,8 +236,8 @@ Page({
|
|||||||
/**
|
/**
|
||||||
* 提交
|
* 提交
|
||||||
*/
|
*/
|
||||||
onSubmit() {
|
async onSubmit() {
|
||||||
const { formData, personId, anniversaryId } = this.data
|
const { formData, personId, anniversaryId, personList } = this.data
|
||||||
|
|
||||||
// 验证关联人员
|
// 验证关联人员
|
||||||
if (!personId) {
|
if (!personId) {
|
||||||
@@ -235,14 +257,36 @@ Page({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果开启了提醒,请求订阅消息授权
|
||||||
|
if (formData.remindEnabled) {
|
||||||
|
try {
|
||||||
|
await this.requestSubscribe()
|
||||||
|
} catch (err) {
|
||||||
|
console.log('用户拒绝订阅消息')
|
||||||
|
// 继续保存,即使用户拒绝订阅
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取人员名称
|
||||||
|
const person = personList.find(p => p.id === personId)
|
||||||
|
const personName = person ? person.name : ''
|
||||||
|
|
||||||
if (anniversaryId) {
|
if (anniversaryId) {
|
||||||
// 编辑模式
|
// 编辑模式
|
||||||
const success = storage.updateAnniversary(anniversaryId, {
|
const success = storage.updateAnniversary(anniversaryId, {
|
||||||
personId,
|
personId,
|
||||||
|
personName,
|
||||||
...formData
|
...formData
|
||||||
})
|
})
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
// 同步到云端
|
||||||
|
this.syncToCloud(anniversaryId, {
|
||||||
|
personId,
|
||||||
|
personName,
|
||||||
|
...formData
|
||||||
|
}, 'update')
|
||||||
|
|
||||||
wx.showToast({ title: '保存成功', icon: 'success' })
|
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||||
setTimeout(() => wx.navigateBack(), 1500)
|
setTimeout(() => wx.navigateBack(), 1500)
|
||||||
}
|
}
|
||||||
@@ -250,14 +294,55 @@ Page({
|
|||||||
// 新增模式
|
// 新增模式
|
||||||
const newAnniversary = storage.addAnniversary({
|
const newAnniversary = storage.addAnniversary({
|
||||||
personId,
|
personId,
|
||||||
|
personName,
|
||||||
...formData
|
...formData
|
||||||
})
|
})
|
||||||
|
|
||||||
if (newAnniversary) {
|
if (newAnniversary) {
|
||||||
|
// 同步到云端
|
||||||
|
this.syncToCloud(newAnniversary.id, newAnniversary, 'add')
|
||||||
|
|
||||||
wx.showToast({ title: '添加成功', icon: 'success' })
|
wx.showToast({ title: '添加成功', icon: 'success' })
|
||||||
setTimeout(() => wx.navigateBack(), 1500)
|
setTimeout(() => wx.navigateBack(), 1500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求订阅消息授权
|
||||||
|
*/
|
||||||
|
requestSubscribe() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.requestSubscribeMessage({
|
||||||
|
tmplIds: ['6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'],
|
||||||
|
success: (res) => {
|
||||||
|
console.log('订阅消息授权结果:', res)
|
||||||
|
resolve(res)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('订阅消息授权失败:', err)
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步到自建后端
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
// 不影响本地保存
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
padding: 32rpx;
|
padding: 0 32rpx 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-item {
|
.form-item {
|
||||||
@@ -15,6 +15,11 @@
|
|||||||
padding: 32rpx;
|
padding: 32rpx;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item:first-child {
|
||||||
|
margin-top: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
@@ -100,36 +105,57 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
min-height: 160rpx;
|
min-height: 160rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
margin-top: 40rpx;
|
margin-top: 40rpx;
|
||||||
|
padding: 0 32rpx 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 88rpx;
|
height: 96rpx;
|
||||||
line-height: 88rpx;
|
line-height: 96rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 48rpx;
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
border: none;
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf0 100%);
|
||||||
color: #666;
|
color: #666;
|
||||||
|
border: 2rpx solid rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel::after,
|
.btn-cancel::after,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**add-person.wxss**/
|
/**add-person.wxss**/
|
||||||
.container {
|
.container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(180deg, rgba(102, 126, 234, 0.08) 0%, transparent 30%);
|
||||||
padding-bottom: 120rpx;
|
padding-bottom: 120rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,16 +10,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-item {
|
.form-item {
|
||||||
background-color: #fff;
|
background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
|
||||||
border-radius: 16rpx;
|
border-radius: 24rpx;
|
||||||
padding: 32rpx;
|
padding: 36rpx;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
box-shadow: 0 8rpx 32rpx rgba(102, 126, 234, 0.12);
|
||||||
|
border: 2rpx solid rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #333;
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -27,6 +32,7 @@
|
|||||||
.label.required::after {
|
.label.required::after {
|
||||||
content: ' *';
|
content: ' *';
|
||||||
color: #ff5722;
|
color: #ff5722;
|
||||||
|
-webkit-text-fill-color: #ff5722;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 头像上传 */
|
/* 头像上传 */
|
||||||
@@ -38,7 +44,9 @@
|
|||||||
width: 200rpx;
|
width: 200rpx;
|
||||||
height: 200rpx;
|
height: 200rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: #f0f0f0;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: 6rpx solid rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder {
|
.avatar-placeholder {
|
||||||
@@ -46,69 +54,107 @@
|
|||||||
height: 200rpx;
|
height: 200rpx;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 2px dashed #ddd;
|
border: 4rpx dashed rgba(102, 126, 234, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder .icon {
|
.avatar-placeholder .icon {
|
||||||
font-size: 60rpx;
|
font-size: 64rpx;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
|
filter: grayscale(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder .text {
|
.avatar-placeholder .text {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #999;
|
color: #667eea;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
background-color: #f5f5f5;
|
background-color: #f8f9fa;
|
||||||
border-radius: 8rpx;
|
border-radius: 12rpx;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
background-color: #f5f5f5;
|
background-color: #f8f9fa;
|
||||||
border-radius: 8rpx;
|
border-radius: 12rpx;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
min-height: 160rpx;
|
min-height: 160rpx;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:focus {
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
margin-top: 40rpx;
|
margin-top: 40rpx;
|
||||||
|
padding: 0 32rpx 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 88rpx;
|
height: 96rpx;
|
||||||
line-height: 88rpx;
|
line-height: 96rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 48rpx;
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
border: none;
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf0 100%);
|
||||||
color: #666;
|
color: #666;
|
||||||
|
border: 2rpx solid rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel::after {
|
.btn-submit:active {
|
||||||
border: none;
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-cancel::after,
|
||||||
.btn-submit::after {
|
.btn-submit::after {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-68
@@ -1,11 +1,12 @@
|
|||||||
// calendar.js
|
// calendar.js
|
||||||
const storage = require('../../utils/storage')
|
const storage = require('../../utils/storage')
|
||||||
const dateUtils = require('../../utils/date')
|
const dateUtils = require('../../utils/date')
|
||||||
|
const { TYPE_NAMES, TYPE_ICONS, IMPORTANCE_COLORS } = require('../../utils/constants')
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
currentYear: 2024,
|
currentYear: new Date().getFullYear(),
|
||||||
currentMonth: 1,
|
currentMonth: new Date().getMonth() + 1,
|
||||||
calendarDays: [],
|
calendarDays: [],
|
||||||
monthEvents: []
|
monthEvents: []
|
||||||
},
|
},
|
||||||
@@ -20,6 +21,21 @@ Page({
|
|||||||
this.loadMonthEvents()
|
this.loadMonthEvents()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建当月事件索引 { "day": [anniversary, ...] }
|
||||||
|
*/
|
||||||
|
buildEventIndex(anniversaries, year, month) {
|
||||||
|
const index = {}
|
||||||
|
for (const a of anniversaries) {
|
||||||
|
if (a.solarYear === year && a.solarMonth === month) {
|
||||||
|
const key = String(a.solarDay)
|
||||||
|
if (!index[key]) index[key] = []
|
||||||
|
index[key].push(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染日历
|
* 渲染日历
|
||||||
*/
|
*/
|
||||||
@@ -27,93 +43,53 @@ Page({
|
|||||||
const { currentYear, currentMonth } = this.data
|
const { currentYear, currentMonth } = this.data
|
||||||
const anniversaries = storage.getAnniversaries()
|
const anniversaries = storage.getAnniversaries()
|
||||||
|
|
||||||
// 获取本月第一天是星期几
|
// 预建事件索引,避免 O(n×m) 过滤
|
||||||
|
const eventIndex = this.buildEventIndex(anniversaries, currentYear, currentMonth)
|
||||||
|
|
||||||
const firstDay = new Date(currentYear, currentMonth - 1, 1)
|
const firstDay = new Date(currentYear, currentMonth - 1, 1)
|
||||||
const startWeekday = firstDay.getDay()
|
const startWeekday = firstDay.getDay()
|
||||||
|
|
||||||
// 获取本月天数
|
|
||||||
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate()
|
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate()
|
||||||
|
|
||||||
// 获取上个月的天数
|
|
||||||
const prevMonthDays = new Date(currentYear, currentMonth - 1, 0).getDate()
|
const prevMonthDays = new Date(currentYear, currentMonth - 1, 0).getDate()
|
||||||
|
|
||||||
// 构建日历天数数组
|
|
||||||
const calendarDays = []
|
const calendarDays = []
|
||||||
const today = new Date()
|
|
||||||
|
|
||||||
// 填充上个月的日期
|
// 填充上个月的日期
|
||||||
for (let i = startWeekday - 1; i >= 0; i--) {
|
for (let i = startWeekday - 1; i >= 0; i--) {
|
||||||
const day = prevMonthDays - i
|
const day = prevMonthDays - i
|
||||||
calendarDays.push({
|
calendarDays.push({ day, isToday: false, isOtherMonth: true, events: [], hasMore: false })
|
||||||
day,
|
|
||||||
date: new Date(currentYear, currentMonth - 2, day),
|
|
||||||
isToday: false,
|
|
||||||
isOtherMonth: true,
|
|
||||||
events: [],
|
|
||||||
hasMore: false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填充本月的日期
|
// 填充本月的日期
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
const date = new Date(currentYear, currentMonth - 1, day)
|
const date = new Date(currentYear, currentMonth - 1, day)
|
||||||
const isToday = dateUtils.isToday(date)
|
const isToday = dateUtils.isToday(date)
|
||||||
|
const allEvents = eventIndex[String(day)] || []
|
||||||
// 查找该日期的纪念日
|
const events = allEvents.slice(0, 3).map(a => ({ id: a.id, color: this.getEventColor(a) }))
|
||||||
const events = anniversaries
|
|
||||||
.filter(a => this.isAnniversaryOnDate(a, date))
|
|
||||||
.map(a => ({
|
|
||||||
id: a.id,
|
|
||||||
color: this.getEventColor(a)
|
|
||||||
}))
|
|
||||||
.slice(0, 3)
|
|
||||||
|
|
||||||
calendarDays.push({
|
calendarDays.push({
|
||||||
day,
|
day,
|
||||||
date,
|
|
||||||
isToday,
|
isToday,
|
||||||
isOtherMonth: false,
|
isOtherMonth: false,
|
||||||
events,
|
events,
|
||||||
hasMore: events.length > 2,
|
hasMore: allEvents.length > 3,
|
||||||
moreCount: anniversaries.filter(a => this.isAnniversaryOnDate(a, date)).length - 2
|
moreCount: allEvents.length - 3
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填充下个月的日期(补全6行)
|
// 填充下个月的日期(补全6行)
|
||||||
const remainingDays = 42 - calendarDays.length
|
const remainingDays = 42 - calendarDays.length
|
||||||
for (let day = 1; day <= remainingDays; day++) {
|
for (let day = 1; day <= remainingDays; day++) {
|
||||||
calendarDays.push({
|
calendarDays.push({ day, isToday: false, isOtherMonth: true, events: [], hasMore: false })
|
||||||
day,
|
|
||||||
date: new Date(currentYear, currentMonth, day),
|
|
||||||
isToday: false,
|
|
||||||
isOtherMonth: true,
|
|
||||||
events: [],
|
|
||||||
hasMore: false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setData({ calendarDays })
|
this.setData({ calendarDays })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断纪念日是否在某日期
|
|
||||||
*/
|
|
||||||
isAnniversaryOnDate(anniversary, date) {
|
|
||||||
return anniversary.solarYear === date.getFullYear() &&
|
|
||||||
anniversary.solarMonth === date.getMonth() + 1 &&
|
|
||||||
anniversary.solarDay === date.getDate()
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取事件颜色
|
* 获取事件颜色
|
||||||
*/
|
*/
|
||||||
getEventColor(anniversary) {
|
getEventColor(anniversary) {
|
||||||
const colors = {
|
return IMPORTANCE_COLORS[anniversary.importance] || '#07c160'
|
||||||
high: '#ff5722',
|
|
||||||
medium: '#ff9800',
|
|
||||||
low: '#07c160'
|
|
||||||
}
|
|
||||||
return colors[anniversary.importance] || '#07c160'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,28 +126,14 @@ Page({
|
|||||||
* 获取类型图标
|
* 获取类型图标
|
||||||
*/
|
*/
|
||||||
getTypeIcon(type) {
|
getTypeIcon(type) {
|
||||||
const icons = {
|
return TYPE_ICONS[type] || '📅'
|
||||||
birthday: '🎂',
|
|
||||||
lunar_birthday: '🌙',
|
|
||||||
wedding: '💍',
|
|
||||||
engagement: '💕',
|
|
||||||
other: '📅'
|
|
||||||
}
|
|
||||||
return icons[type] || '📅'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取类型名称
|
* 获取类型名称
|
||||||
*/
|
*/
|
||||||
getTypeName(type) {
|
getTypeName(type) {
|
||||||
const names = {
|
return TYPE_NAMES[type] || '其他'
|
||||||
birthday: '公历生日',
|
|
||||||
lunar_birthday: '农历生日',
|
|
||||||
wedding: '结婚纪念日',
|
|
||||||
engagement: '订婚纪念日',
|
|
||||||
other: '其他纪念日'
|
|
||||||
}
|
|
||||||
return names[type] || '其他'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
|
|
||||||
<!-- 星期标题 -->
|
<!-- 星期标题 -->
|
||||||
<view class="week-header">
|
<view class="week-header">
|
||||||
<view wx:for="{{['日', '一', '二', '三', '四', '五', '六']}}" wx:key="*this" class="week-title">{{item}}</view>
|
<view wx:for="{{['日', '一', '二', '三', '四', '五', '六']}}" wx:key="*this" class="week-title">
|
||||||
|
<text>{{item}}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 日历内容 -->
|
<!-- 日历内容 -->
|
||||||
|
|||||||
+118
-51
@@ -5,43 +5,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-header {
|
.calendar-header {
|
||||||
background-color: #fff;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
padding: 32rpx;
|
padding: 40rpx 32rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-navigation {
|
.month-navigation {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 32rpx;
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
font-size: 48rpx;
|
font-size: 40rpx;
|
||||||
color: #07c160;
|
color: #fff;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
width: 60rpx;
|
width: 56rpx;
|
||||||
text-align: center;
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:active {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-title {
|
.month-title {
|
||||||
font-size: 36rpx;
|
font-size: 36rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #fff;
|
||||||
min-width: 240rpx;
|
min-width: 240rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-btn {
|
.today-btn {
|
||||||
padding: 12rpx 32rpx;
|
padding: 14rpx 32rpx;
|
||||||
background-color: #f5f5f5;
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
color: #666;
|
color: #fff;
|
||||||
border-radius: 40rpx;
|
border-radius: 40rpx;
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
border: none;
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn:active {
|
||||||
|
background-color: rgba(255, 255, 255, 0.35);
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-btn::after {
|
.today-btn::after {
|
||||||
@@ -49,96 +67,137 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.week-header {
|
.week-header {
|
||||||
display: flex;
|
display: grid;
|
||||||
background-color: #fff;
|
grid-template-columns: repeat(7, 1fr);
|
||||||
border-bottom: 1px solid #f0f0f0;
|
background: linear-gradient(to bottom, #fff 0%, #fafafa 100%);
|
||||||
|
padding: 20rpx 24rpx 12rpx 24rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
border-bottom: 2rpx solid #f0f0f0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-title {
|
.week-title {
|
||||||
flex: 1;
|
display: flex;
|
||||||
text-align: center;
|
align-items: center;
|
||||||
padding: 20rpx 0;
|
justify-content: center;
|
||||||
font-size: 24rpx;
|
height: 60rpx;
|
||||||
color: #999;
|
font-size: 26rpx;
|
||||||
font-weight: 500;
|
color: #888;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-title text {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-content {
|
.calendar-content {
|
||||||
height: calc(100vh - 200rpx);
|
height: calc(100vh - 200rpx);
|
||||||
|
background-color: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-grid {
|
.calendar-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
background-color: #fff;
|
background-color: #f5f5f5;
|
||||||
|
padding: 16rpx 24rpx 16rpx 24rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-cell {
|
.calendar-cell {
|
||||||
min-height: 120rpx;
|
min-height: 100rpx;
|
||||||
border: 1px solid #f0f0f0;
|
background-color: #fff;
|
||||||
padding: 8rpx;
|
border-radius: 12rpx;
|
||||||
|
padding: 12rpx 8rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-cell.other-month {
|
.calendar-cell.other-month {
|
||||||
background-color: #fafafa;
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.today {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
box-shadow: 0 8rpx 16rpx rgba(102, 126, 234, 0.3);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-cell.today .date-num {
|
.calendar-cell.today .date-num {
|
||||||
background-color: #07c160;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 50%;
|
font-weight: 700;
|
||||||
width: 48rpx;
|
|
||||||
height: 48rpx;
|
|
||||||
line-height: 48rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-num {
|
.date-num {
|
||||||
font-size: 28rpx;
|
font-size: 32rpx;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: 8rpx;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-cell.other-month .date-num {
|
.calendar-cell.other-month .date-num {
|
||||||
color: #ccc;
|
color: #d0d0d0;
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.events {
|
.events {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4rpx;
|
gap: 6rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-dot {
|
.event-dot {
|
||||||
width: 12rpx;
|
width: 8rpx;
|
||||||
height: 12rpx;
|
height: 8rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.today .event-dot {
|
||||||
|
background-color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-text {
|
.more-text {
|
||||||
font-size: 20rpx;
|
font-size: 18rpx;
|
||||||
color: #999;
|
color: #999;
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.today .more-text {
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 事件列表 */
|
/* 事件列表 */
|
||||||
.events-list {
|
.events-list {
|
||||||
padding: 32rpx;
|
padding: 32rpx 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
|
padding-left: 16rpx;
|
||||||
|
border-left: 6rpx solid #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 120rpx 40rpx;
|
padding: 120rpx 40rpx;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state .icon {
|
.empty-state .icon {
|
||||||
@@ -150,14 +209,17 @@
|
|||||||
.empty-state .text {
|
.empty-state .text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card {
|
.event-card {
|
||||||
background-color: #fff;
|
background: linear-gradient(135deg, #fff 0%, #fafafa 100%);
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
padding: 32rpx;
|
padding: 32rpx;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 20rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||||
|
border: 2rpx solid rgba(102, 126, 234, 0.1);
|
||||||
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-header {
|
.event-header {
|
||||||
@@ -190,26 +252,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lunar-badge {
|
.lunar-badge {
|
||||||
padding: 4rpx 12rpx;
|
padding: 6rpx 16rpx;
|
||||||
background-color: #e3f2fd;
|
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
|
||||||
color: #1976d2;
|
color: #1976d2;
|
||||||
border-radius: 4rpx;
|
border-radius: 20rpx;
|
||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(25, 118, 210, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
padding: 4rpx 12rpx;
|
padding: 6rpx 16rpx;
|
||||||
border-radius: 4rpx;
|
border-radius: 20rpx;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.urgent {
|
.status.urgent {
|
||||||
background-color: #fff3f0;
|
background: linear-gradient(135deg, #ffe0db 0%, #ffccbc 100%);
|
||||||
color: #ff5722;
|
color: #ff5722;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(255, 87, 34, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.warning {
|
.status.warning {
|
||||||
background-color: #fff8e6;
|
background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%);
|
||||||
color: #ff9800;
|
color: #ff9800;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(255, 152, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+66
-31
@@ -1,13 +1,17 @@
|
|||||||
// index.js
|
// index.js
|
||||||
const storage = require('../../utils/storage')
|
const storage = require('../../utils/storage')
|
||||||
const dateUtils = require('../../utils/date')
|
const dateUtils = require('../../utils/date')
|
||||||
|
const { TYPE_NAMES } = require('../../utils/constants')
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
persons: [],
|
persons: [],
|
||||||
originalPersons: [], // 原始数据备份
|
originalPersons: [],
|
||||||
searchKeyword: '',
|
searchKeyword: '',
|
||||||
currentFilter: 'all'
|
currentFilter: 'all',
|
||||||
|
totalCount: 0,
|
||||||
|
upcomingCount: 0,
|
||||||
|
todayText: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -15,7 +19,6 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
// 每次显示页面时刷新数据
|
|
||||||
this.loadPersons()
|
this.loadPersons()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -26,31 +29,23 @@ Page({
|
|||||||
const persons = storage.getPersons()
|
const persons = storage.getPersons()
|
||||||
const anniversaries = storage.getAnniversaries()
|
const anniversaries = storage.getAnniversaries()
|
||||||
|
|
||||||
// 为每个人员添加纪念日信息
|
|
||||||
const personsWithAnniversaries = persons.map(person => {
|
const personsWithAnniversaries = persons.map(person => {
|
||||||
const personAnniversaries = anniversaries.filter(a => a.personId === person.id)
|
const personAnniversaries = anniversaries.filter(a => a.personId === person.id)
|
||||||
|
|
||||||
// 找到最近的纪念日
|
|
||||||
let nextAnniversary = null
|
let nextAnniversary = null
|
||||||
if (personAnniversaries.length > 0) {
|
if (personAnniversaries.length > 0) {
|
||||||
const today = new Date()
|
|
||||||
const upcoming = personAnniversaries
|
const upcoming = personAnniversaries
|
||||||
.map(a => {
|
.map(a => {
|
||||||
// 如果是农历,需要特殊处理
|
const { date, daysUntil } = dateUtils.getNextOccurrence(a.solarMonth, a.solarDay)
|
||||||
const date = new Date(a.solarYear, a.solarMonth - 1, a.solarDay)
|
return { ...a, date, daysUntil }
|
||||||
return {
|
|
||||||
...a,
|
|
||||||
date,
|
|
||||||
daysUntil: dateUtils.getDaysUntil(date)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.filter(a => a.daysUntil >= 0)
|
|
||||||
.sort((a, b) => a.daysUntil - b.daysUntil)
|
.sort((a, b) => a.daysUntil - b.daysUntil)
|
||||||
|
|
||||||
if (upcoming.length > 0) {
|
if (upcoming.length > 0) {
|
||||||
const next = upcoming[0]
|
const next = upcoming[0]
|
||||||
nextAnniversary = {
|
nextAnniversary = {
|
||||||
type: next.type,
|
type: next.type,
|
||||||
|
typeName: this.getTypeName(next.type, next.customTypeName),
|
||||||
dateText: dateUtils.formatDate(next.date, 'MM月DD日'),
|
dateText: dateUtils.formatDate(next.date, 'MM月DD日'),
|
||||||
daysUntil: next.daysUntil,
|
daysUntil: next.daysUntil,
|
||||||
daysUntilText: this.formatDaysUntil(next.daysUntil)
|
daysUntilText: this.formatDaysUntil(next.daysUntil)
|
||||||
@@ -58,14 +53,9 @@ Page({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { ...person, anniversaryCount: personAnniversaries.length, nextAnniversary }
|
||||||
...person,
|
|
||||||
anniversaryCount: personAnniversaries.length,
|
|
||||||
nextAnniversary
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 按最近的纪念日排序
|
|
||||||
const sorted = personsWithAnniversaries.sort((a, b) => {
|
const sorted = personsWithAnniversaries.sort((a, b) => {
|
||||||
if (!a.nextAnniversary && !b.nextAnniversary) return 0
|
if (!a.nextAnniversary && !b.nextAnniversary) return 0
|
||||||
if (!a.nextAnniversary) return 1
|
if (!a.nextAnniversary) return 1
|
||||||
@@ -73,10 +63,18 @@ Page({
|
|||||||
return a.nextAnniversary.daysUntil - b.nextAnniversary.daysUntil
|
return a.nextAnniversary.daysUntil - b.nextAnniversary.daysUntil
|
||||||
})
|
})
|
||||||
|
|
||||||
this.setData({
|
const today = new Date()
|
||||||
persons: sorted,
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
originalPersons: sorted
|
const todayText = `${today.getMonth() + 1}月${today.getDate()}日 周${weekdays[today.getDay()]}`
|
||||||
})
|
const upcomingCount = sorted.filter(p => p.nextAnniversary && p.nextAnniversary.daysUntil <= 7).length
|
||||||
|
|
||||||
|
this.setData({ originalPersons: sorted, totalCount: sorted.length, upcomingCount, todayText })
|
||||||
|
|
||||||
|
if (this.data.currentFilter === 'all' && !this.data.searchKeyword) {
|
||||||
|
this.setData({ persons: sorted })
|
||||||
|
} else {
|
||||||
|
this.filterPersons()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +88,14 @@ Page({
|
|||||||
return `还有${Math.floor(days / 30)}个月`
|
return `还有${Math.floor(days / 30)}个月`
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取类型名称
|
||||||
|
*/
|
||||||
|
getTypeName(type, customName) {
|
||||||
|
if (type === 'other' && customName) return customName
|
||||||
|
return TYPE_NAMES[type] || '纪念日'
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 搜索输入
|
* 搜索输入
|
||||||
*/
|
*/
|
||||||
@@ -104,8 +110,9 @@ Page({
|
|||||||
*/
|
*/
|
||||||
onFilterTap(e) {
|
onFilterTap(e) {
|
||||||
const filter = e.currentTarget.dataset.filter
|
const filter = e.currentTarget.dataset.filter
|
||||||
this.setData({ currentFilter: filter })
|
this.setData({ currentFilter: filter }, () => {
|
||||||
this.filterPersons()
|
this.filterPersons()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +120,7 @@ Page({
|
|||||||
*/
|
*/
|
||||||
filterPersons() {
|
filterPersons() {
|
||||||
const { originalPersons, searchKeyword, currentFilter } = this.data
|
const { originalPersons, searchKeyword, currentFilter } = this.data
|
||||||
|
const anniversaries = storage.getAnniversaries()
|
||||||
|
|
||||||
let filtered = [...originalPersons]
|
let filtered = [...originalPersons]
|
||||||
|
|
||||||
@@ -124,12 +132,39 @@ Page({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 类型筛选(暂时保留,后续可以实现更精确的筛选)
|
// 类型筛选
|
||||||
// if (currentFilter !== 'all') {
|
if (currentFilter !== 'all') {
|
||||||
// // 可以实现更精确的筛选逻辑
|
filtered = filtered.filter(person => {
|
||||||
// }
|
const personAnniversaries = anniversaries.filter(a => a.personId === person.id)
|
||||||
|
|
||||||
this.setData({ persons: filtered })
|
if (currentFilter === 'birthday') {
|
||||||
|
// 生日筛选:只显示有生日类型的人(公历生日或农历生日)
|
||||||
|
return personAnniversaries.some(a =>
|
||||||
|
a.type === 'birthday' || a.type === 'lunar_birthday'
|
||||||
|
)
|
||||||
|
} else if (currentFilter === 'anniversary') {
|
||||||
|
// 纪念日筛选:只显示有纪念日类型的人(结婚、订婚、其他)
|
||||||
|
return personAnniversaries.some(a =>
|
||||||
|
a.type === 'wedding' || a.type === 'engagement' || a.type === 'other'
|
||||||
|
)
|
||||||
|
} else if (currentFilter === 'upcoming') {
|
||||||
|
// 即将到来:只显示7天内有纪念日的人
|
||||||
|
return person.nextAnniversary && person.nextAnniversary.daysUntil <= 7
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序:与 loadPersons() 保持一致,按最近的纪念日排序
|
||||||
|
const sorted = filtered.sort((a, b) => {
|
||||||
|
if (!a.nextAnniversary && !b.nextAnniversary) return 0
|
||||||
|
if (!a.nextAnniversary) return 1
|
||||||
|
if (!b.nextAnniversary) return -1
|
||||||
|
return a.nextAnniversary.daysUntil - b.nextAnniversary.daysUntil
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setData({ persons: sorted })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+92
-36
@@ -1,56 +1,112 @@
|
|||||||
<!--index.wxml-->
|
<!--index.wxml-->
|
||||||
<view class="container">
|
<view class="page">
|
||||||
|
|
||||||
|
<!-- 顶部 Header -->
|
||||||
|
<view class="header">
|
||||||
|
<view class="header-top">
|
||||||
|
<view class="header-left">
|
||||||
|
<text class="header-title">生日提醒</text>
|
||||||
|
<text class="header-date">{{todayText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="header-icon">🎂</view>
|
||||||
|
</view>
|
||||||
|
<view class="stats-row">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num">{{totalCount}}</text>
|
||||||
|
<text class="stat-label">位好友</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-sep"></view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num {{upcomingCount > 0 ? 'stat-num-urgent' : ''}}">{{upcomingCount}}</text>
|
||||||
|
<text class="stat-label">即将到来</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 搜索栏 -->
|
<!-- 搜索栏 -->
|
||||||
<view class="search-bar">
|
<view class="search-wrap">
|
||||||
<input class="search-input" placeholder="搜索姓名" value="{{searchKeyword}}" bindinput="onSearchInput" />
|
<view class="search-box">
|
||||||
|
<text class="search-icon">🔍</text>
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
placeholder="搜索姓名或昵称"
|
||||||
|
placeholder-class="search-placeholder"
|
||||||
|
value="{{searchKeyword}}"
|
||||||
|
bindinput="onSearchInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 筛选栏 -->
|
<!-- 筛选栏 -->
|
||||||
<view class="filter-bar">
|
<scroll-view class="filter-scroll" scroll-x enable-flex>
|
||||||
<text class="filter-label">筛选:</text>
|
<view class="filter-bar">
|
||||||
<scroll-view class="filter-scroll" scroll-x>
|
<view class="filter-tab {{currentFilter === 'all' ? 'tab-active' : ''}}" bindtap="onFilterTap" data-filter="all">全部</view>
|
||||||
<view class="filter-item {{currentFilter === 'all' ? 'active' : ''}}" bindtap="onFilterTap" data-filter="all">全部</view>
|
<view class="filter-tab {{currentFilter === 'birthday' ? 'tab-active' : ''}}" bindtap="onFilterTap" data-filter="birthday">🎂 生日</view>
|
||||||
<view class="filter-item {{currentFilter === 'birthday' ? 'active' : ''}}" bindtap="onFilterTap" data-filter="birthday">生日</view>
|
<view class="filter-tab {{currentFilter === 'anniversary' ? 'tab-active' : ''}}" bindtap="onFilterTap" data-filter="anniversary">💍 纪念日</view>
|
||||||
<view class="filter-item {{currentFilter === 'anniversary' ? 'active' : ''}}" bindtap="onFilterTap" data-filter="anniversary">纪念日</view>
|
<view class="filter-tab {{currentFilter === 'upcoming' ? 'tab-active' : ''}}" bindtap="onFilterTap" data-filter="upcoming">⏰ 即将到来</view>
|
||||||
<view class="filter-item {{currentFilter === 'upcoming' ? 'active' : ''}}" bindtap="onFilterTap" data-filter="upcoming">即将到来</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 人员列表 -->
|
<!-- 人员列表 -->
|
||||||
<scroll-view class="person-list" scroll-y>
|
<scroll-view class="list" scroll-y>
|
||||||
<view wx:if="{{persons.length === 0}}" class="empty-state">
|
|
||||||
<text class="icon">📅</text>
|
<!-- 空状态 -->
|
||||||
<text class="text">还没有添加任何人</text>
|
<view wx:if="{{persons.length === 0}}" class="empty">
|
||||||
<text class="hint">点击右下角 + 添加第一个</text>
|
<text class="empty-icon">🌟</text>
|
||||||
|
<text class="empty-title">还没有添加任何人</text>
|
||||||
|
<text class="empty-hint">点击右下角 + 开始添加</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:for="{{persons}}" wx:key="id" class="person-card" bindtap="onPersonTap" data-id="{{item.id}}">
|
<!-- 人员卡片 -->
|
||||||
<view class="person-header">
|
<view
|
||||||
<image class="avatar" src="{{item.avatar || '/images/default-avatar.png'}}" mode="aspectFill" />
|
wx:for="{{persons}}"
|
||||||
<view class="person-info">
|
wx:key="id"
|
||||||
<text class="person-name">{{item.name}}</text>
|
class="card {{item.nextAnniversary && item.nextAnniversary.daysUntil === 0 ? 'card-today' : item.nextAnniversary && item.nextAnniversary.daysUntil <= 7 ? 'card-urgent' : ''}}"
|
||||||
<text wx:if="{{item.nickname}}" class="person-nickname">{{item.nickname}}</text>
|
bindtap="onPersonTap"
|
||||||
</view>
|
data-id="{{item.id}}"
|
||||||
<view class="person-count">
|
>
|
||||||
<text wx:if="{{item.anniversaryCount}}" class="count-text">{{item.anniversaryCount}}</text>
|
<!-- 左侧头像 -->
|
||||||
<text class="count-label">个纪念日</text>
|
<view class="avatar-wrap">
|
||||||
|
<image wx:if="{{item.avatar}}" class="avatar-img" src="{{item.avatar}}" mode="aspectFill" />
|
||||||
|
<view wx:else class="avatar-placeholder">
|
||||||
|
<text class="avatar-initial">{{item.name[0]}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 最近的纪念日 -->
|
<!-- 右侧内容 -->
|
||||||
<view wx:if="{{item.nextAnniversary}}" class="next-anniversary">
|
<view class="card-body">
|
||||||
<text class="next-label">📌 </text>
|
<view class="card-row-top">
|
||||||
<text class="next-text">{{item.nextAnniversary.type}}: {{item.nextAnniversary.dateText}}</text>
|
<view class="name-group">
|
||||||
<text wx:if="{{item.nextAnniversary.daysUntil}}" class="days-text {{item.nextAnniversary.daysUntil <= 7 ? 'urgent' : ''}}">
|
<text class="card-name">{{item.name}}</text>
|
||||||
{{item.nextAnniversary.daysUntilText}}
|
<text wx:if="{{item.nickname}}" class="card-nickname">{{item.nickname}}</text>
|
||||||
</text>
|
</view>
|
||||||
|
<view
|
||||||
|
wx:if="{{item.nextAnniversary}}"
|
||||||
|
class="badge {{item.nextAnniversary.daysUntil === 0 ? 'badge-today' : item.nextAnniversary.daysUntil <= 3 ? 'badge-hot' : item.nextAnniversary.daysUntil <= 7 ? 'badge-soon' : item.nextAnniversary.daysUntil <= 30 ? 'badge-month' : 'badge-normal'}}"
|
||||||
|
>
|
||||||
|
<text class="badge-text">{{item.nextAnniversary.daysUntilText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{item.nextAnniversary}}" class="card-row-ann">
|
||||||
|
<text class="ann-icon">
|
||||||
|
{{item.nextAnniversary.type === 'birthday' || item.nextAnniversary.type === 'lunar_birthday' ? '🎂' : item.nextAnniversary.type === 'wedding' ? '💍' : item.nextAnniversary.type === 'engagement' ? '💕' : '📅'}}
|
||||||
|
</text>
|
||||||
|
<text class="ann-info">{{item.nextAnniversary.typeName}} · {{item.nextAnniversary.dateText}}</text>
|
||||||
|
<text wx:if="{{item.anniversaryCount > 1}}" class="ann-more">+{{item.anniversaryCount - 1}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:else class="card-row-ann no-ann">
|
||||||
|
<text class="ann-info muted">暂无纪念日</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="list-bottom"></view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 浮动添加按钮 -->
|
<!-- 浮动添加按钮 -->
|
||||||
<view class="fab" bindtap="onAddTap">
|
<view class="fab" bindtap="onAddTap">
|
||||||
<text>+</text>
|
<text class="fab-icon">+</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
|
</view>
|
||||||
|
|||||||
+353
-136
@@ -1,193 +1,410 @@
|
|||||||
/**index.wxss**/
|
/* index.wxss */
|
||||||
.container {
|
|
||||||
min-height: 100vh;
|
page {
|
||||||
background-color: #f5f5f5;
|
background: #f1f4f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 搜索栏 */
|
.page {
|
||||||
.search-bar {
|
|
||||||
padding: 24rpx;
|
|
||||||
background-color: #fff;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
border-radius: 40rpx;
|
|
||||||
padding: 24rpx 32rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 筛选栏 */
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
padding: 20rpx 24rpx;
|
height: 100vh;
|
||||||
background-color: #fff;
|
background: #f1f4f9;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-label {
|
/* ───── Header ───── */
|
||||||
font-size: 26rpx;
|
.header {
|
||||||
color: #666;
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
margin-right: 16rpx;
|
padding: 48rpx 36rpx 40rpx;
|
||||||
white-space: nowrap;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-scroll {
|
.header-top {
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-item {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 12rpx 32rpx;
|
|
||||||
margin-right: 16rpx;
|
|
||||||
border-radius: 40rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-item.active {
|
|
||||||
background-color: #07c160;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 人员列表 */
|
|
||||||
.person-list {
|
|
||||||
height: calc(100vh - 200rpx);
|
|
||||||
padding: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-card {
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
padding: 32rpx;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.header-left {
|
||||||
width: 100rpx;
|
|
||||||
height: 100rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 24rpx;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-name {
|
.header-title {
|
||||||
font-size: 32rpx;
|
font-size: 44rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #fff;
|
||||||
margin-bottom: 8rpx;
|
line-height: 1.2;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-nickname {
|
.header-date {
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
color: #999;
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin-top: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-count {
|
.header-icon {
|
||||||
text-align: right;
|
font-size: 64rpx;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count-text {
|
.stats-row {
|
||||||
font-size: 36rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #07c160;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-label {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 最近的纪念日 */
|
|
||||||
.next-anniversary {
|
|
||||||
margin-top: 20rpx;
|
|
||||||
padding-top: 20rpx;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 26rpx;
|
background: rgba(255, 255, 255, 0.15);
|
||||||
color: #666;
|
border-radius: 20rpx;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
backdrop-filter: blur(8rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.next-label {
|
.stat-item {
|
||||||
margin-right: 8rpx;
|
display: flex;
|
||||||
}
|
align-items: baseline;
|
||||||
|
gap: 8rpx;
|
||||||
.next-text {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.days-text {
|
.stat-num {
|
||||||
color: #07c160;
|
font-size: 48rpx;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.days-text.urgent {
|
.stat-num-urgent {
|
||||||
color: #ff5722;
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-sep {
|
||||||
|
width: 2rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Search ───── */
|
||||||
|
.search-wrap {
|
||||||
|
padding: 24rpx 24rpx 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1e293b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Filter ───── */
|
||||||
|
.filter-scroll {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
padding: 16rpx 24rpx 20rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14rpx 28rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #64748b;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-active {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── List ───── */
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Card ───── */
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 28rpx 28rpx 28rpx 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||||
|
border-left: 6rpx solid #e2e8f0;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
box-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-urgent {
|
||||||
|
border-left-color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-today {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Avatar ───── */
|
||||||
|
.avatar-wrap {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-initial {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Card body ───── */
|
||||||
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 32rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 空状态 */
|
.card-nickname {
|
||||||
.empty-state {
|
font-size: 22rpx;
|
||||||
padding: 160rpx 40rpx;
|
color: #94a3b8;
|
||||||
text-align: center;
|
background: #f1f5f9;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state .icon {
|
/* ───── Badge ───── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-today {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
.badge-today .badge-text {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-hot {
|
||||||
|
background: #ffedd5;
|
||||||
|
}
|
||||||
|
.badge-hot .badge-text {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-soon {
|
||||||
|
background: #fef9c3;
|
||||||
|
}
|
||||||
|
.badge-soon .badge-text {
|
||||||
|
color: #ca8a04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-month {
|
||||||
|
background: #ede9fe;
|
||||||
|
}
|
||||||
|
.badge-month .badge-text {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-normal {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
.badge-normal .badge-text {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Anniversary row ───── */
|
||||||
|
.card-row-ann {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-icon {
|
||||||
|
font-size: 26rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-info {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #475569;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-more {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
background: #ede9fe;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Empty state ───── */
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 160rpx 40rpx 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
font-size: 120rpx;
|
font-size: 120rpx;
|
||||||
display: block;
|
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state .text {
|
.empty-title {
|
||||||
font-size: 28rpx;
|
font-size: 32rpx;
|
||||||
color: #999;
|
font-weight: 600;
|
||||||
display: block;
|
color: #475569;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state .hint {
|
.empty-hint {
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
color: #bbb;
|
color: #94a3b8;
|
||||||
display: block;
|
background: #fff;
|
||||||
|
padding: 14rpx 32rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 浮动按钮 */
|
/* ───── List bottom padding ───── */
|
||||||
|
.list-bottom {
|
||||||
|
height: 180rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── FAB ───── */
|
||||||
.fab {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 120rpx;
|
bottom: 120rpx;
|
||||||
right: 40rpx;
|
right: 40rpx;
|
||||||
width: 100rpx;
|
width: 112rpx;
|
||||||
height: 100rpx;
|
height: 112rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 60rpx;
|
box-shadow: 0 12rpx 40rpx rgba(99, 102, 241, 0.5);
|
||||||
box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transition: all 0.3s;
|
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab:active {
|
.fab:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.9);
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(99, 102, 241, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fab-icon {
|
||||||
|
font-size: 64rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: -4rpx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// person-detail.js
|
// person-detail.js
|
||||||
const storage = require('../../utils/storage')
|
const storage = require('../../utils/storage')
|
||||||
const dateUtils = require('../../utils/date')
|
const dateUtils = require('../../utils/date')
|
||||||
|
const { TYPE_NAMES, TYPE_ICONS, IMPORTANCE_TEXTS } = require('../../utils/constants')
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
@@ -57,6 +58,7 @@ Page({
|
|||||||
date,
|
date,
|
||||||
dateText: dateUtils.formatDate(date, 'YYYY年MM月DD日'),
|
dateText: dateUtils.formatDate(date, 'YYYY年MM月DD日'),
|
||||||
daysUntil,
|
daysUntil,
|
||||||
|
daysUntilAbs: Math.abs(daysUntil), // 添加绝对值
|
||||||
typeIcon: this.getTypeIcon(a.type),
|
typeIcon: this.getTypeIcon(a.type),
|
||||||
typeName: a.customTypeName || this.getTypeName(a.type),
|
typeName: a.customTypeName || this.getTypeName(a.type),
|
||||||
importanceText: this.getImportanceText(a.importance)
|
importanceText: this.getImportanceText(a.importance)
|
||||||
@@ -73,40 +75,21 @@ Page({
|
|||||||
* 获取类型图标
|
* 获取类型图标
|
||||||
*/
|
*/
|
||||||
getTypeIcon(type) {
|
getTypeIcon(type) {
|
||||||
const icons = {
|
return TYPE_ICONS[type] || '📅'
|
||||||
birthday: '🎂',
|
|
||||||
lunar_birthday: '🌙',
|
|
||||||
wedding: '💍',
|
|
||||||
engagement: '💕',
|
|
||||||
other: '📅'
|
|
||||||
}
|
|
||||||
return icons[type] || '📅'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取类型名称
|
* 获取类型名称
|
||||||
*/
|
*/
|
||||||
getTypeName(type) {
|
getTypeName(type) {
|
||||||
const names = {
|
return TYPE_NAMES[type] || '其他'
|
||||||
birthday: '公历生日',
|
|
||||||
lunar_birthday: '农历生日',
|
|
||||||
wedding: '结婚纪念日',
|
|
||||||
engagement: '订婚纪念日',
|
|
||||||
other: '其他纪念日'
|
|
||||||
}
|
|
||||||
return names[type] || '其他'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取重要程度文本
|
* 获取重要程度文本
|
||||||
*/
|
*/
|
||||||
getImportanceText(importance) {
|
getImportanceText(importance) {
|
||||||
const texts = {
|
return IMPORTANCE_TEXTS[importance] || '一般'
|
||||||
high: '非常重要',
|
|
||||||
medium: '重要',
|
|
||||||
low: '一般'
|
|
||||||
}
|
|
||||||
return texts[importance] || '一般'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,17 +110,12 @@ Page({
|
|||||||
content: `确定要删除"${this.data.person.name}"吗?相关纪念日也将被删除。`,
|
content: `确定要删除"${this.data.person.name}"吗?相关纪念日也将被删除。`,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
// 先删除相关纪念日
|
const success = storage.deletePersonWithAnniversaries(this.data.personId)
|
||||||
const anniversaries = storage.getAnniversariesByPersonId(this.data.personId)
|
|
||||||
anniversaries.forEach(a => storage.deleteAnniversary(a.id))
|
|
||||||
|
|
||||||
// 再删除人员
|
|
||||||
const success = storage.deletePerson(this.data.personId)
|
|
||||||
if (success) {
|
if (success) {
|
||||||
wx.showToast({ title: '删除成功', icon: 'success' })
|
wx.showToast({ title: '删除成功', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => wx.navigateBack(), 1500)
|
||||||
wx.navigateBack()
|
} else {
|
||||||
}, 1500)
|
wx.showToast({ title: '删除失败,请重试', icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,9 @@
|
|||||||
|
|
||||||
<view wx:if="{{item.daysUntil !== undefined}}" class="days-info">
|
<view wx:if="{{item.daysUntil !== undefined}}" class="days-info">
|
||||||
<text wx:if="{{item.daysUntil === 0}}" class="days-text urgent">今天</text>
|
<text wx:if="{{item.daysUntil === 0}}" class="days-text urgent">今天</text>
|
||||||
<text wx:else-if="{{item.daysUntil === 1}}" class="days-text warning">明天</text>
|
<text wx:elif="{{item.daysUntil === 1}}" class="days-text warning">明天</text>
|
||||||
<text wx:else-if="{{item.daysUntil > 0}}" class="days-text">{{item.daysUntil}}天后</text>
|
<text wx:elif="{{item.daysUntil > 0}}" class="days-text">{{item.daysUntil}}天后</text>
|
||||||
<text wx:else class="days-text past">已过{{Math.abs(item.daysUntil)}}天</text>
|
<text wx:else class="days-text past">已过{{item.daysUntilAbs}}天</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{item.remark}}" class="anniversary-remark">{{item.remark}}</view>
|
<view wx:if="{{item.remark}}" class="anniversary-remark">{{item.remark}}</view>
|
||||||
|
|||||||
@@ -69,29 +69,16 @@ Page({
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(res.data)
|
const data = JSON.parse(res.data)
|
||||||
const success = storage.importData(data)
|
const result = storage.importData(data)
|
||||||
|
|
||||||
if (success) {
|
if (result.success) {
|
||||||
wx.showToast({
|
wx.showToast({ title: '导入成功', icon: 'success' })
|
||||||
title: '导入成功',
|
setTimeout(() => wx.reLaunch({ url: '/pages/index/index' }), 1500)
|
||||||
icon: 'success'
|
|
||||||
})
|
|
||||||
setTimeout(() => {
|
|
||||||
wx.reLaunch({
|
|
||||||
url: '/pages/index/index'
|
|
||||||
})
|
|
||||||
}, 1500)
|
|
||||||
} else {
|
} else {
|
||||||
wx.showToast({
|
wx.showToast({ title: result.error || '导入失败,请检查数据格式', icon: 'none' })
|
||||||
title: '导入失败,请检查数据格式',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
wx.showToast({
|
wx.showToast({ title: '数据格式错误', icon: 'none' })
|
||||||
title: '数据格式错误',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+102
-29
@@ -1,46 +1,119 @@
|
|||||||
<!--settings.wxml-->
|
<!--settings.wxml-->
|
||||||
<view class="container">
|
<view class="page">
|
||||||
<view class="settings-list">
|
|
||||||
<!-- 数据备份 -->
|
|
||||||
<view class="setting-section">
|
|
||||||
<text class="section-title">数据管理</text>
|
|
||||||
|
|
||||||
<view class="setting-item" bindtap="onExportData">
|
<!-- Header -->
|
||||||
<text class="item-label">📤 导出数据</text>
|
<view class="header">
|
||||||
<text class="item-arrow">›</text>
|
<view class="header-top">
|
||||||
|
<view class="header-left">
|
||||||
|
<text class="header-title">设置</text>
|
||||||
|
<text class="header-sub">数据管理与关于</text>
|
||||||
|
</view>
|
||||||
|
<view class="header-icon">⚙️</view>
|
||||||
|
</view>
|
||||||
|
<!-- 数据统计 -->
|
||||||
|
<view class="stats-row">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num">{{dataCount.persons}}</text>
|
||||||
|
<text class="stat-label">位好友</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-sep"></view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num">{{dataCount.anniversaries}}</text>
|
||||||
|
<text class="stat-label">个纪念日</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="body" scroll-y>
|
||||||
|
|
||||||
|
<!-- 数据管理 -->
|
||||||
|
<view class="section-label">数据管理</view>
|
||||||
|
<view class="card-group">
|
||||||
|
<view class="row" bindtap="onExportData">
|
||||||
|
<view class="row-icon-wrap" style="background:#fff7ed;">
|
||||||
|
<text class="row-icon">📤</text>
|
||||||
|
</view>
|
||||||
|
<view class="row-body">
|
||||||
|
<text class="row-title">导出数据</text>
|
||||||
|
<text class="row-desc">复制 JSON 到剪贴板</text>
|
||||||
|
</view>
|
||||||
|
<text class="row-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="setting-item" bindtap="onImportData">
|
<view class="row-divider"></view>
|
||||||
<text class="item-label">📥 导入数据</text>
|
|
||||||
<text class="item-arrow">›</text>
|
<view class="row" bindtap="onImportData">
|
||||||
|
<view class="row-icon-wrap" style="background:#eff6ff;">
|
||||||
|
<text class="row-icon">📥</text>
|
||||||
|
</view>
|
||||||
|
<view class="row-body">
|
||||||
|
<text class="row-title">导入数据</text>
|
||||||
|
<text class="row-desc">从剪贴板读取并导入</text>
|
||||||
|
</view>
|
||||||
|
<text class="row-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="setting-item" bindtap="onClearData">
|
<view class="row-divider"></view>
|
||||||
<text class="item-label danger">🗑️ 清空所有数据</text>
|
|
||||||
<text class="item-arrow">›</text>
|
<view class="row" bindtap="onClearData">
|
||||||
|
<view class="row-icon-wrap" style="background:#fff1f2;">
|
||||||
|
<text class="row-icon">🗑️</text>
|
||||||
|
</view>
|
||||||
|
<view class="row-body">
|
||||||
|
<text class="row-title danger">清空所有数据</text>
|
||||||
|
<text class="row-desc danger">此操作不可恢复,请谨慎</text>
|
||||||
|
</view>
|
||||||
|
<text class="row-arrow danger">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 关于 -->
|
<!-- 关于 -->
|
||||||
<view class="setting-section">
|
<view class="section-label">关于</view>
|
||||||
<text class="section-title">关于</text>
|
<view class="card-group">
|
||||||
|
<view class="row">
|
||||||
<view class="setting-item">
|
<view class="row-icon-wrap" style="background:#f0fdf4;">
|
||||||
<text class="item-label">版本号</text>
|
<text class="row-icon">📱</text>
|
||||||
<text class="item-value">v1.0.0</text>
|
</view>
|
||||||
|
<view class="row-body">
|
||||||
|
<text class="row-title">版本号</text>
|
||||||
|
</view>
|
||||||
|
<text class="row-value">v1.0.0</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="setting-item">
|
<view class="row-divider"></view>
|
||||||
<text class="item-label">开发作者</text>
|
|
||||||
<text class="item-value">生日提醒团队</text>
|
<view class="row">
|
||||||
|
<view class="row-icon-wrap" style="background:#faf5ff;">
|
||||||
|
<text class="row-icon">👨💻</text>
|
||||||
|
</view>
|
||||||
|
<view class="row-body">
|
||||||
|
<text class="row-title">开发作者</text>
|
||||||
|
</view>
|
||||||
|
<text class="row-value">生日提醒团队</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 提示信息 -->
|
<!-- 提示 -->
|
||||||
<view class="info-box">
|
<view class="tips-card">
|
||||||
<text class="info-title">💡 温馨提示</text>
|
<view class="tips-title">
|
||||||
<text class="info-text">1. 建议定期导出数据备份\n2. 农历转换目前使用简化算法\n3. 提醒功能需要小程序权限</text>
|
<text class="tips-icon">💡</text>
|
||||||
|
<text class="tips-heading">温馨提示</text>
|
||||||
|
</view>
|
||||||
|
<view class="tips-item">
|
||||||
|
<text class="tips-dot">·</text>
|
||||||
|
<text class="tips-text">建议定期导出数据备份</text>
|
||||||
|
</view>
|
||||||
|
<view class="tips-item">
|
||||||
|
<text class="tips-dot">·</text>
|
||||||
|
<text class="tips-text">农历转换使用寿星万年历算法(1900–2100)</text>
|
||||||
|
</view>
|
||||||
|
<view class="tips-item">
|
||||||
|
<text class="tips-dot">·</text>
|
||||||
|
<text class="tips-text">开启提醒需授权订阅消息</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
<view class="bottom-pad"></view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
+207
-49
@@ -1,82 +1,240 @@
|
|||||||
/**settings.wxss**/
|
/* settings.wxss */
|
||||||
.container {
|
|
||||||
min-height: 100vh;
|
page {
|
||||||
background-color: #f5f5f5;
|
background: #f1f4f9;
|
||||||
padding-bottom: 120rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-list {
|
.page {
|
||||||
padding: 32rpx;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #f1f4f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-section {
|
/* ───── Header ───── */
|
||||||
background-color: #fff;
|
.header {
|
||||||
border-radius: 16rpx;
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
padding: 48rpx 36rpx 40rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 64rpx;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-sep {
|
||||||
|
width: 2rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Body ───── */
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Section label ───── */
|
||||||
|
.section-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
padding: 32rpx 32rpx 12rpx;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Card group ───── */
|
||||||
|
.card-group {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin: 0 24rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
/* ───── Row ───── */
|
||||||
padding: 24rpx 32rpx;
|
.row {
|
||||||
background-color: #f9f9f9;
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #999;
|
|
||||||
font-weight: 500;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
padding: 28rpx 28rpx;
|
||||||
padding: 32rpx;
|
transition: background 0.15s ease;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item:last-child {
|
.row:active {
|
||||||
border-bottom: none;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-label {
|
.row-icon-wrap {
|
||||||
font-size: 28rpx;
|
width: 72rpx;
|
||||||
color: #333;
|
height: 72rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-label.danger {
|
.row-icon {
|
||||||
color: #ff5722;
|
font-size: 36rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-value {
|
.row-body {
|
||||||
font-size: 26rpx;
|
flex: 1;
|
||||||
color: #999;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-arrow {
|
.row-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title.danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-desc.danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-arrow {
|
||||||
font-size: 40rpx;
|
font-size: 40rpx;
|
||||||
color: #ccc;
|
color: #cbd5e1;
|
||||||
|
margin-left: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box {
|
.row-arrow.danger {
|
||||||
background-color: #fff;
|
color: #fca5a5;
|
||||||
border-radius: 16rpx;
|
|
||||||
padding: 32rpx;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-title {
|
.row-value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-divider {
|
||||||
|
height: 1rpx;
|
||||||
|
background: #f1f5f9;
|
||||||
|
margin-left: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Tips card ───── */
|
||||||
|
.tips-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin: 8rpx 24rpx 0;
|
||||||
|
padding: 28rpx 28rpx 32rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||||
|
border-left: 6rpx solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-heading {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #333;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: block;
|
color: #1e293b;
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-text {
|
.tips-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-dot {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
line-height: 1.8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #666;
|
color: #475569;
|
||||||
line-height: 2;
|
line-height: 1.8;
|
||||||
white-space: pre-line;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───── Bottom padding ───── */
|
||||||
|
.bottom-pad {
|
||||||
|
height: 80rpx;
|
||||||
|
}
|
||||||
|
|||||||
+5
-3
@@ -34,10 +34,12 @@
|
|||||||
"disableSWC": true
|
"disableSWC": true
|
||||||
},
|
},
|
||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"libVersion": "3.0.0",
|
"libVersion": "3.10.3",
|
||||||
"appid": "touristappid",
|
"appid": "wxe72882e2072d141a",
|
||||||
"projectname": "birthday-reminder",
|
"projectname": "birthday-reminder",
|
||||||
|
"cloudfunctionRoot": "cloudfunctions/",
|
||||||
"condition": {},
|
"condition": {},
|
||||||
"simulatorPluginLibVersion": {},
|
"simulatorPluginLibVersion": {},
|
||||||
"editorSetting": {}
|
"editorSetting": {},
|
||||||
|
"cloudfunctionTemplateRoot": "cloudfunctionTemplate/"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 微信小程序凭证(在微信公众平台 → 开发管理 → 开发设置 中获取)
|
||||||
|
WX_APPID=
|
||||||
|
WX_APPSECRET=
|
||||||
|
|
||||||
|
# 订阅消息模板 ID(保持和云函数 sendReminder 中的 TEMPLATE_ID 一致)
|
||||||
|
WX_TEMPLATE_ID=6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw
|
||||||
|
|
||||||
|
# 小程序版本:开发版 developer / 体验版 trial / 正式版 formal
|
||||||
|
WX_MINIPROGRAM_STATE=formal
|
||||||
|
|
||||||
|
# 服务监听端口
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# SQLite 数据库文件路径(容器内路径,挂载到宿主机 data/ 目录)
|
||||||
|
DB_PATH=/app/data/birthday.db
|
||||||
|
|
||||||
|
# 定时任务 cron 表达式(默认每天 9:00)
|
||||||
|
REMINDER_CRON=0 9 * * *
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
# better-sqlite3 是原生模块,需要编译工具
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 先单独拷 package*.json,利用 docker 层缓存
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
birthday-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
HTTP_PROXY: http://host.docker.internal:29758
|
||||||
|
HTTPS_PROXY: http://host.docker.internal:29758
|
||||||
|
NO_PROXY: localhost,127.0.0.1
|
||||||
|
container_name: birthday-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "birthday-reminder-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "生日提醒小程序自建后端",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const Database = require('better-sqlite3')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'birthday.db')
|
||||||
|
|
||||||
|
// 确保数据目录存在
|
||||||
|
const dir = path.dirname(dbPath)
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||||
|
|
||||||
|
const db = new Database(dbPath)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
|
||||||
|
// 纪念日表:兼容原云数据库 anniversaries 集合字段
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS anniversaries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
openid TEXT NOT NULL,
|
||||||
|
personId TEXT,
|
||||||
|
personName TEXT,
|
||||||
|
type TEXT,
|
||||||
|
customTypeName TEXT,
|
||||||
|
isLunar INTEGER DEFAULT 0,
|
||||||
|
solarYear INTEGER,
|
||||||
|
solarMonth INTEGER,
|
||||||
|
solarDay INTEGER,
|
||||||
|
importance TEXT,
|
||||||
|
remindEnabled INTEGER DEFAULT 0,
|
||||||
|
remindDays INTEGER DEFAULT 0,
|
||||||
|
remark TEXT,
|
||||||
|
createTime INTEGER,
|
||||||
|
updateTime INTEGER
|
||||||
|
);
|
||||||
|
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 remind_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
anniversaryId TEXT,
|
||||||
|
personName TEXT,
|
||||||
|
typeName TEXT,
|
||||||
|
daysUntil INTEGER,
|
||||||
|
sendDate INTEGER,
|
||||||
|
status TEXT,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_log_anniv ON remind_logs(anniversaryId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate);
|
||||||
|
`)
|
||||||
|
|
||||||
|
module.exports = db
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
require('dotenv').config()
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const db = require('./db')
|
||||||
|
const wx = require('./wx')
|
||||||
|
const reminder = require('./reminder')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(express.json({ limit: '1mb' }))
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ ok: true, time: Date.now() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 登录:前端传 code,返回 openid
|
||||||
|
app.post('/api/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.body
|
||||||
|
if (!code) return res.json({ success: false, error: '缺少 code' })
|
||||||
|
const result = await wx.code2session(code)
|
||||||
|
res.json({ success: true, openid: result.openid, unionid: result.unionid })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('登录失败:', err.message)
|
||||||
|
res.json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 纪念日操作(兼容原云函数 syncAnniversary 的 action 协议)
|
||||||
|
app.post('/api/anniversary', (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(addAnniversary(openid, data))
|
||||||
|
case 'update': return res.json(updateAnniversary(openid, data))
|
||||||
|
case 'delete': return res.json(deleteAnniversary(openid, data.id))
|
||||||
|
case 'sync': return res.json(syncAnniversaries(openid, data))
|
||||||
|
case 'get': return res.json(getAnniversaries(openid))
|
||||||
|
default: return res.json({ success: false, error: '未知操作' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('纪念日操作失败:', err.message)
|
||||||
|
res.json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 纪念日 CRUD ----
|
||||||
|
|
||||||
|
const FIELDS = [
|
||||||
|
'id', 'openid', 'personId', 'personName', 'type', 'customTypeName',
|
||||||
|
'isLunar', 'solarYear', 'solarMonth', 'solarDay', 'importance',
|
||||||
|
'remindEnabled', 'remindDays', 'remark', 'createTime', 'updateTime'
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalize(row) {
|
||||||
|
if (!row) return row
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
isLunar: !!row.isLunar,
|
||||||
|
remindEnabled: !!row.remindEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAnniversary(openid, anniv) {
|
||||||
|
const now = Date.now()
|
||||||
|
const id = anniv.id || ('id_' + now + '_' + Math.random().toString(36).slice(2, 11))
|
||||||
|
const row = {
|
||||||
|
id, openid,
|
||||||
|
personId: anniv.personId || null,
|
||||||
|
personName: anniv.personName || '',
|
||||||
|
type: anniv.type || 'other',
|
||||||
|
customTypeName: anniv.customTypeName || null,
|
||||||
|
isLunar: anniv.isLunar ? 1 : 0,
|
||||||
|
solarYear: anniv.solarYear ?? null,
|
||||||
|
solarMonth: anniv.solarMonth,
|
||||||
|
solarDay: anniv.solarDay,
|
||||||
|
importance: anniv.importance || 'normal',
|
||||||
|
remindEnabled: anniv.remindEnabled ? 1 : 0,
|
||||||
|
remindDays: anniv.remindDays ?? 0,
|
||||||
|
remark: anniv.remark || null,
|
||||||
|
createTime: anniv.createTime || now,
|
||||||
|
updateTime: now
|
||||||
|
}
|
||||||
|
const placeholders = FIELDS.map(f => '@' + f).join(', ')
|
||||||
|
db.prepare(`INSERT OR REPLACE INTO anniversaries (${FIELDS.join(',')}) VALUES (${placeholders})`).run(row)
|
||||||
|
return { success: true, id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAnniversary(openid, anniv) {
|
||||||
|
const existing = db.prepare('SELECT * FROM anniversaries WHERE id = ? AND openid = ?').get(anniv.id, openid)
|
||||||
|
if (!existing) return { success: false, error: '纪念日不存在' }
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
...existing,
|
||||||
|
...anniv,
|
||||||
|
openid,
|
||||||
|
isLunar: ('isLunar' in anniv ? (anniv.isLunar ? 1 : 0) : existing.isLunar),
|
||||||
|
remindEnabled: ('remindEnabled' in anniv ? (anniv.remindEnabled ? 1 : 0) : existing.remindEnabled),
|
||||||
|
updateTime: Date.now()
|
||||||
|
}
|
||||||
|
const sets = FIELDS.filter(f => f !== 'id' && f !== 'openid' && f !== 'createTime')
|
||||||
|
.map(f => `${f} = @${f}`).join(', ')
|
||||||
|
db.prepare(`UPDATE anniversaries SET ${sets} WHERE id = @id AND openid = @openid`).run(merged)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAnniversary(openid, id) {
|
||||||
|
const info = db.prepare('DELETE FROM anniversaries WHERE id = ? AND openid = ?').run(id, openid)
|
||||||
|
return { success: info.changes > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAnniversaries(openid, list) {
|
||||||
|
const tx = db.transaction((items) => {
|
||||||
|
db.prepare('DELETE FROM anniversaries WHERE openid = ?').run(openid)
|
||||||
|
for (const item of items) addAnniversary(openid, item)
|
||||||
|
})
|
||||||
|
tx(Array.isArray(list) ? list : [])
|
||||||
|
return { success: true, count: Array.isArray(list) ? list.length : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnniversaries(openid) {
|
||||||
|
const rows = db.prepare('SELECT * FROM anniversaries WHERE openid = ? ORDER BY createTime DESC').all(openid)
|
||||||
|
return { success: true, data: rows.map(normalize) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动触发提醒任务(便于调试,无需等到定点)
|
||||||
|
app.post('/api/reminder/run', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await reminder.runOnce()
|
||||||
|
res.json({ success: true, ...result })
|
||||||
|
} catch (err) {
|
||||||
|
res.json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 启动 ----
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`生日提醒后端已启动,监听 ${PORT}`)
|
||||||
|
reminder.start()
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
const cron = require('node-cron')
|
||||||
|
const db = require('./db')
|
||||||
|
const wx = require('./wx')
|
||||||
|
|
||||||
|
const TEMPLATE_ID = process.env.WX_TEMPLATE_ID
|
||||||
|
const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal'
|
||||||
|
|
||||||
|
const TYPE_NAMES = {
|
||||||
|
birthday: '公历生日',
|
||||||
|
lunar_birthday: '农历生日',
|
||||||
|
wedding: '结婚纪念日',
|
||||||
|
engagement: '订婚纪念日',
|
||||||
|
other: '其他纪念日'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeName(type, customName) {
|
||||||
|
if (type === 'other' && customName) return customName
|
||||||
|
return TYPE_NAMES[type] || '纪念日'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${y}年${m}月${d}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 算出"距离今年(或明年)这个纪念日还有多少天"
|
||||||
|
function getThisYearDate(anniv) {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay)
|
||||||
|
target.setHours(0, 0, 0, 0)
|
||||||
|
if (target < today) {
|
||||||
|
target = new Date(today.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay)
|
||||||
|
target.setHours(0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(target) {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
return Math.round((target - today) / 86400000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查今天是否已经给这条纪念日发过提醒
|
||||||
|
function alreadySentToday(anniversaryId) {
|
||||||
|
const start = new Date()
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM remind_logs WHERE anniversaryId = ? AND sendDate >= ? AND status = ?'
|
||||||
|
).get(anniversaryId, start.getTime(), 'success')
|
||||||
|
return row.n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertLog = db.prepare(`
|
||||||
|
INSERT INTO remind_logs (anniversaryId, personName, typeName, daysUntil, sendDate, status, error)
|
||||||
|
VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error)
|
||||||
|
`)
|
||||||
|
|
||||||
|
async function runOnce() {
|
||||||
|
console.log('[reminder] 开始扫描纪念日...')
|
||||||
|
|
||||||
|
const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1').all()
|
||||||
|
console.log(`[reminder] 启用提醒的纪念日 ${list.length} 条`)
|
||||||
|
|
||||||
|
let ok = 0
|
||||||
|
let fail = 0
|
||||||
|
|
||||||
|
for (const anniv of list) {
|
||||||
|
try {
|
||||||
|
const target = getThisYearDate(anniv)
|
||||||
|
const daysUntil = daysBetween(target)
|
||||||
|
|
||||||
|
const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0))
|
||||||
|
if (!shouldRemind) continue
|
||||||
|
|
||||||
|
if (alreadySentToday(anniv.id)) {
|
||||||
|
console.log(`[reminder] 今天已发过,跳过: ${anniv.personName}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeName = getTypeName(anniv.type, anniv.customTypeName)
|
||||||
|
|
||||||
|
await wx.sendSubscribeMessage({
|
||||||
|
touser: anniv.openid,
|
||||||
|
page: 'pages/index/index',
|
||||||
|
templateId: TEMPLATE_ID,
|
||||||
|
miniprogramState: MINIPROGRAM_STATE,
|
||||||
|
data: {
|
||||||
|
name1: { value: anniv.personName },
|
||||||
|
thing2: { value: daysUntil === 0 ? '今天' : `还有${daysUntil}天` },
|
||||||
|
thing6: { value: formatDate(target) },
|
||||||
|
thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
insertLog.run({
|
||||||
|
anniversaryId: anniv.id,
|
||||||
|
personName: anniv.personName,
|
||||||
|
typeName,
|
||||||
|
daysUntil,
|
||||||
|
sendDate: Date.now(),
|
||||||
|
status: 'success',
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
ok++
|
||||||
|
console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${daysUntil}天)`)
|
||||||
|
} catch (err) {
|
||||||
|
fail++
|
||||||
|
console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message)
|
||||||
|
insertLog.run({
|
||||||
|
anniversaryId: anniv.id,
|
||||||
|
personName: anniv.personName,
|
||||||
|
typeName: null,
|
||||||
|
daysUntil: null,
|
||||||
|
sendDate: Date.now(),
|
||||||
|
status: 'failed',
|
||||||
|
error: err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}`)
|
||||||
|
return { total: list.length, ok, fail }
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const expr = process.env.REMINDER_CRON || '0 9 * * *'
|
||||||
|
if (!cron.validate(expr)) {
|
||||||
|
console.error(`[reminder] 无效的 cron 表达式: ${expr},定时任务未启动`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cron.schedule(expr, runOnce, { timezone: 'Asia/Shanghai' })
|
||||||
|
console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, runOnce }
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
|
||||||
|
const APPID = process.env.WX_APPID
|
||||||
|
const APPSECRET = process.env.WX_APPSECRET
|
||||||
|
|
||||||
|
// access_token 内存缓存(微信全局唯一,2 小时有效)
|
||||||
|
let _token = { value: null, expireAt: 0 }
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
const now = Date.now()
|
||||||
|
// 提前 5 分钟刷新,避免临界过期
|
||||||
|
if (_token.value && _token.expireAt - now > 5 * 60 * 1000) {
|
||||||
|
return _token.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${APPSECRET}`
|
||||||
|
const { data } = await axios.get(url, { timeout: 8000 })
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error(`获取 access_token 失败: ${JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
|
_token = {
|
||||||
|
value: data.access_token,
|
||||||
|
expireAt: now + (data.expires_in - 300) * 1000
|
||||||
|
}
|
||||||
|
return _token.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// code2session:拿 openid(小程序登录)
|
||||||
|
async function code2session(code) {
|
||||||
|
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${APPID}&secret=${APPSECRET}&js_code=${code}&grant_type=authorization_code`
|
||||||
|
const { data } = await axios.get(url, { timeout: 8000 })
|
||||||
|
if (data.errcode) {
|
||||||
|
throw new Error(`code2session 失败: ${JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
|
return { openid: data.openid, unionid: data.unionid || null, session_key: data.session_key }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送订阅消息
|
||||||
|
async function sendSubscribeMessage({ touser, page, data, templateId, miniprogramState = 'formal' }) {
|
||||||
|
const token = await getAccessToken()
|
||||||
|
const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`
|
||||||
|
const body = {
|
||||||
|
touser,
|
||||||
|
template_id: templateId,
|
||||||
|
page,
|
||||||
|
miniprogram_state: miniprogramState,
|
||||||
|
lang: 'zh_CN',
|
||||||
|
data
|
||||||
|
}
|
||||||
|
const { data: res } = await axios.post(url, body, { timeout: 8000 })
|
||||||
|
if (res.errcode !== 0) {
|
||||||
|
throw new Error(`发送订阅消息失败: ${JSON.stringify(res)}`)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAccessToken, code2session, sendSubscribeMessage }
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 后端接口请求封装
|
||||||
|
*
|
||||||
|
* 开发版(开发者工具)走本地 docker,体验版/正式版走线上 HTTPS。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEV_BASE_URL = 'http://localhost:3000'
|
||||||
|
const PROD_BASE_URL = 'https://wxserver.ymxixi.space'
|
||||||
|
|
||||||
|
function resolveBaseUrl() {
|
||||||
|
try {
|
||||||
|
const env = wx.getAccountInfoSync().miniProgram.envVersion
|
||||||
|
return env === 'develop' ? DEV_BASE_URL : PROD_BASE_URL
|
||||||
|
} catch (e) {
|
||||||
|
return DEV_BASE_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = resolveBaseUrl()
|
||||||
|
|
||||||
|
function request({ url, method = 'POST', data = {}, withOpenid = true }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const header = { 'Content-Type': 'application/json' }
|
||||||
|
if (withOpenid) {
|
||||||
|
const openid = wx.getStorageSync('openid')
|
||||||
|
if (openid) header['x-openid'] = openid
|
||||||
|
}
|
||||||
|
wx.request({
|
||||||
|
url: BASE_URL + url,
|
||||||
|
method,
|
||||||
|
header,
|
||||||
|
data,
|
||||||
|
timeout: 10000,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data)
|
||||||
|
else reject(new Error(`HTTP ${res.statusCode}`))
|
||||||
|
},
|
||||||
|
fail: (err) => reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录:用 wx.login 拿 code 换 openid
|
||||||
|
function login() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.login({
|
||||||
|
success: async (loginRes) => {
|
||||||
|
try {
|
||||||
|
const res = await request({
|
||||||
|
url: '/api/login',
|
||||||
|
data: { code: loginRes.code },
|
||||||
|
withOpenid: false
|
||||||
|
})
|
||||||
|
if (!res.success) return reject(new Error(res.error || '登录失败'))
|
||||||
|
resolve(res)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纪念日操作(兼容原云函数 action 协议)
|
||||||
|
function anniversary(action, data) {
|
||||||
|
return request({ url: '/api/anniversary', data: { action, data } })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { request, login, anniversary, BASE_URL }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 公共常量
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ANNIVERSARY_TYPES = {
|
||||||
|
BIRTHDAY: 'birthday',
|
||||||
|
LUNAR_BIRTHDAY: 'lunar_birthday',
|
||||||
|
WEDDING: 'wedding',
|
||||||
|
ENGAGEMENT: 'engagement',
|
||||||
|
OTHER: 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_NAMES = {
|
||||||
|
birthday: '公历生日',
|
||||||
|
lunar_birthday: '农历生日',
|
||||||
|
wedding: '结婚纪念日',
|
||||||
|
engagement: '订婚纪念日',
|
||||||
|
other: '其他纪念日'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
birthday: '🎂',
|
||||||
|
lunar_birthday: '🌙',
|
||||||
|
wedding: '💍',
|
||||||
|
engagement: '💕',
|
||||||
|
other: '📅'
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORTANCE_TEXTS = {
|
||||||
|
high: '非常重要',
|
||||||
|
medium: '重要',
|
||||||
|
low: '一般'
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORTANCE_COLORS = {
|
||||||
|
high: '#ff5722',
|
||||||
|
medium: '#ff9800',
|
||||||
|
low: '#07c160'
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ANNIVERSARY_TYPES,
|
||||||
|
TYPE_NAMES,
|
||||||
|
TYPE_ICONS,
|
||||||
|
IMPORTANCE_TEXTS,
|
||||||
|
IMPORTANCE_COLORS
|
||||||
|
}
|
||||||
@@ -103,9 +103,28 @@ function parseDate(dateString) {
|
|||||||
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))
|
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算指定月日的下一次发生日期(今年或明年)
|
||||||
|
* @param {Number} month - 月份 (1-12)
|
||||||
|
* @param {Number} day - 日 (1-31)
|
||||||
|
* @returns {{ date: Date, daysUntil: Number }}
|
||||||
|
*/
|
||||||
|
function getNextOccurrence(month, day) {
|
||||||
|
const today = new Date()
|
||||||
|
const currentYear = today.getFullYear()
|
||||||
|
let date = new Date(currentYear, month - 1, day)
|
||||||
|
let daysUntil = getDaysUntil(date)
|
||||||
|
if (daysUntil < 0) {
|
||||||
|
date = new Date(currentYear + 1, month - 1, day)
|
||||||
|
daysUntil = getDaysUntil(date)
|
||||||
|
}
|
||||||
|
return { date, daysUntil }
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
formatDate,
|
formatDate,
|
||||||
getDaysUntil,
|
getDaysUntil,
|
||||||
|
getNextOccurrence,
|
||||||
isToday,
|
isToday,
|
||||||
isPast,
|
isPast,
|
||||||
isUpcoming,
|
isUpcoming,
|
||||||
|
|||||||
+157
-77
@@ -1,122 +1,203 @@
|
|||||||
/**
|
/**
|
||||||
* 农历日期转换工具
|
* 农历日期转换工具
|
||||||
* 使用简化版的农历计算方法
|
* 基于寿星万年历算法(1900-2100年)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 1900-2100年的农历数据
|
// 农历数据:每条数据记录该年的月份大小和闰月信息
|
||||||
|
// 格式:高4位=闰月大小(0无闰/1闰大月), 中4位=闰月位置, 低12位=12个月大小(1大/0小)
|
||||||
const lunarInfo = [
|
const lunarInfo = [
|
||||||
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
|
0x04bd8,0x04ae0,0x0a570,0x054d5,0x0d260,0x0d950,0x16554,0x056a0,0x09ad0,0x055d2, // 1900-1909
|
||||||
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
|
0x04ae0,0x0a5b6,0x0a4d0,0x0d250,0x1d255,0x0b540,0x0d6a0,0x0ada2,0x095b0,0x14977, // 1910-1919
|
||||||
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
|
0x04970,0x0a4b0,0x0b4b5,0x06a50,0x06d40,0x1ab54,0x02b60,0x09570,0x052f2,0x04970, // 1920-1929
|
||||||
0x06566, 0x0d4a0, 0x0ea50, 0x16a95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950
|
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
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
// 1900年1月31日是农历正月初一(庚子年)
|
||||||
* 农历年份
|
const BASE_DATE = new Date(1900, 0, 31)
|
||||||
*/
|
|
||||||
const lunarMonths = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊']
|
const LUNAR_MONTHS = ['正','二','三','四','五','六','七','八','九','十','冬','腊']
|
||||||
const lunarDays = ['初', '十', '廿', '三']
|
const LUNAR_DAYS_TENS = ['初','十','廿','三']
|
||||||
const lunarDayNames = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
const LUNAR_DAYS_UNITS = ['一','二','三','四','五','六','七','八','九','十']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取农历月份名称
|
* 获取农历某年的总天数
|
||||||
*/
|
*/
|
||||||
function getLunarMonthName(month) {
|
function _lunarYearDays(year) {
|
||||||
return lunarMonths[month - 1]
|
let total = 348
|
||||||
|
for (let i = 0x8000; i > 0x8; i >>= 1) {
|
||||||
|
total += (lunarInfo[year - 1900] & i) ? 1 : 0
|
||||||
|
}
|
||||||
|
return total + _leapDays(year)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取农历日期名称
|
* 获取农历某年闰月的天数
|
||||||
*/
|
*/
|
||||||
function getLunarDayName(day) {
|
function _leapDays(year) {
|
||||||
if (day === 10) return '初十'
|
if (_leapMonth(year)) {
|
||||||
if (day === 20) return '二十'
|
return (lunarInfo[year - 1900] & 0x10000) ? 30 : 29
|
||||||
if (day === 30) return '三十'
|
|
||||||
|
|
||||||
const tens = Math.floor(day / 10)
|
|
||||||
const units = day % 10
|
|
||||||
|
|
||||||
let name = lunarDays[tens]
|
|
||||||
if (units > 0) {
|
|
||||||
name += lunarDayNames[units - 1]
|
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
return name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算农历指定年的总天数
|
* 获取农历某年的闰月月份,0表示无闰月
|
||||||
*/
|
*/
|
||||||
function getLunarYearDays(year) {
|
function _leapMonth(year) {
|
||||||
let total = 0
|
return lunarInfo[year - 1900] & 0xf
|
||||||
for (let i = 0; i < 13; i++) {
|
}
|
||||||
const days = lunarInfo[year] & (0x8000 >> i) ? 30 : 29
|
|
||||||
total += days
|
/**
|
||||||
}
|
* 获取农历某年某月的天数
|
||||||
return total
|
*/
|
||||||
|
function _monthDays(year, month) {
|
||||||
|
return (lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公历转农历
|
* 公历转农历
|
||||||
* @param {Date} solarDate - 公历日期对象
|
* @param {Date} solarDate
|
||||||
* @returns {Object} 农历日期对象 {year, month, day, lunarText}
|
* @returns {{ year, month, day, isLeap, lunarText }}
|
||||||
*/
|
*/
|
||||||
function solarToLunar(solarDate) {
|
function solarToLunar(solarDate) {
|
||||||
const year = solarDate.getFullYear()
|
const date = new Date(solarDate.getFullYear(), solarDate.getMonth(), solarDate.getDate())
|
||||||
const month = solarDate.getMonth() + 1
|
let offset = Math.round((date - BASE_DATE) / 86400000)
|
||||||
const day = solarDate.getDate()
|
|
||||||
|
|
||||||
// 这是一个简化版本,实际计算需要完整的农历转换算法
|
let lunarYear, lunarMonth, lunarDay
|
||||||
// 这里返回一个示例结果
|
let isLeap = false
|
||||||
// 实际项目中建议使用成熟的农历库如 lunar-javascript
|
|
||||||
|
|
||||||
const offset = Math.floor((year - 1900) / 4) + (year - 1900) % 4
|
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 {
|
return {
|
||||||
year: year,
|
year: lunarYear,
|
||||||
month: month,
|
month: lunarMonth,
|
||||||
day: day,
|
day: lunarDay,
|
||||||
lunarText: `${getLunarMonthName(month)}月${getLunarDayName(day)}`
|
isLeap,
|
||||||
|
lunarText: `${isLeap ? '闰' : ''}${getLunarMonthName(lunarMonth)}月${getLunarDayName(lunarDay)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 农历转公历
|
* 农历转公历(指定年份)
|
||||||
* @param {Number} year - 农历年份
|
* @param {Number} lunarYear
|
||||||
* @param {Number} month - 农历月份
|
* @param {Number} lunarMonth
|
||||||
* @param {Number} day - 农历日期
|
* @param {Number} lunarDay
|
||||||
* @returns {Date} 公历日期对象
|
* @param {Boolean} isLeap 是否闰月
|
||||||
|
* @returns {Date}
|
||||||
*/
|
*/
|
||||||
function lunarToSolar(year, month, day) {
|
function lunarToSolar(lunarYear, lunarMonth, lunarDay, isLeap) {
|
||||||
// 简化版本,实际需要完整的农历转换算法
|
let offset = 0
|
||||||
// 这里返回当前日期作为示例
|
|
||||||
// 实际项目中建议使用成熟的农历库
|
|
||||||
|
|
||||||
const now = new Date()
|
for (let y = 1900; y < lunarYear; y++) {
|
||||||
return new Date(now.getFullYear(), month - 1, day)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取农历年份的下一个相同农历日期的公历日期
|
* 获取指定农历月日在今年或明年的公历日期
|
||||||
* @param {Number} lunarYear - 农历年份
|
|
||||||
* @param {Number} lunarMonth - 农历月份
|
|
||||||
* @param {Number} lunarDay - 农历日期
|
|
||||||
* @returns {Date} 下一次该农历日期的公历日期
|
|
||||||
*/
|
*/
|
||||||
function getNextLunarDate(lunarYear, lunarMonth, lunarDay) {
|
function getNextLunarDate(lunarMonth, lunarDay) {
|
||||||
// 简化版本,返回明年同一天的日期
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const nextYear = today.getFullYear() + 1
|
const currentYear = today.getFullYear()
|
||||||
return new Date(nextYear, today.getMonth(), today.getDate())
|
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) {
|
function formatLunarText(lunarDate) {
|
||||||
const lunar = solarToLunar(new Date(lunarDate.year, lunarDate.month - 1, lunarDate.day))
|
return solarToLunar(new Date(lunarDate.year, lunarDate.month - 1, lunarDate.day)).lunarText
|
||||||
return lunar.lunarText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -127,4 +208,3 @@ module.exports = {
|
|||||||
getLunarMonthName,
|
getLunarMonthName,
|
||||||
getLunarDayName
|
getLunarDayName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
-37
@@ -1,17 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* 本地存储管理工具
|
* 本地存储管理工具(含缓存层)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 内存缓存
|
||||||
|
const _cache = {
|
||||||
|
persons: null,
|
||||||
|
anniversaries: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function _invalidate() {
|
||||||
|
_cache.persons = null
|
||||||
|
_cache.anniversaries = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存储人员数据
|
* 存储人员数据
|
||||||
*/
|
*/
|
||||||
function savePersons(persons) {
|
function savePersons(persons) {
|
||||||
try {
|
try {
|
||||||
wx.setStorageSync('persons', persons)
|
wx.setStorageSync('persons', persons)
|
||||||
return true
|
_cache.persons = persons
|
||||||
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('保存人员数据失败', e)
|
console.error('保存人员数据失败', e)
|
||||||
return false
|
return { success: false, error: e.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +31,10 @@ function savePersons(persons) {
|
|||||||
* 获取人员数据
|
* 获取人员数据
|
||||||
*/
|
*/
|
||||||
function getPersons() {
|
function getPersons() {
|
||||||
|
if (_cache.persons !== null) return _cache.persons
|
||||||
try {
|
try {
|
||||||
return wx.getStorageSync('persons') || []
|
_cache.persons = wx.getStorageSync('persons') || []
|
||||||
|
return _cache.persons
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取人员数据失败', e)
|
console.error('获取人员数据失败', e)
|
||||||
return []
|
return []
|
||||||
@@ -31,8 +45,7 @@ function getPersons() {
|
|||||||
* 根据ID获取人员
|
* 根据ID获取人员
|
||||||
*/
|
*/
|
||||||
function getPersonById(id) {
|
function getPersonById(id) {
|
||||||
const persons = getPersons()
|
return getPersons().find(p => p.id === id) || null
|
||||||
return persons.find(p => p.id === id) || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +60,8 @@ function addPerson(person) {
|
|||||||
updateTime: new Date().getTime()
|
updateTime: new Date().getTime()
|
||||||
}
|
}
|
||||||
persons.push(newPerson)
|
persons.push(newPerson)
|
||||||
return savePersons(persons) ? newPerson : null
|
const result = savePersons(persons)
|
||||||
|
return result.success ? newPerson : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,12 +71,8 @@ function updatePerson(id, updates) {
|
|||||||
const persons = getPersons()
|
const persons = getPersons()
|
||||||
const index = persons.findIndex(p => p.id === id)
|
const index = persons.findIndex(p => p.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
persons[index] = {
|
persons[index] = { ...persons[index], ...updates, updateTime: new Date().getTime() }
|
||||||
...persons[index],
|
return savePersons(persons).success
|
||||||
...updates,
|
|
||||||
updateTime: new Date().getTime()
|
|
||||||
}
|
|
||||||
return savePersons(persons)
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -72,8 +82,7 @@ function updatePerson(id, updates) {
|
|||||||
*/
|
*/
|
||||||
function deletePerson(id) {
|
function deletePerson(id) {
|
||||||
const persons = getPersons()
|
const persons = getPersons()
|
||||||
const filtered = persons.filter(p => p.id !== id)
|
return savePersons(persons.filter(p => p.id !== id)).success
|
||||||
return savePersons(filtered)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,10 +91,11 @@ function deletePerson(id) {
|
|||||||
function saveAnniversaries(anniversaries) {
|
function saveAnniversaries(anniversaries) {
|
||||||
try {
|
try {
|
||||||
wx.setStorageSync('anniversaries', anniversaries)
|
wx.setStorageSync('anniversaries', anniversaries)
|
||||||
return true
|
_cache.anniversaries = anniversaries
|
||||||
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('保存纪念日数据失败', e)
|
console.error('保存纪念日数据失败', e)
|
||||||
return false
|
return { success: false, error: e.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +103,10 @@ function saveAnniversaries(anniversaries) {
|
|||||||
* 获取纪念日数据
|
* 获取纪念日数据
|
||||||
*/
|
*/
|
||||||
function getAnniversaries() {
|
function getAnniversaries() {
|
||||||
|
if (_cache.anniversaries !== null) return _cache.anniversaries
|
||||||
try {
|
try {
|
||||||
return wx.getStorageSync('anniversaries') || []
|
_cache.anniversaries = wx.getStorageSync('anniversaries') || []
|
||||||
|
return _cache.anniversaries
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取纪念日数据失败', e)
|
console.error('获取纪念日数据失败', e)
|
||||||
return []
|
return []
|
||||||
@@ -105,8 +117,7 @@ function getAnniversaries() {
|
|||||||
* 根据人员ID获取纪念日
|
* 根据人员ID获取纪念日
|
||||||
*/
|
*/
|
||||||
function getAnniversariesByPersonId(personId) {
|
function getAnniversariesByPersonId(personId) {
|
||||||
const anniversaries = getAnniversaries()
|
return getAnniversaries().filter(a => a.personId === personId)
|
||||||
return anniversaries.filter(a => a.personId === personId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,7 +132,8 @@ function addAnniversary(anniversary) {
|
|||||||
updateTime: new Date().getTime()
|
updateTime: new Date().getTime()
|
||||||
}
|
}
|
||||||
anniversaries.push(newAnniversary)
|
anniversaries.push(newAnniversary)
|
||||||
return saveAnniversaries(anniversaries) ? newAnniversary : null
|
const result = saveAnniversaries(anniversaries)
|
||||||
|
return result.success ? newAnniversary : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,12 +143,8 @@ function updateAnniversary(id, updates) {
|
|||||||
const anniversaries = getAnniversaries()
|
const anniversaries = getAnniversaries()
|
||||||
const index = anniversaries.findIndex(a => a.id === id)
|
const index = anniversaries.findIndex(a => a.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
anniversaries[index] = {
|
anniversaries[index] = { ...anniversaries[index], ...updates, updateTime: new Date().getTime() }
|
||||||
...anniversaries[index],
|
return saveAnniversaries(anniversaries).success
|
||||||
...updates,
|
|
||||||
updateTime: new Date().getTime()
|
|
||||||
}
|
|
||||||
return saveAnniversaries(anniversaries)
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -146,8 +154,26 @@ function updateAnniversary(id, updates) {
|
|||||||
*/
|
*/
|
||||||
function deleteAnniversary(id) {
|
function deleteAnniversary(id) {
|
||||||
const anniversaries = getAnniversaries()
|
const anniversaries = getAnniversaries()
|
||||||
const filtered = anniversaries.filter(a => a.id !== id)
|
return saveAnniversaries(anniversaries.filter(a => a.id !== id)).success
|
||||||
return saveAnniversaries(filtered)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子性删除人员及其所有纪念日
|
||||||
|
*/
|
||||||
|
function deletePersonWithAnniversaries(personId) {
|
||||||
|
try {
|
||||||
|
const persons = getPersons().filter(p => p.id !== personId)
|
||||||
|
const anniversaries = getAnniversaries().filter(a => a.personId !== personId)
|
||||||
|
wx.setStorageSync('persons', persons)
|
||||||
|
wx.setStorageSync('anniversaries', anniversaries)
|
||||||
|
_cache.persons = persons
|
||||||
|
_cache.anniversaries = anniversaries
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('删除人员及纪念日失败', e)
|
||||||
|
_invalidate() // 缓存可能不一致,强制失效
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,20 +195,46 @@ function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导入数据
|
* 验证人员数据结构
|
||||||
|
*/
|
||||||
|
function _validatePerson(p) {
|
||||||
|
return p && typeof p === 'object' && typeof p.id === 'string' && typeof p.name === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证纪念日数据结构
|
||||||
|
*/
|
||||||
|
function _validateAnniversary(a) {
|
||||||
|
return a && typeof a === 'object' && typeof a.id === 'string' &&
|
||||||
|
typeof a.personId === 'string' &&
|
||||||
|
typeof a.solarMonth === 'number' && typeof a.solarDay === 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入数据(含验证)
|
||||||
*/
|
*/
|
||||||
function importData(data) {
|
function importData(data) {
|
||||||
try {
|
try {
|
||||||
if (data.persons && Array.isArray(data.persons)) {
|
if (!data || typeof data !== 'object') {
|
||||||
wx.setStorageSync('persons', data.persons)
|
return { success: false, error: '无效的数据格式' }
|
||||||
}
|
}
|
||||||
if (data.anniversaries && Array.isArray(data.anniversaries)) {
|
if (!Array.isArray(data.persons) || !Array.isArray(data.anniversaries)) {
|
||||||
wx.setStorageSync('anniversaries', data.anniversaries)
|
return { success: false, error: 'persons 和 anniversaries 必须是数组' }
|
||||||
}
|
}
|
||||||
return true
|
if (!data.persons.every(_validatePerson)) {
|
||||||
|
return { success: false, error: '人员数据格式不正确' }
|
||||||
|
}
|
||||||
|
if (!data.anniversaries.every(_validateAnniversary)) {
|
||||||
|
return { success: false, error: '纪念日数据格式不正确' }
|
||||||
|
}
|
||||||
|
wx.setStorageSync('persons', data.persons)
|
||||||
|
wx.setStorageSync('anniversaries', data.anniversaries)
|
||||||
|
_cache.persons = data.persons
|
||||||
|
_cache.anniversaries = data.anniversaries
|
||||||
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('导入数据失败', e)
|
console.error('导入数据失败', e)
|
||||||
return false
|
return { success: false, error: e.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +244,7 @@ function importData(data) {
|
|||||||
function clearAllData() {
|
function clearAllData() {
|
||||||
try {
|
try {
|
||||||
wx.clearStorageSync()
|
wx.clearStorageSync()
|
||||||
|
_invalidate()
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('清空数据失败', e)
|
console.error('清空数据失败', e)
|
||||||
@@ -207,6 +260,7 @@ module.exports = {
|
|||||||
addPerson,
|
addPerson,
|
||||||
updatePerson,
|
updatePerson,
|
||||||
deletePerson,
|
deletePerson,
|
||||||
|
deletePersonWithAnniversaries,
|
||||||
|
|
||||||
// 纪念日相关
|
// 纪念日相关
|
||||||
saveAnniversaries,
|
saveAnniversaries,
|
||||||
@@ -221,4 +275,3 @@ module.exports = {
|
|||||||
importData,
|
importData,
|
||||||
clearAllData
|
clearAllData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
# 生日提醒小程序 - 上线部署指南
|
||||||
|
|
||||||
|
## 📋 上线流程概览
|
||||||
|
|
||||||
|
```
|
||||||
|
注册账号 → 获取AppID → 配置项目 → 上传代码 → 提交审核 → 发布上线
|
||||||
|
↓ ↓ ↓ ↓ ↓ ↓
|
||||||
|
30分钟 即时 5分钟 5分钟 1-7天 即时
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 第一步:注册小程序账号
|
||||||
|
|
||||||
|
### 1.1 前往微信公众平台注册
|
||||||
|
|
||||||
|
**网址**:https://mp.weixin.qq.com/
|
||||||
|
|
||||||
|
**注册步骤**:
|
||||||
|
1. 点击"立即注册"
|
||||||
|
2. 选择"小程序"类型
|
||||||
|
3. 填写账号信息(邮箱、密码)
|
||||||
|
4. 激活邮箱(点击邮件中的链接)
|
||||||
|
5. 选择主体类型:
|
||||||
|
- **个人**:无需营业执照(推荐个人项目)
|
||||||
|
- **企业**:需要营业执照和对公账户
|
||||||
|
|
||||||
|
### 1.2 填写主体信息
|
||||||
|
|
||||||
|
**个人类型需要**:
|
||||||
|
- 管理员身份证信息
|
||||||
|
- 管理员微信扫码认证(需要绑定银行卡)
|
||||||
|
|
||||||
|
**企业类型需要**:
|
||||||
|
- 营业执照
|
||||||
|
- 对公账户信息
|
||||||
|
- 管理员身份信息
|
||||||
|
- 300元认证费(可选,认证后可开通支付等高级功能)
|
||||||
|
|
||||||
|
### 1.3 完成注册
|
||||||
|
|
||||||
|
注册成功后,你将获得:
|
||||||
|
- ✅ **AppID**(重要!)
|
||||||
|
- ✅ 小程序管理后台访问权限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 第二步:获取并配置 AppID
|
||||||
|
|
||||||
|
### 2.1 获取 AppID
|
||||||
|
|
||||||
|
1. 登录 [微信公众平台](https://mp.weixin.qq.com/)
|
||||||
|
2. 进入"开发" → "开发管理" → "开发设置"
|
||||||
|
3. 找到 **AppID**(格式:wx1234567890abcdef)
|
||||||
|
4. 复制保存
|
||||||
|
|
||||||
|
### 2.2 配置项目 AppID
|
||||||
|
|
||||||
|
修改 `project.config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appid": "wx1234567890abcdef", // 替换为你的真实 AppID
|
||||||
|
"projectname": "birthday-reminder"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 添加开发者
|
||||||
|
|
||||||
|
如果有其他人协作开发:
|
||||||
|
|
||||||
|
1. 进入小程序后台
|
||||||
|
2. "管理" → "成员管理" → "项目成员"
|
||||||
|
3. 添加开发者(需要对方微信号)
|
||||||
|
4. 设置权限(开发者/体验者)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 第三步:完善小程序信息
|
||||||
|
|
||||||
|
### 3.1 基本信息设置
|
||||||
|
|
||||||
|
进入"设置" → "基本设置":
|
||||||
|
|
||||||
|
- **小程序名称**:生日提醒(或自定义)
|
||||||
|
- **小程序头像**:上传一个图标(推荐 256x256 像素)
|
||||||
|
- **小程序介绍**:
|
||||||
|
```
|
||||||
|
一款轻量级的生日提醒工具,支持公历和农历生日,
|
||||||
|
智能提醒让你不错过任何重要日子。
|
||||||
|
```
|
||||||
|
- **服务类目**:
|
||||||
|
- 工具 → 效率 → 备忘录/提醒
|
||||||
|
- 或 生活服务 → 综合生活服务
|
||||||
|
|
||||||
|
### 3.2 隐私保护设置
|
||||||
|
|
||||||
|
进入"设置" → "隐私与安全设置":
|
||||||
|
|
||||||
|
1. **用户隐私保护指引**:
|
||||||
|
```
|
||||||
|
本小程序会收集和使用以下信息:
|
||||||
|
1. 您添加的人员姓名、生日等信息
|
||||||
|
2. 所有数据仅存储在您的设备本地
|
||||||
|
3. 我们不会上传或分享您的任何数据
|
||||||
|
|
||||||
|
数据使用目的:
|
||||||
|
- 提供生日提醒服务
|
||||||
|
- 展示纪念日列表和日历
|
||||||
|
|
||||||
|
您的权利:
|
||||||
|
- 可随时删除数据
|
||||||
|
- 可导出备份数据
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **涉及的接口**:
|
||||||
|
- 选择图片(上传头像)
|
||||||
|
- 剪贴板(导入/导出数据)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📤 第四步:上传代码
|
||||||
|
|
||||||
|
### 4.1 准备上传
|
||||||
|
|
||||||
|
在微信开发者工具中:
|
||||||
|
|
||||||
|
1. 点击工具栏的"上传"按钮
|
||||||
|
2. 填写版本号和备注:
|
||||||
|
```
|
||||||
|
版本号:1.0.0
|
||||||
|
备注:首次发布,包含核心功能
|
||||||
|
```
|
||||||
|
3. 点击"上传"
|
||||||
|
|
||||||
|
### 4.2 版本号规范
|
||||||
|
|
||||||
|
建议使用语义化版本:
|
||||||
|
- **1.0.0**:首次发布
|
||||||
|
- **1.0.1**:修复bug
|
||||||
|
- **1.1.0**:新增功能
|
||||||
|
- **2.0.0**:重大更新
|
||||||
|
|
||||||
|
### 4.3 上传成功
|
||||||
|
|
||||||
|
上传成功后,代码会出现在小程序后台的"开发管理" → "开发版本"中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 第五步:体验测试
|
||||||
|
|
||||||
|
### 5.1 添加体验者
|
||||||
|
|
||||||
|
上传后先让其他人体验测试:
|
||||||
|
|
||||||
|
1. 进入小程序后台
|
||||||
|
2. "管理" → "成员管理" → "项目成员"
|
||||||
|
3. 添加"体验者"(最多50个)
|
||||||
|
4. 体验者扫码即可使用
|
||||||
|
|
||||||
|
### 5.2 体验版测试
|
||||||
|
|
||||||
|
- 体验者可以完整测试所有功能
|
||||||
|
- 发现问题及时修复
|
||||||
|
- 重新上传新版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 第六步:提交审核
|
||||||
|
|
||||||
|
### 6.1 配置审核信息
|
||||||
|
|
||||||
|
进入"开发管理" → "版本管理" → "开发版本" → "提交审核"
|
||||||
|
|
||||||
|
**填写信息**:
|
||||||
|
|
||||||
|
1. **功能页面**:
|
||||||
|
```
|
||||||
|
页面类型:首页
|
||||||
|
页面功能:查看人员生日列表
|
||||||
|
|
||||||
|
页面类型:添加页面
|
||||||
|
页面功能:添加人员信息和纪念日
|
||||||
|
|
||||||
|
页面类型:日历页面
|
||||||
|
页面功能:查看纪念日日历
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **可提供的测试账号**:
|
||||||
|
```
|
||||||
|
无需测试账号(或提供一个演示账号)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **版本描述**:
|
||||||
|
```
|
||||||
|
生日提醒小程序首次发布版本,主要功能:
|
||||||
|
1. 添加和管理人员信息
|
||||||
|
2. 支持公历和农历生日
|
||||||
|
3. 多种纪念日类型(生日、结婚纪念日等)
|
||||||
|
4. 智能提醒功能
|
||||||
|
5. 日历视图展示
|
||||||
|
6. 数据导入导出
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 审核时长
|
||||||
|
|
||||||
|
- **正常情况**:1-3个工作日
|
||||||
|
- **节假日**:可能延长至7天
|
||||||
|
- **首次审核**:可能会慢一些
|
||||||
|
|
||||||
|
### 6.3 审核要点
|
||||||
|
|
||||||
|
**容易被拒的原因**:
|
||||||
|
- ❌ 功能描述不清晰
|
||||||
|
- ❌ 涉及未申请的类目
|
||||||
|
- ❌ 缺少隐私政策
|
||||||
|
- ❌ 测试账号无法登录
|
||||||
|
- ❌ 页面空白或无内容
|
||||||
|
|
||||||
|
**避免被拒**:
|
||||||
|
- ✅ 填写清晰的功能描述
|
||||||
|
- ✅ 选择正确的服务类目
|
||||||
|
- ✅ 完善隐私保护指引
|
||||||
|
- ✅ 确保所有功能正常
|
||||||
|
- ✅ 添加测试数据供审核
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第七步:发布上线
|
||||||
|
|
||||||
|
### 7.1 审核通过
|
||||||
|
|
||||||
|
收到审核通过通知后:
|
||||||
|
|
||||||
|
1. 进入"开发管理" → "版本管理"
|
||||||
|
2. 找到"审核通过"的版本
|
||||||
|
3. 点击"发布"按钮
|
||||||
|
4. 确认发布
|
||||||
|
|
||||||
|
### 7.2 发布后
|
||||||
|
|
||||||
|
- ⏱️ **生效时间**:5-10分钟内全网生效
|
||||||
|
- 🔍 **搜索**:用户可以在微信搜索到你的小程序
|
||||||
|
- 📱 **分享**:可以分享给好友使用
|
||||||
|
|
||||||
|
### 7.3 获取小程序码
|
||||||
|
|
||||||
|
发布后可以生成小程序码:
|
||||||
|
|
||||||
|
1. 进入"设置" → "基本设置" → "小程序码"
|
||||||
|
2. 下载并分享给用户
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 第八步:运营推广
|
||||||
|
|
||||||
|
### 8.1 数据监控
|
||||||
|
|
||||||
|
进入"统计" → "数据分析",查看:
|
||||||
|
- 访问次数
|
||||||
|
- 用户数量
|
||||||
|
- 留存率
|
||||||
|
- 使用时长
|
||||||
|
|
||||||
|
### 8.2 推广方式
|
||||||
|
|
||||||
|
1. **朋友圈分享**:生成小程序卡片
|
||||||
|
2. **微信群分享**:发送给感兴趣的群
|
||||||
|
3. **公众号关联**:如有公众号可关联推广
|
||||||
|
4. **附近的小程序**:设置门店位置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 后续更新流程
|
||||||
|
|
||||||
|
### 9.1 版本更新
|
||||||
|
|
||||||
|
发现bug或要添加新功能:
|
||||||
|
|
||||||
|
1. 修改代码
|
||||||
|
2. 测试通过
|
||||||
|
3. 上传新版本(版本号递增)
|
||||||
|
4. 提交审核
|
||||||
|
5. 审核通过后发布
|
||||||
|
|
||||||
|
### 9.2 紧急修复
|
||||||
|
|
||||||
|
如果发现严重bug:
|
||||||
|
|
||||||
|
1. 快速修复代码
|
||||||
|
2. 使用"加急审核"(有次数限制)
|
||||||
|
3. 通过后立即发布
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 费用说明
|
||||||
|
|
||||||
|
### 个人类型(推荐)
|
||||||
|
|
||||||
|
- ✅ **注册**:免费
|
||||||
|
- ✅ **认证**:不需要(个人类型无需认证)
|
||||||
|
- ✅ **上线**:免费
|
||||||
|
- ✅ **使用**:免费
|
||||||
|
|
||||||
|
**限制**:
|
||||||
|
- ❌ 无法开通支付功能
|
||||||
|
- ❌ 部分高级接口受限
|
||||||
|
- ✅ 本项目的所有功能都不受影响
|
||||||
|
|
||||||
|
### 企业类型
|
||||||
|
|
||||||
|
- ✅ **注册**:免费
|
||||||
|
- 💰 **认证**:300元/年(可选)
|
||||||
|
- ✅ **上线**:免费
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
- ✅ 可开通微信支付
|
||||||
|
- ✅ 可使用所有高级接口
|
||||||
|
- ✅ 可关联公众号
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 常见问题
|
||||||
|
|
||||||
|
### Q1: 个人可以注册小程序吗?
|
||||||
|
**A**: 可以!个人注册完全免费,本项目的所有功能都支持。
|
||||||
|
|
||||||
|
### Q2: 多久可以上线?
|
||||||
|
**A**:
|
||||||
|
- 注册:30分钟
|
||||||
|
- 配置:10分钟
|
||||||
|
- 上传:5分钟
|
||||||
|
- 审核:1-7天
|
||||||
|
- **总计**:最快1天,一般3-5天
|
||||||
|
|
||||||
|
### Q3: 审核不通过怎么办?
|
||||||
|
**A**:
|
||||||
|
1. 查看拒绝原因
|
||||||
|
2. 根据要求修改
|
||||||
|
3. 重新提交审核
|
||||||
|
4. 审核次数不限
|
||||||
|
|
||||||
|
### Q4: 小程序可以改名吗?
|
||||||
|
**A**:
|
||||||
|
- 个人类型:一年可改2次
|
||||||
|
- 企业已认证:一年可改2次
|
||||||
|
- 需要在后台申请
|
||||||
|
|
||||||
|
### Q5: 需要服务器吗?
|
||||||
|
**A**: 本项目不需要!所有数据存储在用户本地。
|
||||||
|
|
||||||
|
### Q6: 可以商用吗?
|
||||||
|
**A**: 可以,建议使用企业类型注册。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 快速检查清单
|
||||||
|
|
||||||
|
上线前检查:
|
||||||
|
|
||||||
|
- [ ] 已注册小程序账号
|
||||||
|
- [ ] 已获取并配置 AppID
|
||||||
|
- [ ] 已完善小程序信息(名称、头像、介绍)
|
||||||
|
- [ ] 已设置服务类目
|
||||||
|
- [ ] 已配置隐私保护指引
|
||||||
|
- [ ] 已在开发工具中测试通过
|
||||||
|
- [ ] 已添加体验者测试
|
||||||
|
- [ ] 代码已上传到小程序后台
|
||||||
|
- [ ] 已准备审核资料(功能描述、截图)
|
||||||
|
- [ ] 已提交审核
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 需要帮助?
|
||||||
|
|
||||||
|
### 官方资源
|
||||||
|
|
||||||
|
- **开发文档**:https://developers.weixin.qq.com/miniprogram/dev/framework/
|
||||||
|
- **社区论坛**:https://developers.weixin.qq.com/community/minigame
|
||||||
|
- **客服**:登录小程序后台,右上角"客服"
|
||||||
|
|
||||||
|
### 关键联系方式
|
||||||
|
|
||||||
|
- **小程序客服**:400-901-0500(工作日 9:00-18:00)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 恭喜!
|
||||||
|
|
||||||
|
按照本指南操作,你的小程序很快就能上线让全网用户使用了!
|
||||||
|
|
||||||
|
**预计时间线**:
|
||||||
|
- 今天:注册账号、配置信息、上传代码
|
||||||
|
- 第2-3天:等待审核
|
||||||
|
- 第3-5天:审核通过,正式上线
|
||||||
|
|
||||||
|
加油!🚀
|
||||||
|
|
||||||
+414
@@ -0,0 +1,414 @@
|
|||||||
|
# 生日提醒小程序 - 云开发功能说明
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
生日提醒小程序/
|
||||||
|
├── cloudfunctions/ # 云函数目录
|
||||||
|
│ ├── login/ # 获取用户openid
|
||||||
|
│ │ ├── index.js
|
||||||
|
│ │ ├── config.json
|
||||||
|
│ │ └── package.json
|
||||||
|
│ ├── syncAnniversary/ # 同步纪念日数据
|
||||||
|
│ │ ├── index.js
|
||||||
|
│ │ ├── config.json
|
||||||
|
│ │ └── package.json
|
||||||
|
│ └── sendReminder/ # 发送提醒消息
|
||||||
|
│ ├── index.js
|
||||||
|
│ ├── config.json # 包含定时触发器配置
|
||||||
|
│ └── package.json
|
||||||
|
├── pages/ # 小程序页面
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
├── app.js # 应用入口(已添加云开发初始化)
|
||||||
|
├── app.json # 应用配置
|
||||||
|
└── project.config.json # 项目配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 核心功能实现
|
||||||
|
|
||||||
|
### 1. 云开发初始化(app.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 应用启动时初始化云开发
|
||||||
|
wx.cloud.init({
|
||||||
|
env: 'cloudbase-1gk3x0ia3a6b1f80',
|
||||||
|
traceUser: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 自动获取用户openid
|
||||||
|
await wx.cloud.callFunction({ name: 'login' })
|
||||||
|
```
|
||||||
|
|
||||||
|
**作用**:
|
||||||
|
- 连接到你的云开发环境
|
||||||
|
- 获取用户唯一标识(openid)
|
||||||
|
- 为后续功能提供基础
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 订阅消息授权(add-anniversary.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 用户添加纪念日并开启提醒时
|
||||||
|
wx.requestSubscribeMessage({
|
||||||
|
tmplIds: ['6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw']
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**流程**:
|
||||||
|
1. 用户添加纪念日
|
||||||
|
2. 开启"是否提醒"开关
|
||||||
|
3. 点击保存
|
||||||
|
4. 弹出订阅授权弹窗
|
||||||
|
5. 用户点击"允许"
|
||||||
|
6. 数据保存到本地和云端
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- ✅ 用户授权后才能收到推送
|
||||||
|
- ✅ 一次授权,长期有效
|
||||||
|
- ✅ 用户可随时管理订阅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 数据云端同步(syncAnniversary 云函数)
|
||||||
|
|
||||||
|
**支持的操作**:
|
||||||
|
|
||||||
|
#### 添加纪念日
|
||||||
|
```javascript
|
||||||
|
wx.cloud.callFunction({
|
||||||
|
name: 'syncAnniversary',
|
||||||
|
data: {
|
||||||
|
action: 'add',
|
||||||
|
data: { ...anniversaryData }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新纪念日
|
||||||
|
```javascript
|
||||||
|
wx.cloud.callFunction({
|
||||||
|
name: 'syncAnniversary',
|
||||||
|
data: {
|
||||||
|
action: 'update',
|
||||||
|
data: { id, ...anniversaryData }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除纪念日
|
||||||
|
```javascript
|
||||||
|
wx.cloud.callFunction({
|
||||||
|
name: 'syncAnniversary',
|
||||||
|
data: {
|
||||||
|
action: 'delete',
|
||||||
|
data: { id }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据结构**:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: "cloud-generated-id",
|
||||||
|
_openid: "user-openid", // 自动添加
|
||||||
|
personId: "person-id",
|
||||||
|
personName: "张三",
|
||||||
|
type: "birthday",
|
||||||
|
solarYear: 1990,
|
||||||
|
solarMonth: 5,
|
||||||
|
solarDay: 15,
|
||||||
|
remindEnabled: true,
|
||||||
|
remindDays: 7,
|
||||||
|
remark: "记得买蛋糕",
|
||||||
|
createTime: Date,
|
||||||
|
updateTime: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 定时发送提醒(sendReminder 云函数)
|
||||||
|
|
||||||
|
**执行时间**:每天上午9:00
|
||||||
|
|
||||||
|
**执行流程**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 查询所有启用提醒的纪念日
|
||||||
|
↓
|
||||||
|
2. 遍历每个纪念日
|
||||||
|
↓
|
||||||
|
3. 计算距离纪念日还有多少天
|
||||||
|
↓
|
||||||
|
4. 判断是否需要提醒
|
||||||
|
- 当天生日:发送
|
||||||
|
- 提前N天:发送(N=用户设置的remindDays)
|
||||||
|
↓
|
||||||
|
5. 检查今天是否已发送
|
||||||
|
↓
|
||||||
|
6. 调用微信API发送订阅消息
|
||||||
|
↓
|
||||||
|
7. 记录提醒日志
|
||||||
|
```
|
||||||
|
|
||||||
|
**提醒内容示例**:
|
||||||
|
|
||||||
|
```
|
||||||
|
称呼:张三
|
||||||
|
临近日期:还有3天
|
||||||
|
生日日期:2025年10月30日
|
||||||
|
温馨提示:别忘了准备一份礼物哦!
|
||||||
|
```
|
||||||
|
|
||||||
|
**日志记录**:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
anniversaryId: "anniversary-id",
|
||||||
|
personName: "张三",
|
||||||
|
typeName: "公历生日",
|
||||||
|
daysUntil: 3,
|
||||||
|
sendDate: Date,
|
||||||
|
status: "success" // 或 "failed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 数据流程图
|
||||||
|
|
||||||
|
### 用户添加纪念日
|
||||||
|
|
||||||
|
```
|
||||||
|
用户填写表单
|
||||||
|
↓
|
||||||
|
开启提醒开关
|
||||||
|
↓
|
||||||
|
点击保存
|
||||||
|
↓
|
||||||
|
[请求订阅授权] ← 用户授权
|
||||||
|
↓
|
||||||
|
保存到本地存储
|
||||||
|
↓
|
||||||
|
同步到云数据库 ← 包含 openid
|
||||||
|
↓
|
||||||
|
完成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 定时提醒流程
|
||||||
|
|
||||||
|
```
|
||||||
|
每天 9:00
|
||||||
|
↓
|
||||||
|
云函数被触发
|
||||||
|
↓
|
||||||
|
查询云数据库
|
||||||
|
↓
|
||||||
|
筛选需要提醒的纪念日
|
||||||
|
↓
|
||||||
|
循环处理每条记录
|
||||||
|
↓
|
||||||
|
计算剩余天数
|
||||||
|
↓
|
||||||
|
满足条件?
|
||||||
|
├─ 是 → 发送订阅消息 → 记录日志
|
||||||
|
└─ 否 → 跳过
|
||||||
|
↓
|
||||||
|
完成
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 关键技术点
|
||||||
|
|
||||||
|
### 1. openid 的作用
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// openid 是用户在你的小程序中的唯一标识
|
||||||
|
{
|
||||||
|
"_openid": "oABCD1234567890", // 自动添加到数据库记录
|
||||||
|
"personName": "张三",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- 区分不同用户的数据
|
||||||
|
- 发送订阅消息时指定接收者
|
||||||
|
- 保证数据安全(用户只能访问自己的数据)
|
||||||
|
|
||||||
|
### 2. 订阅消息模板
|
||||||
|
|
||||||
|
**你的模板信息**:
|
||||||
|
```
|
||||||
|
模板ID: 6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw
|
||||||
|
|
||||||
|
字段映射:
|
||||||
|
- name1.DATA → 称呼(人员姓名)
|
||||||
|
- thing2.DATA → 临近日期(还有X天)
|
||||||
|
- thing6.DATA → 生日日期(2025年10月30日)
|
||||||
|
- thing5.DATA → 温馨提示(备注或默认文案)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 定时触发器
|
||||||
|
|
||||||
|
**Cron 表达式**:`0 0 9 * * * *`
|
||||||
|
|
||||||
|
```
|
||||||
|
秒 分 时 日 月 星期 年
|
||||||
|
0 0 9 * * * *
|
||||||
|
|
||||||
|
含义:每天上午9点0分0秒执行
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改提醒时间**:
|
||||||
|
```
|
||||||
|
每天早上8点: 0 0 8 * * * *
|
||||||
|
每天晚上8点: 0 0 20 * * * *
|
||||||
|
每天早晚两次: 0 0 8,20 * * * *
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 数据库设计
|
||||||
|
|
||||||
|
### anniversaries(纪念日表)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| _id | String | 主键(自动生成) | "5f1e..." |
|
||||||
|
| _openid | String | 用户openid(自动) | "oABC..." |
|
||||||
|
| personId | String | 人员ID | "id_123..." |
|
||||||
|
| personName | String | 人员姓名 | "张三" |
|
||||||
|
| type | String | 类型 | "birthday" |
|
||||||
|
| solarMonth | Number | 公历月份 | 10 |
|
||||||
|
| solarDay | Number | 公历日期 | 30 |
|
||||||
|
| remindEnabled | Boolean | 是否提醒 | true |
|
||||||
|
| remindDays | Number | 提前天数 | 7 |
|
||||||
|
| remark | String | 备注 | "买蛋糕" |
|
||||||
|
|
||||||
|
### remind_logs(提醒日志表)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| _id | String | 主键(自动生成) | "5f1e..." |
|
||||||
|
| anniversaryId | String | 纪念日ID | "5f1e..." |
|
||||||
|
| personName | String | 人员姓名 | "张三" |
|
||||||
|
| daysUntil | Number | 剩余天数 | 3 |
|
||||||
|
| sendDate | Date | 发送时间 | Date对象 |
|
||||||
|
| status | String | 状态 | "success" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 注意事项
|
||||||
|
|
||||||
|
### 1. 订阅消息限制
|
||||||
|
|
||||||
|
- ✅ 用户主动触发授权(不能自动弹出)
|
||||||
|
- ✅ 一次授权一个模板
|
||||||
|
- ✅ 用户可以随时取消订阅
|
||||||
|
- ❌ 不能发送营销类消息
|
||||||
|
|
||||||
|
### 2. 云函数限制
|
||||||
|
|
||||||
|
**免费额度**(每天):
|
||||||
|
- 调用次数:10万次
|
||||||
|
- 运行时间:40万GBs
|
||||||
|
- 出流量:5GB
|
||||||
|
|
||||||
|
**对于个人用户,完全够用!**
|
||||||
|
|
||||||
|
### 3. 数据库限制
|
||||||
|
|
||||||
|
**免费额度**:
|
||||||
|
- 容量:5GB
|
||||||
|
- 读操作:5万次/天
|
||||||
|
- 写操作:3万次/天
|
||||||
|
|
||||||
|
**估算**:
|
||||||
|
- 100个用户,每人10条纪念日 = 1000条记录
|
||||||
|
- 每条约1KB,总共约1MB
|
||||||
|
- 完全在免费额度内!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 调试技巧
|
||||||
|
|
||||||
|
### 1. 查看云函数日志
|
||||||
|
|
||||||
|
```
|
||||||
|
云开发控制台 → 云函数 → 选择函数 → 日志
|
||||||
|
```
|
||||||
|
|
||||||
|
**可以看到**:
|
||||||
|
- 函数执行时间
|
||||||
|
- 返回结果
|
||||||
|
- 错误信息
|
||||||
|
- console.log 输出
|
||||||
|
|
||||||
|
### 2. 测试云函数
|
||||||
|
|
||||||
|
```
|
||||||
|
云开发控制台 → 云函数 → 选择函数 → 测试
|
||||||
|
|
||||||
|
输入测试参数:{}
|
||||||
|
点击"运行测试"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 查看数据库
|
||||||
|
|
||||||
|
```
|
||||||
|
云开发控制台 → 数据库 → 选择集合
|
||||||
|
|
||||||
|
可以:
|
||||||
|
- 查看所有记录
|
||||||
|
- 编辑数据
|
||||||
|
- 删除数据
|
||||||
|
- 导出数据
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 模拟定时触发
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 临时修改 Cron 表达式为每分钟执行
|
||||||
|
"0 */1 * * * * *"
|
||||||
|
|
||||||
|
// 测试完成后改回
|
||||||
|
"0 0 9 * * * *"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 优化建议
|
||||||
|
|
||||||
|
### 1. 添加提醒历史页面
|
||||||
|
|
||||||
|
显示用户收到的所有提醒记录。
|
||||||
|
|
||||||
|
### 2. 支持多个提醒时间
|
||||||
|
|
||||||
|
比如提前7天、3天、当天各提醒一次。
|
||||||
|
|
||||||
|
### 3. 自定义提醒文案
|
||||||
|
|
||||||
|
让用户可以自定义温馨提示内容。
|
||||||
|
|
||||||
|
### 4. 提醒统计
|
||||||
|
|
||||||
|
显示总共发送了多少条提醒,成功率等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 总结
|
||||||
|
|
||||||
|
现在你的小程序具备了完整的云端能力:
|
||||||
|
|
||||||
|
✅ **数据云端存储** - 永不丢失
|
||||||
|
✅ **自动推送提醒** - 不再遗忘
|
||||||
|
✅ **多设备同步** - 随处访问
|
||||||
|
✅ **安全可靠** - openid隔离
|
||||||
|
|
||||||
|
**开始享受智能生日提醒服务吧!** 🎂
|
||||||
|
|
||||||
+349
@@ -0,0 +1,349 @@
|
|||||||
|
# 生日提醒小程序 - 云开发部署指南
|
||||||
|
|
||||||
|
## 📋 部署步骤概览
|
||||||
|
|
||||||
|
```
|
||||||
|
上传云函数 → 创建数据库集合 → 配置定时触发器 → 测试功能
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 第一步:上传云函数
|
||||||
|
|
||||||
|
### 1.1 打开云开发控制台
|
||||||
|
|
||||||
|
1. 在微信开发者工具中,点击顶部菜单 **"云开发"**
|
||||||
|
2. 进入云开发控制台
|
||||||
|
3. 确认环境ID为:`cloudbase-1gk3x0ia3a6b1f80`
|
||||||
|
|
||||||
|
### 1.2 上传云函数
|
||||||
|
|
||||||
|
需要上传3个云函数:
|
||||||
|
|
||||||
|
#### 1. 上传 login 云函数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
右键点击 cloudfunctions/login
|
||||||
|
→ 选择 "上传并部署:云端安装依赖"
|
||||||
|
→ 等待上传完成(约30秒)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 上传 syncAnniversary 云函数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
右键点击 cloudfunctions/syncAnniversary
|
||||||
|
→ 选择 "上传并部署:云端安装依赖"
|
||||||
|
→ 等待上传完成(约30秒)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 上传 sendReminder 云函数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
右键点击 cloudfunctions/sendReminder
|
||||||
|
→ 选择 "上传并部署:云端安装依赖"
|
||||||
|
→ 等待上传完成(约30秒)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 验证上传结果
|
||||||
|
|
||||||
|
在云开发控制台 → 云函数,应该看到3个云函数:
|
||||||
|
- ✅ login
|
||||||
|
- ✅ syncAnniversary
|
||||||
|
- ✅ sendReminder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ 第二步:创建数据库集合
|
||||||
|
|
||||||
|
### 2.1 进入数据库管理
|
||||||
|
|
||||||
|
云开发控制台 → 数据库
|
||||||
|
|
||||||
|
### 2.2 创建集合
|
||||||
|
|
||||||
|
需要创建2个集合:
|
||||||
|
|
||||||
|
#### 1. anniversaries(纪念日数据)
|
||||||
|
|
||||||
|
```
|
||||||
|
点击 "添加集合"
|
||||||
|
集合名称:anniversaries
|
||||||
|
权限设置:仅创建者可读写
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: String, // 自动生成
|
||||||
|
_openid: String, // 自动生成(用户openid)
|
||||||
|
personId: String, // 人员ID
|
||||||
|
personName: String, // 人员姓名
|
||||||
|
type: String, // 类型:birthday/lunar_birthday/wedding等
|
||||||
|
customTypeName: String,// 自定义类型名称
|
||||||
|
isLunar: Boolean, // 是否农历
|
||||||
|
solarYear: Number, // 公历年份
|
||||||
|
solarMonth: Number, // 公历月份
|
||||||
|
solarDay: Number, // 公历日期
|
||||||
|
importance: String, // 重要程度
|
||||||
|
remindEnabled: Boolean,// 是否开启提醒
|
||||||
|
remindDays: Number, // 提前提醒天数
|
||||||
|
remark: String, // 备注
|
||||||
|
createTime: Date, // 创建时间
|
||||||
|
updateTime: Date // 更新时间
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. remind_logs(提醒日志)
|
||||||
|
|
||||||
|
```
|
||||||
|
点击 "添加集合"
|
||||||
|
集合名称:remind_logs
|
||||||
|
权限设置:仅创建者可读写
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: String, // 自动生成
|
||||||
|
anniversaryId: String, // 纪念日ID
|
||||||
|
personName: String, // 人员姓名
|
||||||
|
typeName: String, // 类型名称
|
||||||
|
daysUntil: Number, // 剩余天数
|
||||||
|
sendDate: Date, // 发送日期
|
||||||
|
status: String, // 状态:success/failed
|
||||||
|
error: String // 错误信息(如果失败)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏰ 第三步:配置定时触发器
|
||||||
|
|
||||||
|
### 3.1 进入触发器管理
|
||||||
|
|
||||||
|
云开发控制台 → 云函数 → sendReminder → 触发器
|
||||||
|
|
||||||
|
### 3.2 创建触发器
|
||||||
|
|
||||||
|
**方法1:自动配置(推荐)**
|
||||||
|
|
||||||
|
触发器已在 `config.json` 中配置,上传云函数时会自动创建:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "dailyReminder",
|
||||||
|
"type": "timer",
|
||||||
|
"config": "0 0 9 * * * *"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法2:手动创建**
|
||||||
|
|
||||||
|
如果没有自动创建,手动添加:
|
||||||
|
|
||||||
|
```
|
||||||
|
点击 "新建触发器"
|
||||||
|
触发器名称:dailyReminder
|
||||||
|
触发周期:自定义
|
||||||
|
Cron表达式:0 0 9 * * * *
|
||||||
|
|
||||||
|
说明:每天上午9点执行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Cron 表达式说明
|
||||||
|
|
||||||
|
格式:`秒 分 时 日 月 星期 年`
|
||||||
|
|
||||||
|
常用配置:
|
||||||
|
```
|
||||||
|
0 0 9 * * * * - 每天上午9:00
|
||||||
|
0 0 8,20 * * * * - 每天上午8:00和晚上8:00
|
||||||
|
0 0 9 * * 1-5 * - 工作日上午9:00
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 第四步:测试功能
|
||||||
|
|
||||||
|
### 4.1 测试订阅消息授权
|
||||||
|
|
||||||
|
1. 在小程序中添加一个纪念日
|
||||||
|
2. 开启"是否提醒"开关
|
||||||
|
3. 点击保存
|
||||||
|
4. 应该弹出订阅消息授权弹窗
|
||||||
|
5. 点击"允许"
|
||||||
|
|
||||||
|
### 4.2 测试云函数
|
||||||
|
|
||||||
|
在云开发控制台 → 云函数 → sendReminder:
|
||||||
|
|
||||||
|
```
|
||||||
|
点击 "测试"
|
||||||
|
输入参数:{}
|
||||||
|
点击 "运行测试"
|
||||||
|
```
|
||||||
|
|
||||||
|
查看返回结果:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"total": 1,
|
||||||
|
"successCount": 1,
|
||||||
|
"failCount": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 查看数据库
|
||||||
|
|
||||||
|
在云开发控制台 → 数据库:
|
||||||
|
|
||||||
|
1. **anniversaries 集合**:应该看到刚才添加的纪念日数据
|
||||||
|
2. **remind_logs 集合**:如果触发了提醒,应该有日志记录
|
||||||
|
|
||||||
|
### 4.4 测试订阅消息接收
|
||||||
|
|
||||||
|
**方法1:手动触发(立即测试)**
|
||||||
|
|
||||||
|
在云开发控制台 → 云函数 → sendReminder → 运行测试
|
||||||
|
|
||||||
|
**方法2:等待定时触发**
|
||||||
|
|
||||||
|
等到第二天上午9:00,自动触发提醒
|
||||||
|
|
||||||
|
**方法3:修改定时器(快速测试)**
|
||||||
|
|
||||||
|
临时修改 Cron 表达式为:`0 */1 * * * * *`(每分钟执行一次)
|
||||||
|
测试完成后改回:`0 0 9 * * * *`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 第五步:项目配置
|
||||||
|
|
||||||
|
### 5.1 修改 project.config.json
|
||||||
|
|
||||||
|
确认云函数根目录配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cloudfunctionRoot": "cloudfunctions/",
|
||||||
|
"miniprogramRoot": "./",
|
||||||
|
"appid": "你的AppID",
|
||||||
|
"projectname": "生日提醒小程序"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 确认 app.json
|
||||||
|
|
||||||
|
无需额外配置,订阅消息权限已自动启用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 功能验证清单
|
||||||
|
|
||||||
|
完成以下验证,确保功能正常:
|
||||||
|
|
||||||
|
- [ ] 云开发初始化成功(控制台无报错)
|
||||||
|
- [ ] 能够获取到用户 openid
|
||||||
|
- [ ] 添加纪念日时弹出订阅授权
|
||||||
|
- [ ] 纪念日数据同步到云端(查看数据库)
|
||||||
|
- [ ] 手动执行 sendReminder 云函数成功
|
||||||
|
- [ ] 定时触发器配置成功
|
||||||
|
- [ ] 能够收到订阅消息推送
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### Q1: 云函数上传失败?
|
||||||
|
|
||||||
|
**A**: 检查网络连接,或手动安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloudfunctions/login
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
然后选择 "上传并部署:不安装依赖"
|
||||||
|
|
||||||
|
### Q2: 获取不到 openid?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
1. 确认已上传 login 云函数
|
||||||
|
2. 在真机上测试(开发者工具可能有限制)
|
||||||
|
3. 查看控制台日志
|
||||||
|
|
||||||
|
### Q3: 订阅消息没有弹出?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
1. 确认在真机上测试(开发者工具不支持)
|
||||||
|
2. 检查模板ID是否正确
|
||||||
|
3. 每个用户需要单独授权
|
||||||
|
|
||||||
|
### Q4: 定时触发器不执行?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
1. 确认 Cron 表达式正确
|
||||||
|
2. 查看云函数日志
|
||||||
|
3. 手动测试云函数是否正常
|
||||||
|
|
||||||
|
### Q5: 收不到订阅消息?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
1. 确认用户已授权订阅
|
||||||
|
2. 检查数据库中有 openid
|
||||||
|
3. 查看 remind_logs 是否有记录
|
||||||
|
4. 检查云函数权限配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 监控和维护
|
||||||
|
|
||||||
|
### 1. 查看云函数日志
|
||||||
|
|
||||||
|
云开发控制台 → 云函数 → 选择函数 → 日志
|
||||||
|
|
||||||
|
### 2. 查看提醒统计
|
||||||
|
|
||||||
|
查询 remind_logs 集合:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 查看今天的提醒记录
|
||||||
|
db.collection('remind_logs')
|
||||||
|
.where({
|
||||||
|
sendDate: _.gte(new Date(new Date().toDateString()))
|
||||||
|
})
|
||||||
|
.get()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 数据备份
|
||||||
|
|
||||||
|
定期导出数据库数据:
|
||||||
|
|
||||||
|
云开发控制台 → 数据库 → 导出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 费用说明
|
||||||
|
|
||||||
|
### 免费额度(每天)
|
||||||
|
|
||||||
|
- 云函数调用:10万次
|
||||||
|
- 数据库读操作:5万次
|
||||||
|
- 数据库写操作:3万次
|
||||||
|
- 云存储容量:5GB
|
||||||
|
|
||||||
|
对于个人使用,完全免费!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 部署完成!
|
||||||
|
|
||||||
|
现在你的生日提醒小程序已经具备了完整的推送提醒功能:
|
||||||
|
|
||||||
|
✅ 用户添加纪念日时自动请求订阅授权
|
||||||
|
✅ 数据自动同步到云端
|
||||||
|
✅ 每天上午9点自动检查并发送提醒
|
||||||
|
✅ 提醒记录自动保存到数据库
|
||||||
|
|
||||||
|
**享受你的智能生日提醒吧!** 🎂
|
||||||
|
|
||||||
@@ -43,15 +43,14 @@
|
|||||||
3. 选择本项目目录
|
3. 选择本项目目录
|
||||||
4. **AppID填写**:`touristappid`(使用测试号)
|
4. **AppID填写**:`touristappid`(使用测试号)
|
||||||
|
|
||||||
### 2. 添加图标(可选)
|
### 2. 图标说明
|
||||||
|
|
||||||
tabBar需要图标,可以在 `images/` 目录添加:
|
✅ tabBar 已配置为 emoji 图标模式,无需添加图片文件:
|
||||||
- `home.png` / `home-active.png`
|
- 🏠 首页
|
||||||
- `calendar.png` / `calendar-active.png`
|
- 📅 日历
|
||||||
- `settings.png` / `settings-active.png`
|
- ⚙️ 设置
|
||||||
- `default-avatar.png`
|
|
||||||
|
|
||||||
如果不添加图标,tabBar可能显示异常,但功能不受影响。
|
如需添加自定义图标,可以在 `images/` 目录添加图片并修改 `app.json` 配置。
|
||||||
|
|
||||||
### 3. 运行项目
|
### 3. 运行项目
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# 生日提醒小程序 - 云开发部署检查清单
|
||||||
|
|
||||||
|
## ✅ 部署前检查
|
||||||
|
|
||||||
|
### 1. 环境准备
|
||||||
|
- [ ] 已注册微信小程序账号
|
||||||
|
- [ ] 已获取 AppID
|
||||||
|
- [ ] 已开通云开发(环境ID:cloudbase-1gk3x0ia3a6b1f80)
|
||||||
|
- [ ] 已创建订阅消息模板(模板ID:6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw)
|
||||||
|
|
||||||
|
### 2. 代码检查
|
||||||
|
- [ ] app.js 已初始化云开发
|
||||||
|
- [ ] app.js 中的环境ID正确
|
||||||
|
- [ ] add-anniversary.js 已添加订阅授权代码
|
||||||
|
- [ ] add-anniversary.js 中的模板ID正确
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📤 上传云函数
|
||||||
|
|
||||||
|
### 1. login 云函数
|
||||||
|
```
|
||||||
|
□ 右键点击 cloudfunctions/login
|
||||||
|
□ 选择 "上传并部署:云端安装依赖"
|
||||||
|
□ 等待上传成功
|
||||||
|
□ 在云开发控制台确认已上传
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. syncAnniversary 云函数
|
||||||
|
```
|
||||||
|
□ 右键点击 cloudfunctions/syncAnniversary
|
||||||
|
□ 选择 "上传并部署:云端安装依赖"
|
||||||
|
□ 等待上传成功
|
||||||
|
□ 在云开发控制台确认已上传
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. sendReminder 云函数
|
||||||
|
```
|
||||||
|
□ 右键点击 cloudfunctions/sendReminder
|
||||||
|
□ 选择 "上传并部署:云端安装依赖"
|
||||||
|
□ 等待上传成功
|
||||||
|
□ 在云开发控制台确认已上传
|
||||||
|
□ 确认定时触发器已自动创建
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
云开发控制台 → 云函数,应该看到3个云函数:
|
||||||
|
- ✅ login
|
||||||
|
- ✅ syncAnniversary
|
||||||
|
- ✅ sendReminder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ 创建数据库集合
|
||||||
|
|
||||||
|
### 1. anniversaries 集合
|
||||||
|
```
|
||||||
|
□ 进入云开发控制台 → 数据库
|
||||||
|
□ 点击"添加集合"
|
||||||
|
□ 集合名称:anniversaries
|
||||||
|
□ 权限设置:仅创建者可读写
|
||||||
|
□ 点击"确定"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. remind_logs 集合
|
||||||
|
```
|
||||||
|
□ 点击"添加集合"
|
||||||
|
□ 集合名称:remind_logs
|
||||||
|
□ 权限设置:仅创建者可读写
|
||||||
|
□ 点击"确定"
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
云开发控制台 → 数据库,应该看到2个集合:
|
||||||
|
- ✅ anniversaries
|
||||||
|
- ✅ remind_logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏰ 配置定时触发器
|
||||||
|
|
||||||
|
### 检查触发器
|
||||||
|
```
|
||||||
|
□ 进入云开发控制台 → 云函数 → sendReminder
|
||||||
|
□ 点击"触发器"标签
|
||||||
|
□ 确认存在 dailyReminder 触发器
|
||||||
|
□ 确认 Cron 表达式为:0 0 9 * * * *
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如果没有触发器,手动创建
|
||||||
|
```
|
||||||
|
□ 点击"新建触发器"
|
||||||
|
□ 触发器名称:dailyReminder
|
||||||
|
□ 触发周期:自定义
|
||||||
|
□ Cron表达式:0 0 9 * * * *
|
||||||
|
□ 点击"确定"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 功能测试
|
||||||
|
|
||||||
|
### 1. 测试 openid 获取
|
||||||
|
```
|
||||||
|
□ 打开小程序
|
||||||
|
□ 查看控制台日志
|
||||||
|
□ 应该看到:"获取openid成功: oABC..."
|
||||||
|
□ 打开"存储" → 查看 Storage
|
||||||
|
□ 应该看到 openid 已保存
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 测试订阅消息授权
|
||||||
|
```
|
||||||
|
□ 在真机上打开小程序(必须真机)
|
||||||
|
□ 点击"添加纪念日"
|
||||||
|
□ 填写表单,开启"是否提醒"
|
||||||
|
□ 点击"保存"
|
||||||
|
□ 应该弹出订阅消息授权弹窗
|
||||||
|
□ 点击"允许"
|
||||||
|
□ 应该显示"添加成功"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 测试数据同步
|
||||||
|
```
|
||||||
|
□ 进入云开发控制台 → 数据库 → anniversaries
|
||||||
|
□ 应该看到刚才添加的纪念日记录
|
||||||
|
□ 记录中包含 _openid 字段
|
||||||
|
□ 数据完整无误
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 测试云函数
|
||||||
|
```
|
||||||
|
□ 进入云开发控制台 → 云函数 → sendReminder
|
||||||
|
□ 点击"测试"
|
||||||
|
□ 输入参数:{}
|
||||||
|
□ 点击"运行测试"
|
||||||
|
□ 查看返回结果,应该包含:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"total": 数字,
|
||||||
|
"successCount": 数字,
|
||||||
|
"failCount": 数字
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 测试订阅消息发送
|
||||||
|
|
||||||
|
**方法1:等待定时触发**
|
||||||
|
```
|
||||||
|
□ 添加一个明天的生日,提前1天提醒
|
||||||
|
□ 等到明天上午9:00
|
||||||
|
□ 应该收到订阅消息推送
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法2:手动触发(推荐)**
|
||||||
|
```
|
||||||
|
□ 添加一个今天的生日
|
||||||
|
□ 手动执行 sendReminder 云函数
|
||||||
|
□ 应该立即收到订阅消息推送
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法3:修改定时器(快速测试)**
|
||||||
|
```
|
||||||
|
□ 临时修改 Cron 为:0 */1 * * * * *
|
||||||
|
□ 等待1分钟
|
||||||
|
□ 应该收到订阅消息推送
|
||||||
|
□ 测试完成后改回:0 0 9 * * * *
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 验证清单
|
||||||
|
|
||||||
|
### 控制台日志验证
|
||||||
|
```
|
||||||
|
□ 看到:"云开发初始化成功"
|
||||||
|
□ 看到:"获取openid成功"
|
||||||
|
□ 看到:"订阅消息授权结果"
|
||||||
|
□ 看到:"云端同步成功"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库验证
|
||||||
|
```
|
||||||
|
□ anniversaries 集合有数据
|
||||||
|
□ 数据包含 _openid 字段
|
||||||
|
□ remindEnabled 为 true
|
||||||
|
□ personName 字段正确
|
||||||
|
```
|
||||||
|
|
||||||
|
### 提醒日志验证
|
||||||
|
```
|
||||||
|
□ remind_logs 集合有记录
|
||||||
|
□ status 为 "success"
|
||||||
|
□ sendDate 为今天
|
||||||
|
□ personName 正确
|
||||||
|
```
|
||||||
|
|
||||||
|
### 订阅消息验证
|
||||||
|
```
|
||||||
|
□ 收到了微信服务通知
|
||||||
|
□ 消息内容正确
|
||||||
|
□ 称呼、日期、提示都显示正确
|
||||||
|
□ 点击消息能跳转到小程序
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 故障排查
|
||||||
|
|
||||||
|
### 问题1:收不到订阅消息
|
||||||
|
|
||||||
|
**检查步骤**:
|
||||||
|
```
|
||||||
|
□ 确认用户已授权订阅
|
||||||
|
□ 确认 openid 已保存
|
||||||
|
□ 查看 sendReminder 云函数日志
|
||||||
|
□ 查看 remind_logs 是否有 failed 记录
|
||||||
|
□ 确认模板ID正确
|
||||||
|
□ 确认云函数权限包含 subscribeMessage.send
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题2:云函数执行失败
|
||||||
|
|
||||||
|
**检查步骤**:
|
||||||
|
```
|
||||||
|
□ 查看云函数日志,查找错误信息
|
||||||
|
□ 确认依赖已安装(查看 node_modules)
|
||||||
|
□ 确认 config.json 配置正确
|
||||||
|
□ 尝试重新上传云函数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题3:数据没有同步到云端
|
||||||
|
|
||||||
|
**检查步骤**:
|
||||||
|
```
|
||||||
|
□ 确认 openid 已获取
|
||||||
|
□ 查看控制台是否有错误
|
||||||
|
□ 确认 syncAnniversary 云函数已上传
|
||||||
|
□ 手动测试 syncAnniversary 云函数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题4:定时触发器不工作
|
||||||
|
|
||||||
|
**检查步骤**:
|
||||||
|
```
|
||||||
|
□ 确认触发器已创建
|
||||||
|
□ 确认 Cron 表达式正确
|
||||||
|
□ 查看云函数日志,确认有定时调用记录
|
||||||
|
□ 尝试手动触发云函数验证功能
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 性能监控
|
||||||
|
|
||||||
|
### 每日检查(可选)
|
||||||
|
```
|
||||||
|
□ 查看云函数调用次数
|
||||||
|
□ 查看订阅消息发送成功率
|
||||||
|
□ 查看 remind_logs 记录
|
||||||
|
□ 查看是否有异常日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 每周检查(可选)
|
||||||
|
```
|
||||||
|
□ 导出数据库备份
|
||||||
|
□ 清理过期的 remind_logs(可选)
|
||||||
|
□ 查看云开发资源使用情况
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 部署完成确认
|
||||||
|
|
||||||
|
全部完成以下项目即表示部署成功:
|
||||||
|
|
||||||
|
- [ ] 3个云函数全部上传成功
|
||||||
|
- [ ] 2个数据库集合创建成功
|
||||||
|
- [ ] 定时触发器配置成功
|
||||||
|
- [ ] 能够获取用户 openid
|
||||||
|
- [ ] 订阅消息授权正常弹出
|
||||||
|
- [ ] 数据能够同步到云端
|
||||||
|
- [ ] 手动执行云函数成功
|
||||||
|
- [ ] 能够收到订阅消息推送
|
||||||
|
- [ ] 提醒日志正常记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 恭喜部署成功!
|
||||||
|
|
||||||
|
你的生日提醒小程序现在具备:
|
||||||
|
- ✅ 云端数据存储
|
||||||
|
- ✅ 自动推送提醒
|
||||||
|
- ✅ 多设备同步
|
||||||
|
- ✅ 安全可靠
|
||||||
|
|
||||||
|
**开始使用吧!** 🎂
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 需要帮助?
|
||||||
|
|
||||||
|
如果遇到问题:
|
||||||
|
1. 查看《云开发部署指南.md》
|
||||||
|
2. 查看《云开发功能说明.md》
|
||||||
|
3. 查看云开发控制台的错误日志
|
||||||
|
4. 联系技术支持
|
||||||
|
|
||||||
Reference in New Issue
Block a user