目录
1. [项目概述]
2. [开发环境准备]
3. [项目结构搭建]
4. [后端服务开发]
5. [服务器部署]
6. [小程序开发]
7. [域名配置]
8. [测试与调试]
9. [发布上线]
10. [后续优化]
---
1. 项目概述
1-1.项目名称
一土致知学校家长接送签到系统
1-2. 功能描述
家长注册学生信息
学生ID验证(与飞书文档集成)
学生签到管理
考勤记录查看
1-3. 技术栈
前端:微信小程序
后端:Node.js + Express
数据库:模拟数据(可扩展为真实数据库)
服务器:阿里云ECS
域名:zrjm.cc
---
2. 开发环境准备
2-1. 微信开发者工具
下载地址:https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html
安装并登录微信开发者账号
创建小程序项目
2-2. Node.js环境
下载地址:https://nodejs.org/
安装Node.js(推荐LTS版本)
验证安装:`node --version`
2-3. 代码编辑器
推荐使用VSCode或WebStorm
---
3.项目结构搭建
3-1. 创建项目目录
F:\Etu\
├── app.js # 小程序主文件
├── app.json # 小程序配置
├── app.wxss # 全局样式
├── images/ # 图片资源
│ ├── school_logo.png
│ └── school_logo2.png
├── pages/ # 页面文件
│ ├── login/ # 登录页面
│ │ ├── login.js
│ │ ├── login.wxml
│ │ └── login.wxss
│ ├── register/ # 注册页面
│ │ ├── register.js
│ │ ├── register.wxml
│ │ └── register.wxss
│ └── checkin/ # 签到页面
│ ├── checkin.js
│ ├── checkin.wxml
│ └── checkin.wxss
├── utils/ # 工具文件
│ └── api.js # API接口封装
├── server/ # 后端服务
│ ├── app.js
│ ├── package.json
│ └── services/
└── deploy/ # 部署配置
├── docker/
└── nginx/
3-2. 初始化小程序项目
在微信开发者工具中:
1. 选择"小程序"
2. 填写项目信息:
- 项目名称:家长接送签到系统
- 目录:选择F:\Etu
- AppID:填写您的小程序AppID
3. 点击"新建"
---
4. 后端服务开发
4-1. 创建后端项目
4-1-1 初始化package.json
cd server
npm init -y
4-1-2 安装依赖
npm install express cors axios dotenv body-parser
4-1-3 创建主文件 app.js
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// 模拟学生数据
const mockStudents = [
{
studentId: '10001',
studentName: '陈奕宏',
parentName: '陈柯辰',
relation: '父亲',
grade: 'G5',
className: 'G5-1凤凰班',
homeroomTeacher: '汤萍'
},
{
studentId: '10002',
studentName: '陈奕欣',
parentName: '陈柯辰',
relation: '父亲',
grade: 'G3',
className: 'G3-1天马班',
homeroomTeacher: '韦丽平'
}
];
// API路由
app.get('/api/students', (req, res) => {
res.json({
success: true,
data: mockStudents
});
});
app.get('/api/students/:studentId', (req, res) => {
const { studentId } = req.params;
const student = mockStudents.find(s => s.studentId === studentId);
if (student) {
res.json({
success: true,
data: student
});
} else {
res.status(404).json({
success: false,
message: '未找到该学生'
});
}
});
app.post('/api/students/verify', (req, res) => {
const { studentId } = req.body;
const student = mockStudents.find(s => s.studentId === studentId);
res.json({
success: true,
exists: !!student,
data: student || null
});
});
app.get('/health', (req, res) => {
res.json({
success: true,
message: '服务运行正常',
timestamp: new Date().toISOString()
});
});
// 启动服务器
app.listen(PORT, '0.0.0.0', () => {
console.log(`服务器运行在端口 ${PORT}`);
console.log(`健康检查: http://localhost:${PORT}/health`);
});
module.exports = app;
4-2. 测试后端服务
启动服务
node app.js
测试接口
curl http://localhost:3000/health
curl http://localhost:3000/api/students
---
5. 服务器部署
5-1. 购买阿里云服务器
5-1-1 选择配置
- CPU:2核
- 内存:4GB
- 系统:CentOS 7.x 或 Ubuntu 18.04+
- 带宽:3Mbps
5-1-2 配置安全组
开放端口:
- 22 (SSH)
- 80 (HTTP)
- 443 (HTTPS)
- 3000 (后端服务)
5-2. 域名配置
5-2-1 购买域名
- 在阿里云购买域名:zrjm.cc
- 完成域名实名认证
5-2-2 域名解析
添加A记录:
- 主机记录:@
- 记录类型:A
- 记录值:服务器公网IP
- TTL:600
5-3. 服务器环境配置
5-3-1 连接服务器
ssh root@your-server-ip
5-3-2 更新系统
#CentOS
yum update -y
#Ubuntu
apt update && apt upgrade -y
5-3-3 安装Node.js
# 使用NodeSource仓库
curl -fsSL https://rpm.nodesource.com/setup_18.x | bash -
yum install -y nodejs
# 验证安装
node --version
npm --version
```
5-3-4 安装Nginx
# CentOS
yum install -y nginx
# Ubuntu
apt install -y nginx
# 启动服务
systemctl start nginx
systemctl enable nginx
5-3-5 安装PM2
npm install -g pm2
5-4. 配置SSL证书
5-4-1 安装certbot
# CentOS
yum install -y certbot python2-certbot-nginx
# Ubuntu
apt install -y certbot python3-certbot-nginx
5-4-2 申请证书
certbot --nginx -d zrjm.cc
5-4-3 验证证书
测试HTTPS
curl https://zrjm.cc
5-5. 部署后端服务
5-5-1 上传代码
# 创建项目目录
mkdir -p /opt/etu-miniprogram
cd /opt/etu-miniprogram
# 上传代码文件(使用scp或git)
scp -r server/* root@your-server-ip:/opt/etu-miniprogram/
5-5-2 安装依赖
cd /opt/etu-miniprogram
npm install
5-5-3 启动服务
# 使用PM2启动
pm2 start app.js --name "etu-backend"
# 设置开机自启
pm2 startup
pm2 save
# 查看状态
pm2 status
5-5-4 配置Nginx反向代理
# 备份原配置
cp /etc/nginx/sites-enabled/default /etc/nginx/sites-enabled/default.backup
# 创建新配置
cat > /etc/nginx/sites-enabled/default << 'EOF'
server {
listen 80;
server_name zrjm.cc;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name zrjm.cc;
# SSL证书配置
ssl_certificate /etc/letsencrypt/live/zrjm.cc/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/zrjm.cc/privkey.pem;
# SSL安全配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 反向代理到后端服务
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
EOF
# 重启Nginx
nginx -t
systemctl restart nginx
5-6. 测试部署结果
# 测试本地连接
curl http://localhost:3000/health
# 测试HTTPS连接
curl https://zrjm.cc/health
curl https://zrjm.cc/api/students
---
6. 小程序开发
6-1. 创建页面文件
6-1-1 登录页面 (pages/login/)
login.wxml
xml
<view class="container">
<view class="school-info">
<image class="school-logo" src="/images/school_logo2.png" mode="aspectFit"></image>
</view>
<view class="login-form">
<view class="form-title">家长接送签到系统</view>
<button class="login-btn" bindtap="goToRegister">开始使用</button>
</view>
</view>
login.wxss
css
.container {
padding: 30rpx;
background-color: var(--light-green);
min-height: 100vh;
}
.school-info {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 100rpx;
}
.school-logo {
width: 300rpx;
height: 150rpx;
}
.login-form {
text-align: center;
}
.form-title {
font-size: 36rpx;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 80rpx;
}
.login-btn {
background: var(--gradient-green);
color: white;
border-radius: 40rpx;
height: 90rpx;
line-height: 90rpx;
font-size: 32rpx;
width: 100%;
}
login.js
javascript
Page({
goToRegister: function() {
wx.navigateTo({
url: '/pages/register/register'
})
}
})
6-1-2 注册页面 (pages/register/)
register.wxml
xml
<view class="container">
<view class="school-info">
<image class="school-logo" src="/images/school_logo2.png" mode="aspectFit"></image>
</view>
<view class="form-title">添加学生信息</view>
<form bindsubmit="submitForm">
<view class="form-group">
<view class="form-label">家长姓名</view>
<input class="form-input" name="parentName" placeholder="请输入家长姓名" value="{{parentName}}"/>
</view>
<view class="form-group">
<view class="form-label">与学生关系</view>
<picker bindchange="relationChange" value="{{relationIndex}}" range="{{relations}}">
<view class="picker">
{{relations[relationIndex] || '请选择与学生关系'}}
</view>
</picker>
</view>
<view class="form-group">
<view class="form-label">学生姓名</view>
<input class="form-input" name="studentName" placeholder="请输入学生姓名" value="{{studentName}}"/>
</view>
<view class="form-group">
<view class="form-label">学生ID</view>
<input class="form-input" name="studentId" placeholder="请输入学生ID" value="{{studentId}}"/>
</view>
<button class="submit-btn" type="primary" form-type="submit" disabled="{{loading}}">
{{loading ? '验证中...' : '提交'}}
</button>
</form>
</view>
register.wxss
css
.container {
padding: 30rpx;
background-color: var(--light-green);
}
.form-title {
font-size: 36rpx;
font-weight: 600;
text-align: center;
margin: 30rpx 0 50rpx;
color: var(--primary-color);
}
.form-group {
margin-bottom: 30rpx;
background-color: rgba(255, 255, 255, 0.8);
padding: 20rpx;
border-radius: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.form-label {
font-size: 28rpx;
color: var(--text-color);
margin-bottom: 10rpx;
font-weight: 500;
}
.form-input {
border: 1rpx solid rgba(0, 0, 0, 0.1);
border-radius: 8rpx;
padding: 15rpx;
font-size: 28rpx;
background-color: rgba(255, 255, 255, 0.9);
}
.picker {
border: 1rpx solid rgba(0, 0, 0, 0.1);
border-radius: 8rpx;
padding: 15rpx;
font-size: 28rpx;
color: #666;
background-color: rgba(255, 255, 255, 0.9);
}
.submit-btn {
margin-top: 50rpx;
border-radius: 40rpx;
background: var(--gradient-green) !important;
height: 90rpx;
line-height: 90rpx;
font-size: 32rpx;
letter-spacing: 2rpx;
box-shadow: 0 8rpx 16rpx rgba(94, 176, 69, 0.3);
border: none;
}
.school-info {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 30rpx;
}
.school-logo {
width: 300rpx;
height: 150rpx;
}
register.js
javascript
const api = require('../../utils/api');
Page({
data: {
parentName: '',
relations: ['父亲', '母亲', '其他'],
relationIndex: 0,
studentName: '',
studentId: '',
loading: false
},
onLoad: function (options) {
const app = getApp()
if (app.globalData.studentInfo) {
const studentInfo = app.globalData.studentInfo
this.setData({
parentName: studentInfo.parentName || '',
relationIndex: studentInfo.relationIndex || 0,
studentName: studentInfo.studentName || '',
studentId: studentInfo.studentId || ''
})
}
},
relationChange: function(e) {
this.setData({
relationIndex: e.detail.value
})
},
// 验证学生ID和姓名是否匹配
verifyStudentInfo: function(studentId, studentName) {
var that = this
return api.verifyStudentId(studentId).then(function(response) {
if (response.success && response.exists) {
var feishuStudent = response.data
// 比较学生姓名是否匹配(忽略空格和大小写)
var inputName = studentName.trim().replace(/\s+/g, '')
var feishuName = feishuStudent.studentName.trim().replace(/\s+/g, '')
if (inputName === feishuName) {
return {
success: true,
student: feishuStudent
}
} else {
return {
success: false,
message: '学生姓名与学生ID不匹配'
}
}
} else {
return {
success: false,
message: '学生ID不存在'
}
}
}).catch(function(error) {
console.error('验证学生信息失败:', error)
return {
success: false,
message: '验证失败,请检查网络连接'
}
})
},
submitForm: function (e) {
var parentName = e.detail.value.parentName
var studentName = e.detail.value.studentName
var studentId = e.detail.value.studentId
var relation = this.data.relations[this.data.relationIndex]
console.log('提交表单:', { parentName, studentName, studentId, relation })
if (!parentName || !relation || !studentName || !studentId) {
wx.showToast({
title: '请填写所有信息',
icon: 'none'
})
return
}
// 设置加载状态
this.setData({ loading: true })
var that = this
// 由于后端API可能还未部署,先使用模拟验证
this.simulateVerification(studentId, studentName).then(function(verifyResult) {
that.setData({ loading: false })
console.log('验证结果:', verifyResult)
if (verifyResult.success) {
// 验证成功,添加学生
var newStudent = {
parentName: parentName,
relation: relation,
studentName: studentName,
studentId: studentId,
grade: verifyResult.student.grade || '',
className: verifyResult.student.className || '',
homeroomTeacher: verifyResult.student.homeroomTeacher || ''
}
// 获取现有的学生列表
var app = getApp()
var existingStudentList = app.globalData.studentList || []
// 检查学生ID是否已存在
var isDuplicate = false
for (var i = 0; i < existingStudentList.length; i++) {
if (existingStudentList[i].studentId === studentId) {
isDuplicate = true
break
}
}
if (isDuplicate) {
wx.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
// 添加新学生到列表
existingStudentList.push(newStudent)
// 更新全局数据
app.globalData.studentList = existingStudentList
app.globalData.studentInfo = newStudent
// 保存到本地存储
wx.setStorage({
key: 'studentList',
data: existingStudentList,
success: function() {
wx.setStorage({
key: 'studentInfo',
data: newStudent,
success: function() {
wx.showToast({
title: '添加成功',
icon: 'success',
duration: 1500,
success: function() {
wx.redirectTo({
url: '/pages/checkin/checkin',
})
}
})
}
})
},
fail: function(err) {
console.error("保存失败", err)
wx.showToast({
title: '添加失败',
icon: 'none'
})
}
})
} else {
// 验证失败,显示错误信息
wx.showToast({
title: verifyResult.message || '添加失败,请核查学生姓名与学生ID是否有误',
icon: 'none',
duration: 3000
})
}
}).catch(function(error) {
that.setData({ loading: false })
console.error('验证过程出错:', error)
wx.showToast({
title: '验证失败,请检查网络连接',
icon: 'none',
duration: 3000
})
})
},
// 模拟验证函数(用于测试,实际部署后可以删除)
simulateVerification: function(studentId, studentName) {
var that = this
return new Promise(function(resolve) {
// 模拟网络延迟
setTimeout(function() {
// 模拟验证逻辑:如果学生ID是10001且姓名是陈奕宏,则验证通过
if (studentId === '10001' && studentName === '陈奕宏') {
resolve({
success: true,
student: {
studentId: '10001',
studentName: '陈奕宏',
grade: 'G5',
className: 'G5-1凤凰班',
homeroomTeacher: '汤萍'
}
})
} else if (studentId === '10002' && studentName === '陈奕欣') {
resolve({
success: true,
student: {
studentId: '10002',
studentName: '陈奕欣',
grade: 'G3',
className: 'G3-1天马班',
homeroomTeacher: '韦丽平'
}
})
} else {
resolve({
success: false,
message: '添加失败,请核查学生姓名与学生ID是否有误'
})
}
}, 1000)
})
}
})
6-1-3 签到页面 (pages/checkin/)
checkin.wxml
<view class="container">
<view class="header">
<view class="school-info">
<image class="school-logo" src="/images/school_logo2.png" mode="aspectFit"></image>
<view class="school-name">一土致知学校</view>
</view>
<view class="student-info">
<view class="student-avatar" bindtap="changeAvatar">
<image wx:if="{{currentStudent.avatar}}" src="{{currentStudent.avatar}}" mode="aspectFill"></image>
<view wx:else class="avatar-placeholder">头像</view>
</view>
<view class="student-details">
<view class="student-name">{{currentStudent.studentName}}</view>
<view class="student-id">学号:{{currentStudent.studentId}}</view>
<view class="parent-info">{{currentStudent.parentName}}({{currentStudent.relation}})</view>
</view>
</view>
</view>
<view class="today-info">
<view class="date">{{todayDate}}</view>
<view class="attendance-status">
<view class="status-item" wx:if="{{todayAttendance.entry}}">
<text class="status-label">入校:</text>
<text class="status-time">{{todayAttendance.entry}}</text>
</view>
<view class="status-item" wx:if="{{todayAttendance.exit}}">
<text class="status-label">出校:</text>
<text class="status-time">{{todayAttendance.exit}}</text>
</view>
</view>
</view>
<view class="student-list" wx:if="{{studentList.length > 1}}">
<view class="list-title">切换学生</view>
<view class="student-items">
<view
class="student-item {{currentStudentId === item.studentId ? 'active' : ''}}"
wx:for="{{studentList}}"
wx:key="studentId"
bindtap="switchStudent"
data-student-id="{{item.studentId}}"
>
{{item.studentName}}
</view>
</view>
</view>
<view class="actions">
<button class="action-btn add-student" bindtap="addStudent">添加学生</button>
<button class="action-btn logout" bindtap="testLogout">退出登录</button>
</view>
<view class="calendar-section">
<view class="calendar-header">
<button class="month-btn" bindtap="prevMonth">‹</button>
<view class="month-year">{{currentYear}}年{{currentMonth}}月</view>
<button class="month-btn" bindtap="nextMonth">›</button>
</view>
<view class="calendar-grid">
<view class="weekdays">
<view class="weekday" wx:for="{{['日','一','二','三','四','五','六']}}" wx:key="*this">{{item}}</view>
</view>
<view class="days">
<view
class="day {{item.isCurrentMonth ? 'current-month' : ''}} {{item.isToday ? 'today' : ''}} {{item.hasCheckin ? 'has-checkin' : ''}}"
wx:for="{{calendarDays}}"
wx:key="index"
bindtap="onDayTap"
data-day="{{item.day}}"
>
{{item.day}}
<view class="checkin-indicator" wx:if="{{item.hasCheckin}}">
<view class="indicator-dot {{item.checkinType}}"></view>
</view>
</view>
</view>
</view>
</view>
</view>
checkin.wxss
.container {
padding: 20rpx;
background-color: var(--light-green);
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #5eb045 0%, #4a9c3a 100%);
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
color: white;
}
.school-info {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 30rpx;
}
.school-logo {
width: 200rpx;
height: 100rpx;
margin-bottom: 10rpx;
}
.school-name {
font-size: 28rpx;
font-weight: 600;
}
.student-info {
display: flex;
align-items: center;
}
.student-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background-color: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
overflow: hidden;
}
.student-avatar image {
width: 100%;
height: 100%;
}
.avatar-placeholder {
color: white;
font-size: 24rpx;
}
.student-details {
flex: 1;
}
.student-name {
font-size: 32rpx;
font-weight: 600;
margin-bottom: 10rpx;
}
.student-id {
font-size: 24rpx;
opacity: 0.8;
margin-bottom: 5rpx;
}
.parent-info {
font-size: 22rpx;
opacity: 0.7;
}
.today-info {
background: white;
border-radius: 15rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.date {
font-size: 28rpx;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 20rpx;
}
.attendance-status {
display: flex;
gap: 30rpx;
}
.status-item {
display: flex;
align-items: center;
}
.status-label {
font-size: 24rpx;
color: #666;
margin-right: 10rpx;
}
.status-time {
font-size: 24rpx;
font-weight: 600;
color: var(--primary-color);
}
.student-list {
background: white;
border-radius: 15rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.list-title {
font-size: 28rpx;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 20rpx;
}
.student-items {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.student-item {
padding: 15rpx 25rpx;
border-radius: 25rpx;
background-color: #f5f5f5;
color: #666;
font-size: 24rpx;
transition: all 0.3s;
}
.student-item.active {
background-color: var(--primary-color);
color: white;
}
.actions {
display: flex;
gap: 20rpx;
margin-bottom: 30rpx;
}
.action-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 600;
border: none;
}
.add-student {
background: var(--gradient-green);
color: white;
}
.logout {
background: #ff6b6b;
color: white;
}
.calendar-section {
background: white;
border-radius: 15rpx;
padding: 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
}
.month-btn {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: var(--primary-color);
border: none;
}
.month-year {
font-size: 32rpx;
font-weight: 600;
color: var(--primary-color);
}
.calendar-grid {
width: 100%;
}
.weekdays {
display: flex;
margin-bottom: 20rpx;
}
.weekday {
flex: 1;
text-align: center;
font-size: 24rpx;
color: #666;
font-weight: 600;
padding: 10rpx 0;
}
.days {
display: flex;
flex-wrap: wrap;
}
.day {
width: calc(100% / 7);
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #666;
position: relative;
border-radius: 8rpx;
margin: 2rpx;
}
.day.current-month {
color: #333;
}
.day.today {
background-color: var(--primary-color);
color: white;
font-weight: 600;
}
.day.has-checkin {
background-color: rgba(94, 176, 69, 0.1);
}
.checkin-indicator {
position: absolute;
bottom: 5rpx;
right: 5rpx;
}
.indicator-dot {
width: 12rpx;
height: 12rpx;
border-radius: 6rpx;
background-color: var(--primary-color);
}
.indicator-dot.entry {
background-color: #4CAF50;
}
.indicator-dot.exit {
background-color: #FF9800;
}
.indicator-dot.both {
background-color: #2196F3;
}
checkin.js
const api = require('../../utils/api');
Page({
data: {
studentInfo: null,
studentList: [],
currentStudentId: '',
currentStudent: {},
checkinHistory: [],
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
calendarDays: [],
studentAttendance: {},
todayDate: '',
todayAttendance: {},
loading: false,
error: null
},
onLoad: function() {
console.log('签到页面加载完成')
this.loadStudentList()
this.loadStudentAttendance()
this.generateCalendar()
this.initTodayInfo()
},
onShow: function() {
console.log('签到页面显示')
// 重新加载学生列表,以便显示新添加的学生
this.loadStudentList()
this.loadStudentAttendance()
this.generateCalendar()
this.initTodayInfo()
},
// 加载学生列表
loadStudentList: function() {
var that = this
var app = getApp()
this.setData({ loading: true, error: null })
// 先尝试从全局数据获取
if (app.globalData.studentList && app.globalData.studentList.length > 0) {
this.setData({
studentList: app.globalData.studentList,
currentStudentId: app.globalData.currentStudentId || app.globalData.studentList[0].studentId,
currentStudent: this.getStudentById(app.globalData.studentList, app.globalData.currentStudentId || app.globalData.studentList[0].studentId),
loading: false
})
return
}
// 从API获取学生数据
api.getAllStudents().then(function(response) {
if (response.success) {
var studentList = response.data || []
var currentStudentId = studentList.length > 0 ? studentList[0].studentId : ''
that.setData({
studentList: studentList,
currentStudentId: currentStudentId,
currentStudent: that.getStudentById(studentList, currentStudentId),
loading: false
})
// 保存到全局数据和本地存储
app.globalData.studentList = studentList
app.globalData.currentStudentId = currentStudentId
wx.setStorage({
key: 'studentList',
data: studentList
})
} else {
throw new Error(response.message || '获取学生数据失败')
}
}).catch(function(error) {
console.error('获取学生数据失败:', error)
// 如果API失败,尝试从本地存储获取
wx.getStorage({
key: 'studentList',
success: function(res) {
var studentList = res.data || []
if (studentList.length > 0) {
var currentStudentId = studentList[0].studentId
that.setData({
studentList: studentList,
currentStudentId: currentStudentId,
currentStudent: that.getStudentById(studentList, currentStudentId),
loading: false,
error: '网络连接失败,使用本地数据'
})
app.globalData.studentList = studentList
app.globalData.currentStudentId = currentStudentId
} else {
// 如果本地也没有数据,生成模拟数据
that.generateMockStudentList()
}
},
fail: function() {
// 如果本地也没有数据,生成模拟数据
that.generateMockStudentList()
}
})
})
},
// 生成模拟学生列表
generateMockStudentList: function() {
var mockStudents = [
{
studentId: '10001',
studentName: '陈奕宏',
parentName: '陈柯辰',
relation: '父亲'
},
{
studentId: '10002',
studentName: '陈奕欣',
parentName: '陈柯辰',
relation: '父亲'
}
]
this.setData({
studentList: mockStudents,
currentStudentId: mockStudents[0].studentId,
currentStudent: mockStudents[0]
})
// 保存到本地存储和全局数据
var app = getApp()
wx.setStorage({
key: 'studentList',
data: mockStudents
})
app.globalData.studentList = mockStudents
app.globalData.currentStudentId = mockStudents[0].studentId
},
// 加载学生考勤数据
loadStudentAttendance: function() {
// 模拟数据
var attendance = {}
var today = new Date()
for (var i = 0; i < 30; i++) {
var date = new Date(today)
date.setDate(date.getDate() - i)
var dateStr = this.formatDate(date)
var hasEntry = Math.random() > 0.1
var hasExit = Math.random() > 0.2
if (hasEntry || hasExit) {
attendance[dateStr] = {
entry: hasEntry ? this.generateTime(7, 8) : null,
exit: hasExit ? this.generateTime(15, 17) : null
}
}
}
this.setData({
studentAttendance: attendance
})
},
// 生成随机时间
generateTime: function(startHour, endHour) {
var hour = startHour + Math.floor(Math.random() * (endHour - startHour + 1))
var minute = Math.floor(Math.random() * 60)
return hour.toString().padStart(2, '0') + ':' + minute.toString().padStart(2, '0')
},
// 格式化日期
formatDate: function(date) {
var year = date.getFullYear()
var month = (date.getMonth() + 1).toString().padStart(2, '0')
var day = date.getDate().toString().padStart(2, '0')
return year + '-' + month + '-' + day
},
// 生成日历数据
generateCalendar: function() {
var currentYear = this.data.currentYear
var currentMonth = this.data.currentMonth
var studentAttendance = this.data.studentAttendance
var calendarDays = []
var firstDay = new Date(currentYear, currentMonth - 1, 1)
var lastDay = new Date(currentYear, currentMonth, 0)
var firstDayOfWeek = firstDay.getDay()
// 添加上个月的日期
var prevMonth = new Date(currentYear, currentMonth - 2, 0)
for (var i = firstDayOfWeek - 1; i >= 0; i--) {
var day = prevMonth.getDate() - i
calendarDays.push({
day: day,
isCurrentMonth: false,
hasCheckin: false,
isToday: false
})
}
// 添加当月的日期
var today = new Date()
for (var day = 1; day <= lastDay.getDate(); day++) {
var dateStr = this.formatDate(new Date(currentYear, currentMonth - 1, day))
var attendance = studentAttendance[dateStr] || {}
var hasEntry = !!attendance.entry
var hasExit = !!attendance.exit
var hasCheckin = hasEntry || hasExit
var checkinType = ''
if (hasEntry && hasExit) {
checkinType = 'both'
} else if (hasEntry) {
checkinType = 'entry'
} else if (hasExit) {
checkinType = 'exit'
}
calendarDays.push({
day: day,
isCurrentMonth: true,
hasCheckin: hasCheckin,
checkinType: checkinType,
isToday: today.getFullYear() === currentYear &&
today.getMonth() + 1 === currentMonth &&
today.getDate() === day
})
}
// 添加下个月的日期
var remainingDays = 42 - calendarDays.length
for (var day = 1; day <= remainingDays; day++) {
calendarDays.push({
day: day,
isCurrentMonth: false,
hasCheckin: false,
isToday: false
})
}
this.setData({
calendarDays: calendarDays
})
},
// 初始化今天信息
initTodayInfo: function() {
var today = new Date()
var todayDateStr = this.formatDate(today)
var todayAttendance = this.data.studentAttendance[todayDateStr] || {}
var year = today.getFullYear()
var month = today.getMonth() + 1
var day = today.getDate()
var weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
var weekday = weekdays[today.getDay()]
this.setData({
todayDate: year + '年' + month + '月' + day + '日 ' + weekday,
todayAttendance: todayAttendance
})
},
// 修改头像
changeAvatar: function(e) {
console.log('头像被点击', e)
wx.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: function(res) {
if (res.tapIndex === 0) {
// 拍照
this.chooseImage('camera')
} else if (res.tapIndex === 1) {
// 从相册选择
this.chooseImage('album')
}
}.bind(this)
})
},
// 选择图片
chooseImage: function(sourceType) {
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: [sourceType],
success: function(res) {
var tempFilePath = res.tempFilePaths[0]
this.uploadAvatar(tempFilePath)
}.bind(this),
fail: function(err) {
console.error('选择图片失败:', err)
wx.showToast({
title: '选择图片失败',
icon: 'none'
})
}
})
},
// 上传头像
uploadAvatar: function(filePath) {
wx.showLoading({
title: '上传中...'
})
// 模拟上传过程
setTimeout(function() {
wx.hideLoading()
// 更新当前学生头像
var studentList = this.data.studentList.map(function(student) {
if (student.studentId === this.data.currentStudentId) {
var newStudent = {}
for (var key in student) {
newStudent[key] = student[key]
}
newStudent.avatar = filePath
return newStudent
}
return student
}.bind(this))
var newCurrentStudent = {}
for (var key in this.data.currentStudent) {
newCurrentStudent[key] = this.data.currentStudent[key]
}
newCurrentStudent.avatar = filePath
this.setData({
studentList: studentList,
currentStudent: newCurrentStudent
})
// 保存到本地存储
wx.setStorage({
key: 'studentList',
data: studentList
})
wx.showToast({
title: '头像更新成功',
icon: 'success'
})
}.bind(this), 1500)
},
// 添加学生
addStudent: function(e) {
console.log('添加学生按钮被点击', e)
wx.showModal({
title: '添加学生',
content: '是否要添加新学生?添加后需要绑定学生信息。',
success: function(res) {
if (res.confirm) {
// 跳转到注册页面
wx.navigateTo({
url: '/pages/register/register'
})
}
}
})
},
// 切换学生
switchStudent: function(e) {
var studentId = e.currentTarget.dataset.studentId
var currentStudent = this.getStudentById(this.data.studentList, studentId)
this.setData({
currentStudentId: studentId,
currentStudent: currentStudent
})
// 重新加载该学生的考勤数据
this.loadStudentAttendance()
// 显示切换成功提示
wx.showToast({
title: '已切换到' + currentStudent.studentName,
icon: 'success',
duration: 1500
})
},
// 根据ID获取学生信息
getStudentById: function(studentList, studentId) {
for (var i = 0; i < studentList.length; i++) {
if (studentList[i].studentId === studentId) {
return studentList[i]
}
}
return studentList[0]
},
// 上一个月
prevMonth: function() {
var currentYear = this.data.currentYear
var currentMonth = this.data.currentMonth
currentMonth--
if (currentMonth < 1) {
currentMonth = 12
currentYear--
}
this.setData({
currentYear: currentYear,
currentMonth: currentMonth
})
this.generateCalendar()
},
// 下一个月
nextMonth: function() {
var currentYear = this.data.currentYear
var currentMonth = this.data.currentMonth
currentMonth++
if (currentMonth > 12) {
currentMonth = 1
currentYear++
}
this.setData({
currentYear: currentYear,
currentMonth: currentMonth
})
this.generateCalendar()
},
// 点击日期
onDayTap: function(e) {
var day = e.currentTarget.dataset.day
var currentYear = this.data.currentYear
var currentMonth = this.data.currentMonth
var dateStr = this.formatDate(new Date(currentYear, currentMonth - 1, day))
var attendance = this.data.studentAttendance[dateStr]
if (attendance) {
var message = currentYear + '年' + currentMonth + '月' + day + '日\n'
if (attendance.entry) {
message += '入校时间: ' + attendance.entry + '\n'
}
if (attendance.exit) {
message += '出校时间: ' + attendance.exit
}
wx.showModal({
title: '考勤详情',
content: message,
showCancel: false
})
} else {
wx.showToast({
title: '该日无考勤记录',
icon: 'none'
})
}
},
// 退出登录
testLogout: function(e) {
console.log('退出登录按钮被点击', e)
wx.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: function(res) {
if (res.confirm) {
wx.showToast({
title: '正在退出...',
icon: 'loading',
duration: 1500
})
setTimeout(function() {
// 清除本地数据
wx.clearStorageSync()
// 跳转到登录页面
wx.reLaunch({
url: '/pages/login/login',
success: function() {
console.log('成功跳转到登录页面')
},
fail: function(err) {
console.error('跳转失败:', err)
wx.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}, 1500)
}
}
})
}
})
6-1-4 工具文件 (utils/)
api.js
// API配置
const API_BASE_URL = 'https://zrjm.cc/api';
// 请求封装
function request(options) {
return new Promise((resolve, reject) => {
wx.request({
url: API_BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
...options.header
},
success: function(res) {
if (res.statusCode === 200) {
resolve(res.data);
} else {
reject(new Error(`请求失败: ${res.statusCode}`));
}
},
fail: function(err) {
reject(err);
}
});
});
}
// API接口
const api = {
// 获取所有学生数据
getAllStudents() {
return request({
url: '/students',
method: 'GET'
});
},
// 根据学生ID获取学生信息
getStudentById(studentId) {
return request({
url: /students/${studentId},
method: 'GET'
});
},
// 验证学生ID是否存在
verifyStudentId(studentId) {
return request({
url: '/students/verify',
method: 'POST',
data: { studentId }
});
},
// 根据姓名搜索学生
searchStudentsByName(name) {
return request({
url: /students/search/${encodeURIComponent(name)},
method: 'GET'
});
},
// 获取指定年级的学生
getStudentsByGrade(grade) {
return request({
url: /students/grade/${grade},
method: 'GET'
});
},
// 获取指定班级的学生
getStudentsByClass(className) {
return request({
url: /students/class/${encodeURIComponent(className)},
method: 'GET'
});
},
// 签到记录
checkin(studentId, checkinType) {
return request({
url: '/checkin',
method: 'POST',
data: {
studentId,
checkinType,
timestamp: new Date().toISOString()
}
});
}
};
module.exports = api;
6-1-5 全局配置文件
app.js
App({
onLaunch: function() {
// 展示本地存储能力
const logs = wx.getStorageSync('logs') || []
logs.unshift(Date.now())
wx.setStorageSync('logs', logs)
// 登录
wx.login({
success: res => {
// 发送 res.code 到后台换取 openId, sessionKey, unionId
}
})
},
globalData: {
userInfo: null,
studentInfo: null,
studentList: [],
currentStudentId: null
}
})
app.json
{
"pages": [
"pages/login/login",
"pages/register/register",
"pages/checkin/checkin"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#5eb045",
"navigationBarTitleText": "家长接送签到系统",
"navigationBarTextStyle": "white"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}
app.wxss
/**app.wxss**/
.container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 200rpx 0;
box-sizing: border-box;
}
/* 全局变量 */
page {
--primary-color: #5eb045;
--light-green: #f0f9e8;
--text-color: #333;
--gradient-green: linear-gradient(135deg, #5eb045 0%, #4a9c3a 100%);
}
/* 全局样式重置 */
* {
box-sizing: border-box;
}
button {
border: none;
outline: none;
}
button::after {
border: none;
}
input {
outline: none;
}
/* 通用样式 */
.text-center {
text-align: center;
}
.mb-20 {
margin-bottom: 20rpx;
}
.mt-20 {
margin-top: 20rpx;
}
.p-20 {
padding: 20rpx;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
/* 文字样式 */
.text-primary {
color: var(--primary-color);
}
.text-white {
color: white;
}
.text-gray {
color: #666;
}
.text-small {
font-size: 24rpx;
}
.text-large {
font-size: 32rpx;
}
.text-bold {
font-weight: 600;
}
/* 背景样式 */
.bg-primary {
background-color: var(--primary-color);
}
.bg-light {
background-color: var(--light-green);
}
.bg-white {
background-color: white;
}
/* 圆角样式 */
.rounded {
border-radius: 8rpx;
}
.rounded-lg {
border-radius: 15rpx;
}
.rounded-full {
border-radius: 50%;
}
/* 阴影样式 */
.shadow {
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.shadow-lg {
box-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.15);
}
---
7. 域名配置
7-1. 微信公众平台配置
7-1-1 登录微信公众平台
1. 访问:https://mp.weixin.qq.com/
2. 使用管理员微信扫码登录
7-1-2 配置服务器域名
1. 进入"开发" → "开发管理" → "开发设置"
2. 找到"服务器域名"部分
3. 填写以下域名:
```
request合法域名: https://zrjm.cc
socket合法域名: (留空)
uploadFile合法域名: https://zrjm.cc
downloadFile合法域名: https://zrjm.cc
udp合法域名: (留空)
tcp合法域名: (留空)
DNS预解析域名: zrjm.cc
预连接域名: https://zrjm.cc
```
7-1-3 保存配置
1. 点击"保存并提交"
2. 等待配置生效(通常几分钟内)
---
8. 测试与调试
8-1. 本地测试
8-1-1 在微信开发者工具中测试
1. 打开微信开发者工具
2. 选择"小程序"
3. 导入项目:选择F:\Etu目录
4. 点击"编译"
8-1-2 功能测试清单
注册功能测试:
- 页面正常显示
- 表单验证正常
- 学生ID验证功能
- 成功案例:学生ID
10001
,姓名陈奕宏
- 成功案例:学生ID
10002
,姓名陈奕欣
- 失败案例:错误的学生ID或姓名
- 跳转功能正常
签到功能测试:
- 页面正常显示
- 学生信息显示正确
- 日历功能正常
- 切换学生功能
- 添加学生功能
- 退出登录功能
8-1-3 网络请求测试
1. 打开"调试器" → "Network"面板
2. 测试注册功能
3. 检查请求是否发送到 https://zrjm.cc/api/students/verify
4. 验证响应数据格式
8-2. 真机测试
8-2-1 预览功能
1. 在微信开发者工具中点击"预览"
2. 用微信扫描二维码
3. 在真机上测试功能
8-2-2 真机调试
1. 在微信开发者工具中点击"真机调试"
2. 用微信扫描二维码
3. 在真机上测试并查看调试信息
---
9. 发布上线
9-1. 代码上传
9-1-1 在微信开发者工具中上传
1. 点击"上传"按钮
2. 填写版本信息:
- 版本号:1.0.0
- 项目备注:初始版本发布
3. 点击"上传"
9-1-2 确认上传成功
1. 登录微信公众平台
2. 进入"开发" → "开发管理"
3. 查看"开发版本"列表
4. 确认代码已上传
9-2. 提交审核
9-2-1 填写审核信息
1. 在"开发版本"中找到上传的版本
2. 点击"提交审核"
3. 填写审核信息:
- 功能页面:选择主要功能页面
- 测试账号:提供测试账号(如需要)
- 补充材料:上传相关说明文档
9-2-2 等待审核
1. 提交后等待微信审核
2. 审核时间:通常1-7个工作日
3. 审核结果会通过微信通知
9-3. 发布上线
9-3-1 审核通过后发布
1. 收到审核通过通知
2. 在微信公众平台点击"发布"
3. 确认发布信息
4. 点击"发布"
9-3-2 验证发布结果
1. 在微信中搜索小程序名称
2. 测试所有功能
3. 确认用户体验正常
---
10. 后续优化
10-1. 飞书接口集成
10-1-1 申请飞书应用
1. 访问飞书开放平台:https://open.feishu.cn/
2. 创建企业自建应用
3. 获取应用凭证:
- App ID
- App Secret
- 表格Token
- 表格ID
10-1-2 配置后端服务
1. 更新后端代码集成飞书API
2. 配置字段映射
3. 测试数据同步
10-1-3 更新小程序
1. 移除模拟验证逻辑
2. 使用真实API接口
3. 测试完整功能
10-2. 功能增强
10-2-1 数据统计
- 添加考勤统计功能
- 生成月度报表
- 数据可视化展示
10-2-2 消息通知
- 签到成功通知
- 异常情况提醒
- 定期数据推送
10-2-3 权限管理
- 管理员功能
- 教师查看权限
- 家长权限控制
10-3. 性能优化
10-3-1 代码优化
- 图片压缩优化
- 代码分包加载
- 缓存策略优化
10-3-2 服务器优化
- 数据库连接池
- Redis缓存
- CDN加速
10-4. 安全加固
10-4-1 API安全
- 接口签名验证
- 频率限制
- 数据加密
10-4-2 数据安全
- 敏感信息脱敏
- 数据备份策略
- 访问日志记录
---
总结
本教程详细介绍了从零开始开发微信小程序到成功发布的完整流程,包括:
1. 环境准备:开发工具、服务器、域名等
2. 项目开发:前端小程序、后端API、数据库设计
3. 服务器部署:环境配置、SSL证书、服务管理
4. 功能测试:本地测试、真机测试、网络调试
5. 发布上线:代码上传、审核提交、正式发布
6. 后续优化:功能增强、性能优化、安全加固