- 新增 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_modules/
|
||||
|
||||
# 后端运行时数据 & 敏感配置
|
||||
server/.env
|
||||
server/data/
|
||||
server/node_modules/
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
const api = require('./utils/api')
|
||||
|
||||
App({
|
||||
onLaunch() {
|
||||
// 展示本地存储能力
|
||||
const logs = wx.getStorageSync('logs') || []
|
||||
logs.unshift(Date.now())
|
||||
wx.setStorageSync('logs', logs)
|
||||
|
||||
// 初始化数据
|
||||
this.initData()
|
||||
this.getUserOpenId()
|
||||
},
|
||||
|
||||
// 初始化数据结构
|
||||
initData() {
|
||||
// 检查是否有旧数据
|
||||
const persons = wx.getStorageSync('persons') || []
|
||||
const anniversaries = wx.getStorageSync('anniversaries') || []
|
||||
|
||||
if (persons.length === 0 && anniversaries.length === 0) {
|
||||
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: {
|
||||
userInfo: null
|
||||
userInfo: null,
|
||||
openid: ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -16,27 +16,21 @@
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#7A7E83",
|
||||
"selectedColor": "#3cc51f",
|
||||
"selectedColor": "#07c160",
|
||||
"borderStyle": "black",
|
||||
"backgroundColor": "#ffffff",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"iconPath": "/images/home.png",
|
||||
"selectedIconPath": "/images/home-active.png",
|
||||
"text": "首页"
|
||||
"text": "🏠 首页"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/calendar/calendar",
|
||||
"iconPath": "/images/calendar.png",
|
||||
"selectedIconPath": "/images/calendar-active.png",
|
||||
"text": "日历"
|
||||
"text": "📅 日历"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/settings/settings",
|
||||
"iconPath": "/images/settings.png",
|
||||
"selectedIconPath": "/images/settings-active.png",
|
||||
"text": "设置"
|
||||
"text": "⚙️ 设置"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
|
||||
/* 全局样式 */
|
||||
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',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
@@ -23,44 +24,53 @@ page {
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
border-radius: 8rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 32rpx;
|
||||
padding: 24rpx 48rpx;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #07c160;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
border: 2rpx solid #e8e8e8;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
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 {
|
||||
background-color: #fff;
|
||||
background-color: #f8f9fa;
|
||||
padding: 24rpx;
|
||||
border-radius: 8rpx;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 12rpx;
|
||||
border: 2rpx solid transparent;
|
||||
font-size: 28rpx;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
background-color: #fff;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
background-color: #f0f0f0;
|
||||
background: linear-gradient(90deg, transparent, #e0e0e0, transparent);
|
||||
margin: 32rpx 0;
|
||||
}
|
||||
|
||||
@@ -68,17 +78,20 @@ page {
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 120rpx 40rpx;
|
||||
color: #999;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 120rpx;
|
||||
font-size: 140rpx;
|
||||
margin-bottom: 32rpx;
|
||||
filter: drop-shadow(0 4rpx 8rpx rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.empty-state .text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
font-size: 32rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
/* 浮动按钮 */
|
||||
@@ -86,16 +99,22 @@ page {
|
||||
position: fixed;
|
||||
bottom: 120rpx;
|
||||
right: 40rpx;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #07c160;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 60rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
|
||||
font-size: 64rpx;
|
||||
box-shadow: 0 12rpx 40rpx rgba(102, 126, 234, 0.5);
|
||||
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
|
||||
const storage = require('../../utils/storage')
|
||||
const dateUtils = require('../../utils/date')
|
||||
const api = require('../../utils/api')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -188,11 +189,32 @@ Page({
|
||||
*/
|
||||
onRemindDaysChange(e) {
|
||||
const index = parseInt(e.detail.value)
|
||||
const days = [3, 7, 14, 30, 7][index]
|
||||
this.setData({
|
||||
remindDaysIndex: index,
|
||||
'formData.remindDays': days
|
||||
})
|
||||
this.setData({ remindDaysIndex: index })
|
||||
if (index === 4) {
|
||||
// 自定义天数
|
||||
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() {
|
||||
const { formData, personId, anniversaryId } = this.data
|
||||
async onSubmit() {
|
||||
const { formData, personId, anniversaryId, personList } = this.data
|
||||
|
||||
// 验证关联人员
|
||||
if (!personId) {
|
||||
@@ -235,14 +257,36 @@ Page({
|
||||
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) {
|
||||
// 编辑模式
|
||||
const success = storage.updateAnniversary(anniversaryId, {
|
||||
personId,
|
||||
personName,
|
||||
...formData
|
||||
})
|
||||
|
||||
if (success) {
|
||||
// 同步到云端
|
||||
this.syncToCloud(anniversaryId, {
|
||||
personId,
|
||||
personName,
|
||||
...formData
|
||||
}, 'update')
|
||||
|
||||
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 1500)
|
||||
}
|
||||
@@ -250,14 +294,55 @@ Page({
|
||||
// 新增模式
|
||||
const newAnniversary = storage.addAnniversary({
|
||||
personId,
|
||||
personName,
|
||||
...formData
|
||||
})
|
||||
|
||||
if (newAnniversary) {
|
||||
// 同步到云端
|
||||
this.syncToCloud(newAnniversary.id, newAnniversary, 'add')
|
||||
|
||||
wx.showToast({ title: '添加成功', icon: 'success' })
|
||||
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 {
|
||||
padding: 32rpx;
|
||||
padding: 0 32rpx 32rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
@@ -15,6 +15,11 @@
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-item:first-child {
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -100,36 +105,57 @@
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 24rpx;
|
||||
font-size: 28rpx;
|
||||
min-height: 160rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-top: 40rpx;
|
||||
padding: 0 32rpx 32rpx;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 16rpx;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: 48rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
background-color: #f5f5f5;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf0 100%);
|
||||
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 {
|
||||
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**add-person.wxss**/
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.08) 0%, transparent 30%);
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
@@ -10,16 +10,21 @@
|
||||
}
|
||||
|
||||
.form-item {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
|
||||
border-radius: 24rpx;
|
||||
padding: 36rpx;
|
||||
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 {
|
||||
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;
|
||||
display: block;
|
||||
}
|
||||
@@ -27,6 +32,7 @@
|
||||
.label.required::after {
|
||||
content: ' *';
|
||||
color: #ff5722;
|
||||
-webkit-text-fill-color: #ff5722;
|
||||
}
|
||||
|
||||
/* 头像上传 */
|
||||
@@ -38,7 +44,9 @@
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
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 {
|
||||
@@ -46,69 +54,107 @@
|
||||
height: 200rpx;
|
||||
margin: 0 auto;
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: 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 {
|
||||
font-size: 60rpx;
|
||||
font-size: 64rpx;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.avatar-placeholder .text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
font-size: 28rpx;
|
||||
border: 2rpx solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
background-color: #fff;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
font-size: 28rpx;
|
||||
min-height: 160rpx;
|
||||
border: 2rpx solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
background-color: #fff;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-top: 40rpx;
|
||||
padding: 0 32rpx 32rpx;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 16rpx;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: 48rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
background-color: #f5f5f5;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf0 100%);
|
||||
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 {
|
||||
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-cancel::after {
|
||||
border: none;
|
||||
.btn-submit:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-cancel::after,
|
||||
.btn-submit::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
+37
-75
@@ -1,11 +1,12 @@
|
||||
// calendar.js
|
||||
const storage = require('../../utils/storage')
|
||||
const dateUtils = require('../../utils/date')
|
||||
const { TYPE_NAMES, TYPE_ICONS, IMPORTANCE_COLORS } = require('../../utils/constants')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
currentYear: 2024,
|
||||
currentMonth: 1,
|
||||
currentYear: new Date().getFullYear(),
|
||||
currentMonth: new Date().getMonth() + 1,
|
||||
calendarDays: [],
|
||||
monthEvents: []
|
||||
},
|
||||
@@ -20,100 +21,75 @@ Page({
|
||||
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
|
||||
},
|
||||
|
||||
/**
|
||||
* 渲染日历
|
||||
*/
|
||||
renderCalendar() {
|
||||
const { currentYear, currentMonth } = this.data
|
||||
const anniversaries = storage.getAnniversaries()
|
||||
|
||||
// 获取本月第一天是星期几
|
||||
|
||||
// 预建事件索引,避免 O(n×m) 过滤
|
||||
const eventIndex = this.buildEventIndex(anniversaries, currentYear, currentMonth)
|
||||
|
||||
const firstDay = new Date(currentYear, currentMonth - 1, 1)
|
||||
const startWeekday = firstDay.getDay()
|
||||
|
||||
// 获取本月天数
|
||||
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate()
|
||||
|
||||
// 获取上个月的天数
|
||||
const prevMonthDays = new Date(currentYear, currentMonth - 1, 0).getDate()
|
||||
|
||||
// 构建日历天数数组
|
||||
|
||||
const calendarDays = []
|
||||
const today = new Date()
|
||||
|
||||
|
||||
// 填充上个月的日期
|
||||
for (let i = startWeekday - 1; i >= 0; i--) {
|
||||
const day = prevMonthDays - i
|
||||
calendarDays.push({
|
||||
day,
|
||||
date: new Date(currentYear, currentMonth - 2, day),
|
||||
isToday: false,
|
||||
isOtherMonth: true,
|
||||
events: [],
|
||||
hasMore: false
|
||||
})
|
||||
calendarDays.push({ day, isToday: false, isOtherMonth: true, events: [], hasMore: false })
|
||||
}
|
||||
|
||||
|
||||
// 填充本月的日期
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(currentYear, currentMonth - 1, day)
|
||||
const isToday = dateUtils.isToday(date)
|
||||
|
||||
// 查找该日期的纪念日
|
||||
const events = anniversaries
|
||||
.filter(a => this.isAnniversaryOnDate(a, date))
|
||||
.map(a => ({
|
||||
id: a.id,
|
||||
color: this.getEventColor(a)
|
||||
}))
|
||||
.slice(0, 3)
|
||||
|
||||
const allEvents = eventIndex[String(day)] || []
|
||||
const events = allEvents.slice(0, 3).map(a => ({ id: a.id, color: this.getEventColor(a) }))
|
||||
|
||||
calendarDays.push({
|
||||
day,
|
||||
date,
|
||||
isToday,
|
||||
isOtherMonth: false,
|
||||
events,
|
||||
hasMore: events.length > 2,
|
||||
moreCount: anniversaries.filter(a => this.isAnniversaryOnDate(a, date)).length - 2
|
||||
hasMore: allEvents.length > 3,
|
||||
moreCount: allEvents.length - 3
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 填充下个月的日期(补全6行)
|
||||
const remainingDays = 42 - calendarDays.length
|
||||
for (let day = 1; day <= remainingDays; day++) {
|
||||
calendarDays.push({
|
||||
day,
|
||||
date: new Date(currentYear, currentMonth, day),
|
||||
isToday: false,
|
||||
isOtherMonth: true,
|
||||
events: [],
|
||||
hasMore: false
|
||||
})
|
||||
calendarDays.push({ day, isToday: false, isOtherMonth: true, events: [], hasMore: false })
|
||||
}
|
||||
|
||||
this.setData({ calendarDays })
|
||||
},
|
||||
|
||||
/**
|
||||
* 判断纪念日是否在某日期
|
||||
*/
|
||||
isAnniversaryOnDate(anniversary, date) {
|
||||
return anniversary.solarYear === date.getFullYear() &&
|
||||
anniversary.solarMonth === date.getMonth() + 1 &&
|
||||
anniversary.solarDay === date.getDate()
|
||||
this.setData({ calendarDays })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取事件颜色
|
||||
*/
|
||||
getEventColor(anniversary) {
|
||||
const colors = {
|
||||
high: '#ff5722',
|
||||
medium: '#ff9800',
|
||||
low: '#07c160'
|
||||
}
|
||||
return colors[anniversary.importance] || '#07c160'
|
||||
return IMPORTANCE_COLORS[anniversary.importance] || '#07c160'
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -150,28 +126,14 @@ Page({
|
||||
* 获取类型图标
|
||||
*/
|
||||
getTypeIcon(type) {
|
||||
const icons = {
|
||||
birthday: '🎂',
|
||||
lunar_birthday: '🌙',
|
||||
wedding: '💍',
|
||||
engagement: '💕',
|
||||
other: '📅'
|
||||
}
|
||||
return icons[type] || '📅'
|
||||
return TYPE_ICONS[type] || '📅'
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取类型名称
|
||||
*/
|
||||
getTypeName(type) {
|
||||
const names = {
|
||||
birthday: '公历生日',
|
||||
lunar_birthday: '农历生日',
|
||||
wedding: '结婚纪念日',
|
||||
engagement: '订婚纪念日',
|
||||
other: '其他纪念日'
|
||||
}
|
||||
return names[type] || '其他'
|
||||
return TYPE_NAMES[type] || '其他'
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
|
||||
<!-- 星期标题 -->
|
||||
<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>
|
||||
|
||||
<!-- 日历内容 -->
|
||||
|
||||
+118
-51
@@ -5,43 +5,61 @@
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
background-color: #fff;
|
||||
padding: 32rpx;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40rpx 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.month-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
font-size: 48rpx;
|
||||
color: #07c160;
|
||||
font-size: 40rpx;
|
||||
color: #fff;
|
||||
font-weight: 300;
|
||||
width: 60rpx;
|
||||
text-align: center;
|
||||
width: 56rpx;
|
||||
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 {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: #fff;
|
||||
min-width: 240rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
padding: 12rpx 32rpx;
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
padding: 14rpx 32rpx;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
border-radius: 40rpx;
|
||||
font-size: 24rpx;
|
||||
font-size: 26rpx;
|
||||
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 {
|
||||
@@ -49,96 +67,137 @@
|
||||
}
|
||||
|
||||
.week-header {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
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 {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60rpx;
|
||||
font-size: 26rpx;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.week-title text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-content {
|
||||
height: calc(100vh - 200rpx);
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
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 {
|
||||
min-height: 120rpx;
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 8rpx;
|
||||
min-height: 100rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 12rpx;
|
||||
padding: 12rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: 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 {
|
||||
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 {
|
||||
background-color: #07c160;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.date-num {
|
||||
font-size: 28rpx;
|
||||
font-size: 32rpx;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-cell.other-month .date-num {
|
||||
color: #ccc;
|
||||
color: #d0d0d0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.events {
|
||||
display: flex;
|
||||
gap: 4rpx;
|
||||
gap: 6rpx;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
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 {
|
||||
font-size: 20rpx;
|
||||
font-size: 18rpx;
|
||||
color: #999;
|
||||
margin-top: 4rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.calendar-cell.today .more-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 事件列表 */
|
||||
.events-list {
|
||||
padding: 32rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 24rpx;
|
||||
padding-left: 16rpx;
|
||||
border-left: 6rpx solid #667eea;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 120rpx 40rpx;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
@@ -150,14 +209,17 @@
|
||||
.empty-state .text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
background-color: #fff;
|
||||
background: linear-gradient(135deg, #fff 0%, #fafafa 100%);
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 20rpx;
|
||||
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 {
|
||||
@@ -190,26 +252,31 @@
|
||||
}
|
||||
|
||||
.lunar-badge {
|
||||
padding: 4rpx 12rpx;
|
||||
background-color: #e3f2fd;
|
||||
padding: 6rpx 16rpx;
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
|
||||
color: #1976d2;
|
||||
border-radius: 4rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2rpx 8rpx rgba(25, 118, 210, 0.15);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 4rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.urgent {
|
||||
background-color: #fff3f0;
|
||||
background: linear-gradient(135deg, #ffe0db 0%, #ffccbc 100%);
|
||||
color: #ff5722;
|
||||
box-shadow: 0 2rpx 8rpx rgba(255, 87, 34, 0.15);
|
||||
}
|
||||
|
||||
.status.warning {
|
||||
background-color: #fff8e6;
|
||||
background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%);
|
||||
color: #ff9800;
|
||||
box-shadow: 0 2rpx 8rpx rgba(255, 152, 0, 0.15);
|
||||
}
|
||||
|
||||
|
||||
+69
-34
@@ -1,13 +1,17 @@
|
||||
// index.js
|
||||
const storage = require('../../utils/storage')
|
||||
const dateUtils = require('../../utils/date')
|
||||
const { TYPE_NAMES } = require('../../utils/constants')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
persons: [],
|
||||
originalPersons: [], // 原始数据备份
|
||||
originalPersons: [],
|
||||
searchKeyword: '',
|
||||
currentFilter: 'all'
|
||||
currentFilter: 'all',
|
||||
totalCount: 0,
|
||||
upcomingCount: 0,
|
||||
todayText: ''
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -15,7 +19,6 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 每次显示页面时刷新数据
|
||||
this.loadPersons()
|
||||
},
|
||||
|
||||
@@ -25,32 +28,24 @@ Page({
|
||||
loadPersons() {
|
||||
const persons = storage.getPersons()
|
||||
const anniversaries = storage.getAnniversaries()
|
||||
|
||||
// 为每个人员添加纪念日信息
|
||||
|
||||
const personsWithAnniversaries = persons.map(person => {
|
||||
const personAnniversaries = anniversaries.filter(a => a.personId === person.id)
|
||||
|
||||
// 找到最近的纪念日
|
||||
|
||||
let nextAnniversary = null
|
||||
if (personAnniversaries.length > 0) {
|
||||
const today = new Date()
|
||||
const upcoming = personAnniversaries
|
||||
.map(a => {
|
||||
// 如果是农历,需要特殊处理
|
||||
const date = new Date(a.solarYear, a.solarMonth - 1, a.solarDay)
|
||||
return {
|
||||
...a,
|
||||
date,
|
||||
daysUntil: dateUtils.getDaysUntil(date)
|
||||
}
|
||||
const { date, daysUntil } = dateUtils.getNextOccurrence(a.solarMonth, a.solarDay)
|
||||
return { ...a, date, daysUntil }
|
||||
})
|
||||
.filter(a => a.daysUntil >= 0)
|
||||
.sort((a, b) => a.daysUntil - b.daysUntil)
|
||||
|
||||
|
||||
if (upcoming.length > 0) {
|
||||
const next = upcoming[0]
|
||||
nextAnniversary = {
|
||||
type: next.type,
|
||||
typeName: this.getTypeName(next.type, next.customTypeName),
|
||||
dateText: dateUtils.formatDate(next.date, 'MM月DD日'),
|
||||
daysUntil: next.daysUntil,
|
||||
daysUntilText: this.formatDaysUntil(next.daysUntil)
|
||||
@@ -58,14 +53,9 @@ Page({
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...person,
|
||||
anniversaryCount: personAnniversaries.length,
|
||||
nextAnniversary
|
||||
}
|
||||
return { ...person, anniversaryCount: personAnniversaries.length, nextAnniversary }
|
||||
})
|
||||
|
||||
// 按最近的纪念日排序
|
||||
const sorted = personsWithAnniversaries.sort((a, b) => {
|
||||
if (!a.nextAnniversary && !b.nextAnniversary) return 0
|
||||
if (!a.nextAnniversary) return 1
|
||||
@@ -73,10 +63,18 @@ Page({
|
||||
return a.nextAnniversary.daysUntil - b.nextAnniversary.daysUntil
|
||||
})
|
||||
|
||||
this.setData({
|
||||
persons: sorted,
|
||||
originalPersons: sorted
|
||||
})
|
||||
const today = new Date()
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
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)}个月`
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取类型名称
|
||||
*/
|
||||
getTypeName(type, customName) {
|
||||
if (type === 'other' && customName) return customName
|
||||
return TYPE_NAMES[type] || '纪念日'
|
||||
},
|
||||
|
||||
/**
|
||||
* 搜索输入
|
||||
*/
|
||||
@@ -104,8 +110,9 @@ Page({
|
||||
*/
|
||||
onFilterTap(e) {
|
||||
const filter = e.currentTarget.dataset.filter
|
||||
this.setData({ currentFilter: filter })
|
||||
this.filterPersons()
|
||||
this.setData({ currentFilter: filter }, () => {
|
||||
this.filterPersons()
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -113,6 +120,7 @@ Page({
|
||||
*/
|
||||
filterPersons() {
|
||||
const { originalPersons, searchKeyword, currentFilter } = this.data
|
||||
const anniversaries = storage.getAnniversaries()
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
this.setData({ persons: filtered })
|
||||
// 排序:与 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 })
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+93
-37
@@ -1,56 +1,112 @@
|
||||
<!--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">
|
||||
<input class="search-input" placeholder="搜索姓名" value="{{searchKeyword}}" bindinput="onSearchInput" />
|
||||
<view class="search-wrap">
|
||||
<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 class="filter-bar">
|
||||
<text class="filter-label">筛选:</text>
|
||||
<scroll-view class="filter-scroll" scroll-x>
|
||||
<view class="filter-item {{currentFilter === 'all' ? 'active' : ''}}" bindtap="onFilterTap" data-filter="all">全部</view>
|
||||
<view class="filter-item {{currentFilter === 'birthday' ? 'active' : ''}}" bindtap="onFilterTap" data-filter="birthday">生日</view>
|
||||
<view class="filter-item {{currentFilter === 'anniversary' ? 'active' : ''}}" bindtap="onFilterTap" data-filter="anniversary">纪念日</view>
|
||||
<view class="filter-item {{currentFilter === 'upcoming' ? 'active' : ''}}" bindtap="onFilterTap" data-filter="upcoming">即将到来</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<scroll-view class="filter-scroll" scroll-x enable-flex>
|
||||
<view class="filter-bar">
|
||||
<view class="filter-tab {{currentFilter === 'all' ? 'tab-active' : ''}}" bindtap="onFilterTap" data-filter="all">全部</view>
|
||||
<view class="filter-tab {{currentFilter === 'birthday' ? 'tab-active' : ''}}" bindtap="onFilterTap" data-filter="birthday">🎂 生日</view>
|
||||
<view class="filter-tab {{currentFilter === 'anniversary' ? 'tab-active' : ''}}" bindtap="onFilterTap" data-filter="anniversary">💍 纪念日</view>
|
||||
<view class="filter-tab {{currentFilter === 'upcoming' ? 'tab-active' : ''}}" bindtap="onFilterTap" data-filter="upcoming">⏰ 即将到来</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 人员列表 -->
|
||||
<scroll-view class="person-list" scroll-y>
|
||||
<view wx:if="{{persons.length === 0}}" class="empty-state">
|
||||
<text class="icon">📅</text>
|
||||
<text class="text">还没有添加任何人</text>
|
||||
<text class="hint">点击右下角 + 添加第一个</text>
|
||||
<scroll-view class="list" scroll-y>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view wx:if="{{persons.length === 0}}" class="empty">
|
||||
<text class="empty-icon">🌟</text>
|
||||
<text class="empty-title">还没有添加任何人</text>
|
||||
<text class="empty-hint">点击右下角 + 开始添加</text>
|
||||
</view>
|
||||
|
||||
<view wx:for="{{persons}}" wx:key="id" class="person-card" bindtap="onPersonTap" data-id="{{item.id}}">
|
||||
<view class="person-header">
|
||||
<image class="avatar" src="{{item.avatar || '/images/default-avatar.png'}}" mode="aspectFill" />
|
||||
<view class="person-info">
|
||||
<text class="person-name">{{item.name}}</text>
|
||||
<text wx:if="{{item.nickname}}" class="person-nickname">{{item.nickname}}</text>
|
||||
</view>
|
||||
<view class="person-count">
|
||||
<text wx:if="{{item.anniversaryCount}}" class="count-text">{{item.anniversaryCount}}</text>
|
||||
<text class="count-label">个纪念日</text>
|
||||
<!-- 人员卡片 -->
|
||||
<view
|
||||
wx:for="{{persons}}"
|
||||
wx:key="id"
|
||||
class="card {{item.nextAnniversary && item.nextAnniversary.daysUntil === 0 ? 'card-today' : item.nextAnniversary && item.nextAnniversary.daysUntil <= 7 ? 'card-urgent' : ''}}"
|
||||
bindtap="onPersonTap"
|
||||
data-id="{{item.id}}"
|
||||
>
|
||||
<!-- 左侧头像 -->
|
||||
<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 wx:if="{{item.nextAnniversary}}" class="next-anniversary">
|
||||
<text class="next-label">📌 </text>
|
||||
<text class="next-text">{{item.nextAnniversary.type}}: {{item.nextAnniversary.dateText}}</text>
|
||||
<text wx:if="{{item.nextAnniversary.daysUntil}}" class="days-text {{item.nextAnniversary.daysUntil <= 7 ? 'urgent' : ''}}">
|
||||
{{item.nextAnniversary.daysUntilText}}
|
||||
</text>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<view class="card-body">
|
||||
<view class="card-row-top">
|
||||
<view class="name-group">
|
||||
<text class="card-name">{{item.name}}</text>
|
||||
<text wx:if="{{item.nickname}}" class="card-nickname">{{item.nickname}}</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 class="list-bottom"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 浮动添加按钮 -->
|
||||
<view class="fab" bindtap="onAddTap">
|
||||
<text>+</text>
|
||||
<text class="fab-icon">+</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
+353
-136
@@ -1,193 +1,410 @@
|
||||
/**index.wxss**/
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
/* index.wxss */
|
||||
|
||||
page {
|
||||
background: #f1f4f9;
|
||||
}
|
||||
|
||||
/* 搜索栏 */
|
||||
.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 {
|
||||
.page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f1f4f9;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-right: 16rpx;
|
||||
white-space: nowrap;
|
||||
/* ───── Header ───── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 48rpx 36rpx 40rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
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 {
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 24rpx;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.person-info {
|
||||
flex: 1;
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.person-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
.header-title {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.person-nickname {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
.header-date {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.person-count {
|
||||
text-align: right;
|
||||
.header-icon {
|
||||
font-size: 64rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.count-text {
|
||||
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;
|
||||
.stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 32rpx;
|
||||
backdrop-filter: blur(8rpx);
|
||||
}
|
||||
|
||||
.next-label {
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.next-text {
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.days-text {
|
||||
color: #07c160;
|
||||
font-weight: 500;
|
||||
.stat-num {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.days-text.urgent {
|
||||
color: #ff5722;
|
||||
.stat-num-urgent {
|
||||
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;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
padding: 160rpx 40rpx;
|
||||
text-align: center;
|
||||
.card-nickname {
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
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;
|
||||
display: block;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.empty-state .text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
display: block;
|
||||
.empty-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 24rpx;
|
||||
color: #bbb;
|
||||
display: block;
|
||||
.empty-hint {
|
||||
font-size: 26rpx;
|
||||
color: #94a3b8;
|
||||
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 {
|
||||
position: fixed;
|
||||
bottom: 120rpx;
|
||||
right: 40rpx;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 60rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
|
||||
box-shadow: 0 12rpx 40rpx rgba(99, 102, 241, 0.5);
|
||||
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 {
|
||||
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
|
||||
const storage = require('../../utils/storage')
|
||||
const dateUtils = require('../../utils/date')
|
||||
const { TYPE_NAMES, TYPE_ICONS, IMPORTANCE_TEXTS } = require('../../utils/constants')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -57,6 +58,7 @@ Page({
|
||||
date,
|
||||
dateText: dateUtils.formatDate(date, 'YYYY年MM月DD日'),
|
||||
daysUntil,
|
||||
daysUntilAbs: Math.abs(daysUntil), // 添加绝对值
|
||||
typeIcon: this.getTypeIcon(a.type),
|
||||
typeName: a.customTypeName || this.getTypeName(a.type),
|
||||
importanceText: this.getImportanceText(a.importance)
|
||||
@@ -73,40 +75,21 @@ Page({
|
||||
* 获取类型图标
|
||||
*/
|
||||
getTypeIcon(type) {
|
||||
const icons = {
|
||||
birthday: '🎂',
|
||||
lunar_birthday: '🌙',
|
||||
wedding: '💍',
|
||||
engagement: '💕',
|
||||
other: '📅'
|
||||
}
|
||||
return icons[type] || '📅'
|
||||
return TYPE_ICONS[type] || '📅'
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取类型名称
|
||||
*/
|
||||
getTypeName(type) {
|
||||
const names = {
|
||||
birthday: '公历生日',
|
||||
lunar_birthday: '农历生日',
|
||||
wedding: '结婚纪念日',
|
||||
engagement: '订婚纪念日',
|
||||
other: '其他纪念日'
|
||||
}
|
||||
return names[type] || '其他'
|
||||
return TYPE_NAMES[type] || '其他'
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取重要程度文本
|
||||
*/
|
||||
getImportanceText(importance) {
|
||||
const texts = {
|
||||
high: '非常重要',
|
||||
medium: '重要',
|
||||
low: '一般'
|
||||
}
|
||||
return texts[importance] || '一般'
|
||||
return IMPORTANCE_TEXTS[importance] || '一般'
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -127,17 +110,12 @@ Page({
|
||||
content: `确定要删除"${this.data.person.name}"吗?相关纪念日也将被删除。`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 先删除相关纪念日
|
||||
const anniversaries = storage.getAnniversariesByPersonId(this.data.personId)
|
||||
anniversaries.forEach(a => storage.deleteAnniversary(a.id))
|
||||
|
||||
// 再删除人员
|
||||
const success = storage.deletePerson(this.data.personId)
|
||||
const success = storage.deletePersonWithAnniversaries(this.data.personId)
|
||||
if (success) {
|
||||
wx.showToast({ title: '删除成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
wx.navigateBack()
|
||||
}, 1500)
|
||||
setTimeout(() => wx.navigateBack(), 1500)
|
||||
} else {
|
||||
wx.showToast({ title: '删除失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
|
||||
<view wx:if="{{item.daysUntil !== undefined}}" class="days-info">
|
||||
<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:else-if="{{item.daysUntil > 0}}" class="days-text">{{item.daysUntil}}天后</text>
|
||||
<text wx:else class="days-text past">已过{{Math.abs(item.daysUntil)}}天</text>
|
||||
<text wx:elif="{{item.daysUntil === 1}}" class="days-text warning">明天</text>
|
||||
<text wx:elif="{{item.daysUntil > 0}}" class="days-text">{{item.daysUntil}}天后</text>
|
||||
<text wx:else class="days-text past">已过{{item.daysUntilAbs}}天</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{item.remark}}" class="anniversary-remark">{{item.remark}}</view>
|
||||
|
||||
@@ -69,29 +69,16 @@ Page({
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
const success = storage.importData(data)
|
||||
|
||||
if (success) {
|
||||
wx.showToast({
|
||||
title: '导入成功',
|
||||
icon: 'success'
|
||||
})
|
||||
setTimeout(() => {
|
||||
wx.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}, 1500)
|
||||
const result = storage.importData(data)
|
||||
|
||||
if (result.success) {
|
||||
wx.showToast({ title: '导入成功', icon: 'success' })
|
||||
setTimeout(() => wx.reLaunch({ url: '/pages/index/index' }), 1500)
|
||||
} else {
|
||||
wx.showToast({
|
||||
title: '导入失败,请检查数据格式',
|
||||
icon: 'none'
|
||||
})
|
||||
wx.showToast({ title: result.error || '导入失败,请检查数据格式', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
wx.showToast({
|
||||
title: '数据格式错误',
|
||||
icon: 'none'
|
||||
})
|
||||
wx.showToast({ title: '数据格式错误', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+106
-33
@@ -1,46 +1,119 @@
|
||||
<!--settings.wxml-->
|
||||
<view class="container">
|
||||
<view class="settings-list">
|
||||
<!-- 数据备份 -->
|
||||
<view class="setting-section">
|
||||
<text class="section-title">数据管理</text>
|
||||
|
||||
<view class="setting-item" bindtap="onExportData">
|
||||
<text class="item-label">📤 导出数据</text>
|
||||
<text class="item-arrow">›</text>
|
||||
<view class="page">
|
||||
|
||||
<!-- Header -->
|
||||
<view class="header">
|
||||
<view class="header-top">
|
||||
<view class="header-left">
|
||||
<text class="header-title">设置</text>
|
||||
<text class="header-sub">数据管理与关于</text>
|
||||
</view>
|
||||
|
||||
<view class="setting-item" bindtap="onImportData">
|
||||
<text class="item-label">📥 导入数据</text>
|
||||
<text class="item-arrow">›</text>
|
||||
<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="setting-item" bindtap="onClearData">
|
||||
<text class="item-label danger">🗑️ 清空所有数据</text>
|
||||
<text class="item-arrow">›</text>
|
||||
<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 class="row-divider"></view>
|
||||
|
||||
<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 class="row-divider"></view>
|
||||
|
||||
<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 class="setting-section">
|
||||
<text class="section-title">关于</text>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="item-label">版本号</text>
|
||||
<text class="item-value">v1.0.0</text>
|
||||
<view class="section-label">关于</view>
|
||||
<view class="card-group">
|
||||
<view class="row">
|
||||
<view class="row-icon-wrap" style="background:#f0fdf4;">
|
||||
<text class="row-icon">📱</text>
|
||||
</view>
|
||||
<view class="row-body">
|
||||
<text class="row-title">版本号</text>
|
||||
</view>
|
||||
<text class="row-value">v1.0.0</text>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="item-label">开发作者</text>
|
||||
<text class="item-value">生日提醒团队</text>
|
||||
|
||||
<view class="row-divider"></view>
|
||||
|
||||
<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 class="info-box">
|
||||
<text class="info-title">💡 温馨提示</text>
|
||||
<text class="info-text">1. 建议定期导出数据备份\n2. 农历转换目前使用简化算法\n3. 提醒功能需要小程序权限</text>
|
||||
<!-- 提示 -->
|
||||
<view class="tips-card">
|
||||
<view class="tips-title">
|
||||
<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 class="bottom-pad"></view>
|
||||
</scroll-view>
|
||||
|
||||
</view>
|
||||
|
||||
|
||||
+207
-49
@@ -1,82 +1,240 @@
|
||||
/**settings.wxss**/
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
padding-bottom: 120rpx;
|
||||
/* settings.wxss */
|
||||
|
||||
page {
|
||||
background: #f1f4f9;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
padding: 32rpx;
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f1f4f9;
|
||||
}
|
||||
|
||||
.setting-section {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
/* ───── Header ───── */
|
||||
.header {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 24rpx 32rpx;
|
||||
background-color: #f9f9f9;
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
/* ───── Row ───── */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 28rpx 28rpx;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
.row:active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
.row-icon-wrap {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.item-label.danger {
|
||||
color: #ff5722;
|
||||
.row-icon {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
.row-body {
|
||||
flex: 1;
|
||||
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;
|
||||
color: #ccc;
|
||||
color: #cbd5e1;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
.row-arrow.danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: #666;
|
||||
line-height: 2;
|
||||
white-space: pre-line;
|
||||
display: block;
|
||||
color: #475569;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ───── Bottom padding ───── */
|
||||
.bottom-pad {
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
+5
-3
@@ -34,10 +34,12 @@
|
||||
"disableSWC": true
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.0.0",
|
||||
"appid": "touristappid",
|
||||
"libVersion": "3.10.3",
|
||||
"appid": "wxe72882e2072d141a",
|
||||
"projectname": "birthday-reminder",
|
||||
"cloudfunctionRoot": "cloudfunctions/",
|
||||
"condition": {},
|
||||
"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]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指定月日的下一次发生日期(今年或明年)
|
||||
* @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 = {
|
||||
formatDate,
|
||||
getDaysUntil,
|
||||
getNextOccurrence,
|
||||
isToday,
|
||||
isPast,
|
||||
isUpcoming,
|
||||
|
||||
+161
-81
@@ -1,122 +1,203 @@
|
||||
/**
|
||||
* 农历日期转换工具
|
||||
* 使用简化版的农历计算方法
|
||||
* 基于寿星万年历算法(1900-2100年)
|
||||
*/
|
||||
|
||||
// 1900-2100年的农历数据
|
||||
// 农历数据:每条数据记录该年的月份大小和闰月信息
|
||||
// 格式:高4位=闰月大小(0无闰/1闰大月), 中4位=闰月位置, 低12位=12个月大小(1大/0小)
|
||||
const lunarInfo = [
|
||||
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
|
||||
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
|
||||
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
|
||||
0x06566, 0x0d4a0, 0x0ea50, 0x16a95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950
|
||||
0x04bd8,0x04ae0,0x0a570,0x054d5,0x0d260,0x0d950,0x16554,0x056a0,0x09ad0,0x055d2, // 1900-1909
|
||||
0x04ae0,0x0a5b6,0x0a4d0,0x0d250,0x1d255,0x0b540,0x0d6a0,0x0ada2,0x095b0,0x14977, // 1910-1919
|
||||
0x04970,0x0a4b0,0x0b4b5,0x06a50,0x06d40,0x1ab54,0x02b60,0x09570,0x052f2,0x04970, // 1920-1929
|
||||
0x06566,0x0d4a0,0x0ea50,0x06e95,0x05ad0,0x02b60,0x186e3,0x092e0,0x1c8d7,0x0c950, // 1930-1939
|
||||
0x0d4a0,0x1d8a6,0x0b550,0x056a0,0x1a5b4,0x025d0,0x092d0,0x0d2b2,0x0a950,0x0b557, // 1940-1949
|
||||
0x06ca0,0x0b550,0x15355,0x04da0,0x0a5b0,0x14573,0x052b0,0x0a9a8,0x0e950,0x06aa0, // 1950-1959
|
||||
0x0aea6,0x0ab50,0x04b60,0x0aae4,0x0a570,0x05260,0x0f263,0x0d950,0x05b57,0x056a0, // 1960-1969
|
||||
0x096d0,0x04dd5,0x04ad0,0x0a4d0,0x0d4d4,0x0d250,0x0d558,0x0b540,0x0b6a0,0x195a6, // 1970-1979
|
||||
0x095b0,0x049b0,0x0a974,0x0a4b0,0x0b27a,0x06a50,0x06d40,0x0af46,0x0ab60,0x09570, // 1980-1989
|
||||
0x04af5,0x04970,0x064b0,0x074a3,0x0ea50,0x06aa0,0x0a6b6,0x056a0,0x02b60,0x09570, // 1990-1999
|
||||
0x049b0,0x0a4b0,0x0aa50,0x1b255,0x06d40,0x0ad50,0x14b55,0x056a0,0x0a6d0,0x055d4, // 2000-2009
|
||||
0x052d0,0x0a9b8,0x0a950,0x0b4a0,0x0b6a6,0x0ad50,0x055a0,0x0aba4,0x0a5b0,0x052b0, // 2010-2019
|
||||
0x0b273,0x06930,0x07337,0x06aa0,0x0ad50,0x14b55,0x04b60,0x0a570,0x054e4,0x0d160, // 2020-2029
|
||||
0x0e968,0x0d520,0x0daa0,0x16aa6,0x056d0,0x04ae0,0x0a9d4,0x0a2d0,0x0d150,0x0f252, // 2030-2039
|
||||
0x0d520,0x0daa0,0x16aa6,0x056d0,0x04ae0,0x0a9d4,0x0a2d0,0x0d150,0x0f252,0x0d520, // 2040-2049
|
||||
0x0b54f,0x0d6a0,0x0ada0,0x14955,0x056a0,0x0a6d0,0x0155b,0x025d0,0x092d0,0x0d954, // 2050-2059
|
||||
0x0d4a0,0x0b550,0x0b4a9,0x04da0,0x0a5b0,0x15176,0x052b0,0x0a930,0x07954,0x06aa0, // 2060-2069
|
||||
0x0ad50,0x05b52,0x04b60,0x0a6e6,0x0a4e0,0x0d260,0x0ea65,0x0d530,0x05aa0,0x076a3, // 2070-2079
|
||||
0x096d0,0x04afb,0x04ad0,0x0a4d0,0x1d0b6,0x0d250,0x0d520,0x0dd45,0x0b5a0,0x056d0, // 2080-2089
|
||||
0x055b2,0x049b0,0x0a577,0x0a4b0,0x0aa50,0x1b255,0x06d20,0x0ada0,0x14b63,0x09370 // 2090-2099
|
||||
]
|
||||
|
||||
/**
|
||||
* 农历年份
|
||||
*/
|
||||
const lunarMonths = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊']
|
||||
const lunarDays = ['初', '十', '廿', '三']
|
||||
const lunarDayNames = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
// 1900年1月31日是农历正月初一(庚子年)
|
||||
const BASE_DATE = new Date(1900, 0, 31)
|
||||
|
||||
const LUNAR_MONTHS = ['正','二','三','四','五','六','七','八','九','十','冬','腊']
|
||||
const LUNAR_DAYS_TENS = ['初','十','廿','三']
|
||||
const LUNAR_DAYS_UNITS = ['一','二','三','四','五','六','七','八','九','十']
|
||||
|
||||
/**
|
||||
* 获取农历月份名称
|
||||
* 获取农历某年的总天数
|
||||
*/
|
||||
function getLunarMonthName(month) {
|
||||
return lunarMonths[month - 1]
|
||||
function _lunarYearDays(year) {
|
||||
let total = 348
|
||||
for (let i = 0x8000; i > 0x8; i >>= 1) {
|
||||
total += (lunarInfo[year - 1900] & i) ? 1 : 0
|
||||
}
|
||||
return total + _leapDays(year)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取农历日期名称
|
||||
* 获取农历某年闰月的天数
|
||||
*/
|
||||
function getLunarDayName(day) {
|
||||
if (day === 10) return '初十'
|
||||
if (day === 20) return '二十'
|
||||
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]
|
||||
function _leapDays(year) {
|
||||
if (_leapMonth(year)) {
|
||||
return (lunarInfo[year - 1900] & 0x10000) ? 30 : 29
|
||||
}
|
||||
|
||||
return name
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算农历指定年的总天数
|
||||
* 获取农历某年的闰月月份,0表示无闰月
|
||||
*/
|
||||
function getLunarYearDays(year) {
|
||||
let total = 0
|
||||
for (let i = 0; i < 13; i++) {
|
||||
const days = lunarInfo[year] & (0x8000 >> i) ? 30 : 29
|
||||
total += days
|
||||
}
|
||||
return total
|
||||
function _leapMonth(year) {
|
||||
return lunarInfo[year - 1900] & 0xf
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取农历某年某月的天数
|
||||
*/
|
||||
function _monthDays(year, month) {
|
||||
return (lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29
|
||||
}
|
||||
|
||||
/**
|
||||
* 公历转农历
|
||||
* @param {Date} solarDate - 公历日期对象
|
||||
* @returns {Object} 农历日期对象 {year, month, day, lunarText}
|
||||
* @param {Date} solarDate
|
||||
* @returns {{ year, month, day, isLeap, lunarText }}
|
||||
*/
|
||||
function solarToLunar(solarDate) {
|
||||
const year = solarDate.getFullYear()
|
||||
const month = solarDate.getMonth() + 1
|
||||
const day = solarDate.getDate()
|
||||
|
||||
// 这是一个简化版本,实际计算需要完整的农历转换算法
|
||||
// 这里返回一个示例结果
|
||||
// 实际项目中建议使用成熟的农历库如 lunar-javascript
|
||||
|
||||
const offset = Math.floor((year - 1900) / 4) + (year - 1900) % 4
|
||||
|
||||
// 返回格式化的农历
|
||||
const date = new Date(solarDate.getFullYear(), solarDate.getMonth(), solarDate.getDate())
|
||||
let offset = Math.round((date - BASE_DATE) / 86400000)
|
||||
|
||||
let lunarYear, lunarMonth, lunarDay
|
||||
let isLeap = false
|
||||
|
||||
for (lunarYear = 1900; lunarYear < 2100 && offset > 0; lunarYear++) {
|
||||
const days = _lunarYearDays(lunarYear)
|
||||
offset -= days
|
||||
}
|
||||
if (offset < 0) {
|
||||
offset += _lunarYearDays(--lunarYear)
|
||||
}
|
||||
|
||||
const leapM = _leapMonth(lunarYear)
|
||||
let isLeapYear = false
|
||||
|
||||
for (lunarMonth = 1; lunarMonth < 13 && offset > 0; lunarMonth++) {
|
||||
if (leapM > 0 && lunarMonth === leapM + 1 && !isLeapYear) {
|
||||
--lunarMonth
|
||||
isLeapYear = true
|
||||
const leapDays = _leapDays(lunarYear)
|
||||
offset -= leapDays
|
||||
} else {
|
||||
offset -= _monthDays(lunarYear, lunarMonth)
|
||||
}
|
||||
if (isLeapYear && lunarMonth === leapM + 1) isLeapYear = false
|
||||
}
|
||||
|
||||
if (offset === 0 && leapM > 0 && lunarMonth === leapM + 1) {
|
||||
if (isLeapYear) {
|
||||
isLeapYear = false
|
||||
} else {
|
||||
isLeapYear = true
|
||||
--lunarMonth
|
||||
}
|
||||
}
|
||||
if (offset < 0) {
|
||||
offset += isLeapYear ? _leapDays(lunarYear) : _monthDays(lunarYear, lunarMonth)
|
||||
if (isLeapYear) isLeapYear = false
|
||||
else --lunarMonth
|
||||
}
|
||||
|
||||
lunarDay = offset + 1
|
||||
isLeap = isLeapYear
|
||||
|
||||
return {
|
||||
year: year,
|
||||
month: month,
|
||||
day: day,
|
||||
lunarText: `${getLunarMonthName(month)}月${getLunarDayName(day)}`
|
||||
year: lunarYear,
|
||||
month: lunarMonth,
|
||||
day: lunarDay,
|
||||
isLeap,
|
||||
lunarText: `${isLeap ? '闰' : ''}${getLunarMonthName(lunarMonth)}月${getLunarDayName(lunarDay)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 农历转公历
|
||||
* @param {Number} year - 农历年份
|
||||
* @param {Number} month - 农历月份
|
||||
* @param {Number} day - 农历日期
|
||||
* @returns {Date} 公历日期对象
|
||||
* 农历转公历(指定年份)
|
||||
* @param {Number} lunarYear
|
||||
* @param {Number} lunarMonth
|
||||
* @param {Number} lunarDay
|
||||
* @param {Boolean} isLeap 是否闰月
|
||||
* @returns {Date}
|
||||
*/
|
||||
function lunarToSolar(year, month, day) {
|
||||
// 简化版本,实际需要完整的农历转换算法
|
||||
// 这里返回当前日期作为示例
|
||||
// 实际项目中建议使用成熟的农历库
|
||||
|
||||
const now = new Date()
|
||||
return new Date(now.getFullYear(), month - 1, day)
|
||||
function lunarToSolar(lunarYear, lunarMonth, lunarDay, isLeap) {
|
||||
let offset = 0
|
||||
|
||||
for (let y = 1900; y < lunarYear; y++) {
|
||||
offset += _lunarYearDays(y)
|
||||
}
|
||||
|
||||
const leapM = _leapMonth(lunarYear)
|
||||
let hasLeap = false
|
||||
|
||||
for (let m = 1; m < lunarMonth; m++) {
|
||||
if (leapM > 0 && m === leapM && !hasLeap) {
|
||||
offset += _leapDays(lunarYear)
|
||||
hasLeap = true
|
||||
}
|
||||
offset += _monthDays(lunarYear, m)
|
||||
}
|
||||
|
||||
if (isLeap && lunarMonth === leapM) {
|
||||
offset += _monthDays(lunarYear, lunarMonth)
|
||||
}
|
||||
|
||||
offset += lunarDay - 1
|
||||
|
||||
const result = new Date(BASE_DATE.getTime() + offset * 86400000)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取农历年份的下一个相同农历日期的公历日期
|
||||
* @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 nextYear = today.getFullYear() + 1
|
||||
return new Date(nextYear, today.getMonth(), today.getDate())
|
||||
const currentYear = today.getFullYear()
|
||||
const lunarToday = solarToLunar(today)
|
||||
|
||||
// 尝试今年
|
||||
let candidate = lunarToSolar(lunarToday.year, lunarMonth, lunarDay, false)
|
||||
if (candidate >= today) return candidate
|
||||
|
||||
// 明年
|
||||
return lunarToSolar(lunarToday.year + 1, lunarMonth, lunarDay, false)
|
||||
}
|
||||
|
||||
function getLunarMonthName(month) {
|
||||
return LUNAR_MONTHS[month - 1] || ''
|
||||
}
|
||||
|
||||
function getLunarDayName(day) {
|
||||
if (day === 10) return '初十'
|
||||
if (day === 20) return '二十'
|
||||
if (day === 30) return '三十'
|
||||
const tens = Math.floor(day / 10)
|
||||
const units = day % 10
|
||||
return LUNAR_DAYS_TENS[tens] + (units > 0 ? LUNAR_DAYS_UNITS[units - 1] : '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化农历显示文本
|
||||
*/
|
||||
function formatLunarText(lunarDate) {
|
||||
const lunar = solarToLunar(new Date(lunarDate.year, lunarDate.month - 1, lunarDate.day))
|
||||
return lunar.lunarText
|
||||
return solarToLunar(new Date(lunarDate.year, lunarDate.month - 1, lunarDate.day)).lunarText
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -127,4 +208,3 @@ module.exports = {
|
||||
getLunarMonthName,
|
||||
getLunarDayName
|
||||
}
|
||||
|
||||
|
||||
+92
-39
@@ -1,17 +1,29 @@
|
||||
/**
|
||||
* 本地存储管理工具
|
||||
* 本地存储管理工具(含缓存层)
|
||||
*/
|
||||
|
||||
// 内存缓存
|
||||
const _cache = {
|
||||
persons: null,
|
||||
anniversaries: null
|
||||
}
|
||||
|
||||
function _invalidate() {
|
||||
_cache.persons = null
|
||||
_cache.anniversaries = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储人员数据
|
||||
*/
|
||||
function savePersons(persons) {
|
||||
try {
|
||||
wx.setStorageSync('persons', persons)
|
||||
return true
|
||||
_cache.persons = persons
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('保存人员数据失败', e)
|
||||
return false
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +31,10 @@ function savePersons(persons) {
|
||||
* 获取人员数据
|
||||
*/
|
||||
function getPersons() {
|
||||
if (_cache.persons !== null) return _cache.persons
|
||||
try {
|
||||
return wx.getStorageSync('persons') || []
|
||||
_cache.persons = wx.getStorageSync('persons') || []
|
||||
return _cache.persons
|
||||
} catch (e) {
|
||||
console.error('获取人员数据失败', e)
|
||||
return []
|
||||
@@ -31,8 +45,7 @@ function getPersons() {
|
||||
* 根据ID获取人员
|
||||
*/
|
||||
function getPersonById(id) {
|
||||
const persons = getPersons()
|
||||
return persons.find(p => p.id === id) || null
|
||||
return getPersons().find(p => p.id === id) || null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +60,8 @@ function addPerson(person) {
|
||||
updateTime: new Date().getTime()
|
||||
}
|
||||
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 index = persons.findIndex(p => p.id === id)
|
||||
if (index !== -1) {
|
||||
persons[index] = {
|
||||
...persons[index],
|
||||
...updates,
|
||||
updateTime: new Date().getTime()
|
||||
}
|
||||
return savePersons(persons)
|
||||
persons[index] = { ...persons[index], ...updates, updateTime: new Date().getTime() }
|
||||
return savePersons(persons).success
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -72,8 +82,7 @@ function updatePerson(id, updates) {
|
||||
*/
|
||||
function deletePerson(id) {
|
||||
const persons = getPersons()
|
||||
const filtered = persons.filter(p => p.id !== id)
|
||||
return savePersons(filtered)
|
||||
return savePersons(persons.filter(p => p.id !== id)).success
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,10 +91,11 @@ function deletePerson(id) {
|
||||
function saveAnniversaries(anniversaries) {
|
||||
try {
|
||||
wx.setStorageSync('anniversaries', anniversaries)
|
||||
return true
|
||||
_cache.anniversaries = anniversaries
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('保存纪念日数据失败', e)
|
||||
return false
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +103,10 @@ function saveAnniversaries(anniversaries) {
|
||||
* 获取纪念日数据
|
||||
*/
|
||||
function getAnniversaries() {
|
||||
if (_cache.anniversaries !== null) return _cache.anniversaries
|
||||
try {
|
||||
return wx.getStorageSync('anniversaries') || []
|
||||
_cache.anniversaries = wx.getStorageSync('anniversaries') || []
|
||||
return _cache.anniversaries
|
||||
} catch (e) {
|
||||
console.error('获取纪念日数据失败', e)
|
||||
return []
|
||||
@@ -105,8 +117,7 @@ function getAnniversaries() {
|
||||
* 根据人员ID获取纪念日
|
||||
*/
|
||||
function getAnniversariesByPersonId(personId) {
|
||||
const anniversaries = getAnniversaries()
|
||||
return anniversaries.filter(a => a.personId === personId)
|
||||
return getAnniversaries().filter(a => a.personId === personId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +132,8 @@ function addAnniversary(anniversary) {
|
||||
updateTime: new Date().getTime()
|
||||
}
|
||||
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 index = anniversaries.findIndex(a => a.id === id)
|
||||
if (index !== -1) {
|
||||
anniversaries[index] = {
|
||||
...anniversaries[index],
|
||||
...updates,
|
||||
updateTime: new Date().getTime()
|
||||
}
|
||||
return saveAnniversaries(anniversaries)
|
||||
anniversaries[index] = { ...anniversaries[index], ...updates, updateTime: new Date().getTime() }
|
||||
return saveAnniversaries(anniversaries).success
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -146,8 +154,26 @@ function updateAnniversary(id, updates) {
|
||||
*/
|
||||
function deleteAnniversary(id) {
|
||||
const anniversaries = getAnniversaries()
|
||||
const filtered = anniversaries.filter(a => a.id !== id)
|
||||
return saveAnniversaries(filtered)
|
||||
return saveAnniversaries(anniversaries.filter(a => a.id !== id)).success
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子性删除人员及其所有纪念日
|
||||
*/
|
||||
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) {
|
||||
try {
|
||||
if (data.persons && Array.isArray(data.persons)) {
|
||||
wx.setStorageSync('persons', data.persons)
|
||||
if (!data || typeof data !== 'object') {
|
||||
return { success: false, error: '无效的数据格式' }
|
||||
}
|
||||
if (data.anniversaries && Array.isArray(data.anniversaries)) {
|
||||
wx.setStorageSync('anniversaries', data.anniversaries)
|
||||
if (!Array.isArray(data.persons) || !Array.isArray(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) {
|
||||
console.error('导入数据失败', e)
|
||||
return false
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +244,7 @@ function importData(data) {
|
||||
function clearAllData() {
|
||||
try {
|
||||
wx.clearStorageSync()
|
||||
_invalidate()
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('清空数据失败', e)
|
||||
@@ -207,7 +260,8 @@ module.exports = {
|
||||
addPerson,
|
||||
updatePerson,
|
||||
deletePerson,
|
||||
|
||||
deletePersonWithAnniversaries,
|
||||
|
||||
// 纪念日相关
|
||||
saveAnniversaries,
|
||||
getAnniversaries,
|
||||
@@ -215,10 +269,9 @@ module.exports = {
|
||||
addAnniversary,
|
||||
updateAnniversary,
|
||||
deleteAnniversary,
|
||||
|
||||
|
||||
// 工具函数
|
||||
exportData,
|
||||
importData,
|
||||
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. 选择本项目目录
|
||||
4. **AppID填写**:`touristappid`(使用测试号)
|
||||
|
||||
### 2. 添加图标(可选)
|
||||
### 2. 图标说明
|
||||
|
||||
tabBar需要图标,可以在 `images/` 目录添加:
|
||||
- `home.png` / `home-active.png`
|
||||
- `calendar.png` / `calendar-active.png`
|
||||
- `settings.png` / `settings-active.png`
|
||||
- `default-avatar.png`
|
||||
✅ tabBar 已配置为 emoji 图标模式,无需添加图片文件:
|
||||
- 🏠 首页
|
||||
- 📅 日历
|
||||
- ⚙️ 设置
|
||||
|
||||
如果不添加图标,tabBar可能显示异常,但功能不受影响。
|
||||
如需添加自定义图标,可以在 `images/` 目录添加图片并修改 `app.json` 配置。
|
||||
|
||||
### 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