接入自建后端 + Gitea CI/CD
部署到群晖 / deploy (push) Failing after 6m22s

- 新增 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:
yuming
2026-06-01 15:44:09 +08:00
parent 6747ade9c4
commit 3965e542fc
49 changed files with 5616 additions and 670 deletions
+54
View File
@@ -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"
+5
View File
@@ -12,3 +12,8 @@ $RECYCLE.BIN/
# Node.js
node_modules/
# 后端运行时数据 & 敏感配置
server/.env
server/data/
server/node_modules/
+23 -6
View File
@@ -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: ''
}
})
+4 -10
View File
@@ -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": "⚙️ 设置"
}
]
},
+38 -19
View File
@@ -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);
}
+6
View File
@@ -0,0 +1,6 @@
{
"permissions": {
"openapi": []
}
}
+18
View File
@@ -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
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "login",
"version": "1.0.0",
"description": "获取用户openid",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"openapi": [
"subscribeMessage.send"
]
},
"triggers": [
{
"name": "dailyReminder",
"type": "timer",
"config": "0 0 9 * * * *"
}
]
}
+197
View File
@@ -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
}
}
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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": []
}
}
+157
View File
@@ -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"
}
}
+91 -6
View File
@@ -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)
// 不影响本地保存
}
}
})
+32 -6
View File
@@ -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,
+68 -22
View File
@@ -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;
}
+30 -68
View File
@@ -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,6 +21,21 @@ 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
},
/**
* 渲染日历
*/
@@ -27,93 +43,53 @@ Page({
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()
},
/**
* 获取事件颜色
*/
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] || '其他'
},
/**
+3 -1
View File
@@ -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
View File
@@ -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);
}
+65 -30
View File
@@ -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()
},
@@ -26,31 +29,23 @@ Page({
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.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)
this.setData({ persons: filtered })
if (currentFilter === 'birthday') {
// 生日筛选:只显示有生日类型的人(公历生日或农历生日)
return personAnniversaries.some(a =>
a.type === 'birthday' || a.type === 'lunar_birthday'
)
} else if (currentFilter === 'anniversary') {
// 纪念日筛选:只显示有纪念日类型的人(结婚、订婚、其他)
return personAnniversaries.some(a =>
a.type === 'wedding' || a.type === 'engagement' || a.type === 'other'
)
} else if (currentFilter === 'upcoming') {
// 即将到来:只显示7天内有纪念日的人
return person.nextAnniversary && person.nextAnniversary.daysUntil <= 7
}
return true
})
}
// 排序:与 loadPersons() 保持一致,按最近的纪念日排序
const sorted = filtered.sort((a, b) => {
if (!a.nextAnniversary && !b.nextAnniversary) return 0
if (!a.nextAnniversary) return 1
if (!b.nextAnniversary) return -1
return a.nextAnniversary.daysUntil - b.nextAnniversary.daysUntil
})
this.setData({ persons: sorted })
},
/**
+89 -33
View File
@@ -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>
<!-- 筛选栏 -->
<scroll-view class="filter-scroll" scroll-x enable-flex>
<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 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}}
<!-- 右侧内容 -->
<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
View File
@@ -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;
}
+9 -31
View File
@@ -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' })
}
}
}
+3 -3
View File
@@ -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>
+6 -19
View File
@@ -69,29 +69,16 @@ Page({
success: (res) => {
try {
const data = JSON.parse(res.data)
const success = storage.importData(data)
const result = storage.importData(data)
if (success) {
wx.showToast({
title: '导入成功',
icon: 'success'
})
setTimeout(() => {
wx.reLaunch({
url: '/pages/index/index'
})
}, 1500)
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' })
}
}
})
+101 -28
View File
@@ -1,46 +1,119 @@
<!--settings.wxml-->
<view class="container">
<view class="settings-list">
<!-- 数据备份 -->
<view class="setting-section">
<text class="section-title">数据管理</text>
<view class="page">
<view class="setting-item" bindtap="onExportData">
<text class="item-label">📤 导出数据</text>
<text class="item-arrow"></text>
<!-- 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="header-icon">⚙️</view>
</view>
<!-- 数据统计 -->
<view class="stats-row">
<view class="stat-item">
<text class="stat-num">{{dataCount.persons}}</text>
<text class="stat-label">位好友</text>
</view>
<view class="stat-sep"></view>
<view class="stat-item">
<text class="stat-num">{{dataCount.anniversaries}}</text>
<text class="stat-label">个纪念日</text>
</view>
</view>
</view>
<view class="setting-item" bindtap="onImportData">
<text class="item-label">📥 导入数据</text>
<text class="item-arrow"></text>
<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="setting-item" bindtap="onClearData">
<text class="item-label danger">🗑️ 清空所有数据</text>
<text class="item-arrow"></text>
<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">农历转换使用寿星万年历算法(19002100</text>
</view>
<view class="tips-item">
<text class="tips-dot">·</text>
<text class="tips-text">开启提醒需授权订阅消息</text>
</view>
</view>
<view class="bottom-pad"></view>
</scroll-view>
</view>
+207 -49
View File
@@ -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
View File
@@ -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/"
}
+18
View File
@@ -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 * * *
+5
View File
@@ -0,0 +1,5 @@
node_modules/
data/
.env
*.log
.DS_Store
+19
View File
@@ -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"]
+18
View File
@@ -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
+20
View File
@@ -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"
}
}
+51
View File
@@ -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
+145
View File
@@ -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()
})
+139
View File
@@ -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 }
+57
View File
@@ -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 }
+70
View File
@@ -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 }
+47
View File
@@ -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
}
+19
View File
@@ -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,
+157 -77
View File
@@ -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()
const date = new Date(solarDate.getFullYear(), solarDate.getMonth(), solarDate.getDate())
let offset = Math.round((date - BASE_DATE) / 86400000)
// 这是一个简化版本,实际计算需要完整的农历转换算法
// 这里返回一个示例结果
// 实际项目中建议使用成熟的农历库如 lunar-javascript
let lunarYear, lunarMonth, lunarDay
let isLeap = false
const offset = Math.floor((year - 1900) / 4) + (year - 1900) % 4
for (lunarYear = 1900; lunarYear < 2100 && offset > 0; lunarYear++) {
const days = _lunarYearDays(lunarYear)
offset -= days
}
if (offset < 0) {
offset += _lunarYearDays(--lunarYear)
}
const leapM = _leapMonth(lunarYear)
let isLeapYear = false
for (lunarMonth = 1; lunarMonth < 13 && offset > 0; lunarMonth++) {
if (leapM > 0 && lunarMonth === leapM + 1 && !isLeapYear) {
--lunarMonth
isLeapYear = true
const leapDays = _leapDays(lunarYear)
offset -= leapDays
} else {
offset -= _monthDays(lunarYear, lunarMonth)
}
if (isLeapYear && lunarMonth === leapM + 1) isLeapYear = false
}
if (offset === 0 && leapM > 0 && lunarMonth === leapM + 1) {
if (isLeapYear) {
isLeapYear = false
} else {
isLeapYear = true
--lunarMonth
}
}
if (offset < 0) {
offset += isLeapYear ? _leapDays(lunarYear) : _monthDays(lunarYear, lunarMonth)
if (isLeapYear) isLeapYear = false
else --lunarMonth
}
lunarDay = offset + 1
isLeap = isLeapYear
// 返回格式化的农历
return {
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) {
// 简化版本,实际需要完整的农历转换算法
// 这里返回当前日期作为示例
// 实际项目中建议使用成熟的农历库
function lunarToSolar(lunarYear, lunarMonth, lunarDay, isLeap) {
let offset = 0
const now = new Date()
return new Date(now.getFullYear(), month - 1, day)
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
}
+90 -37
View File
@@ -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)) {
if (!data || typeof data !== 'object') {
return { success: false, error: '无效的数据格式' }
}
if (!Array.isArray(data.persons) || !Array.isArray(data.anniversaries)) {
return { success: false, error: 'persons 和 anniversaries 必须是数组' }
}
if (!data.persons.every(_validatePerson)) {
return { success: false, error: '人员数据格式不正确' }
}
if (!data.anniversaries.every(_validateAnniversary)) {
return { success: false, error: '纪念日数据格式不正确' }
}
wx.setStorageSync('persons', data.persons)
}
if (data.anniversaries && Array.isArray(data.anniversaries)) {
wx.setStorageSync('anniversaries', data.anniversaries)
}
return true
_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,6 +260,7 @@ module.exports = {
addPerson,
updatePerson,
deletePerson,
deletePersonWithAnniversaries,
// 纪念日相关
saveAnniversaries,
@@ -221,4 +275,3 @@ module.exports = {
importData,
clearAllData
}
+398
View File
@@ -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
View File
@@ -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
View File
@@ -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点自动检查并发送提醒
✅ 提醒记录自动保存到数据库
**享受你的智能生日提醒吧!** 🎂
+6 -7
View File
@@ -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. 运行项目
+308
View File
@@ -0,0 +1,308 @@
# 生日提醒小程序 - 云开发部署检查清单
## ✅ 部署前检查
### 1. 环境准备
- [ ] 已注册微信小程序账号
- [ ] 已获取 AppID
- [ ] 已开通云开发(环境IDcloudbase-1gk3x0ia3a6b1f80
- [ ] 已创建订阅消息模板(模板ID6J7Stt-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. 联系技术支持