- 修改数据库配置将MYSQL_DB从mys_info_v3改为mys_info_test - 新增auth_users表wechat_openid字段并建立唯一索引 - 为plan_checkins表添加计划性能索引(idx_plan_period_student) - 将checkin_records_old表关联方式从student_id改为user_id - 将plan_checkins表关联方式从student_id改为user_id并更新附件路径 - 移除routes/auth.py中的uuid导入使用安全token生成函数 - 重构routes/checkin.py打卡逻辑去除course_type字段并优化返回结构 - 添加打卡时间限制验证功能防止过期打卡操作 - 实现小组统计功能支持老师查看各组打卡情况 - 更新主页index.html界面适配新的打卡和计划功能 - 调整计划提交时间规则为周五至周四周期 - 添加月度和年度计划提交状态验证机制
436 lines
13 KiB
JavaScript
436 lines
13 KiB
JavaScript
const api = require('../../utils/api');
|
||
const auth = require('../../utils/auth');
|
||
|
||
const STATUSES = ['正常', '迟到', '请假', '不参加讨论', '早退'];
|
||
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||
const STATUS_META = {
|
||
正常: '#2E7D32',
|
||
迟到: '#C67B30',
|
||
请假: '#3A7BD5',
|
||
早退: '#8B7A6A',
|
||
'不参加讨论': '#C0392B'
|
||
};
|
||
const STATUS_ITEMS = STATUSES.map((label) => ({
|
||
label,
|
||
color: STATUS_META[label] || '#C67B30'
|
||
}));
|
||
|
||
function errMessage(err) {
|
||
if (err && typeof err === 'object') {
|
||
if (err.detail) return String(err.detail);
|
||
if (err.error) return String(err.error);
|
||
if (err.message) return err.message;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function pad2(n) {
|
||
return n < 10 ? '0' + n : String(n);
|
||
}
|
||
|
||
function fmtTime(ts) {
|
||
if (!ts) return '';
|
||
const d = new Date(String(ts).replace(' ', 'T'));
|
||
if (isNaN(d.getTime())) return String(ts);
|
||
const now = new Date();
|
||
const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||
const diff = Math.round((startOfDay(now) - startOfDay(d)) / 86400000);
|
||
const hm = pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||
if (diff === 0) return '今天 ' + hm;
|
||
if (diff === 1) return '昨天 ' + hm;
|
||
return pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' + hm;
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
statusItems: STATUS_ITEMS,
|
||
statusIndex: -1,
|
||
loading: true,
|
||
classDate: '',
|
||
weekday: '',
|
||
dayNum: '',
|
||
checked: false,
|
||
record: null,
|
||
statusColor: '',
|
||
// 视图模式:overview 总览 / detail 小组详情(仅老师)
|
||
view: 'overview',
|
||
// 当前选中的小组(详情页用)
|
||
selectedGroupName: '',
|
||
// 小组统计列表(老师总览用)
|
||
groups: [],
|
||
// 小组成员列表(详情页 & 学员小组 tab 用)
|
||
members: [],
|
||
checkedCount: 0,
|
||
total: 0,
|
||
// 用户角色
|
||
isTeacher: false,
|
||
myGroup: '',
|
||
unassignedCount: 0,
|
||
// 学员视图 tab:group / personal
|
||
tab: 'group',
|
||
// 个人历史
|
||
history: [],
|
||
historyPage: 1,
|
||
historyTotal: 0,
|
||
historyFinished: false,
|
||
historyLoading: false,
|
||
submitting: false,
|
||
refreshing: false,
|
||
loadError: '',
|
||
// 周选择相关
|
||
weekList: [],
|
||
showWeekPicker: false,
|
||
},
|
||
|
||
onShow() {
|
||
this.loadPage();
|
||
},
|
||
|
||
onPullDownRefresh() {
|
||
this.loadPage().finally(() => wx.stopPullDownRefresh());
|
||
},
|
||
|
||
loadPage() {
|
||
this.setData({ loadError: '' });
|
||
return auth.ensureLogin()
|
||
.then((token) => {
|
||
if (!token) {
|
||
wx.switchTab({ url: '/pages/profile/profile' });
|
||
return;
|
||
}
|
||
// 先加载周列表,再加载当前周数据
|
||
return this.loadWeeks();
|
||
})
|
||
.then(() => {
|
||
return this.loadWeek();
|
||
})
|
||
.catch((err) => {
|
||
const msg = (err && (err.error || err.detail || err.message)) || '加载失败,请重试';
|
||
this.setData({ loading: false, loadError: msg });
|
||
wx.showToast({ title: msg, icon: 'none' });
|
||
});
|
||
},
|
||
|
||
onRetry() {
|
||
this.loadPage();
|
||
},
|
||
|
||
/** 加载可用周列表 */
|
||
loadWeeks() {
|
||
return api.request({ url: '/api/v1/checkin/weeks' }).then((res) => {
|
||
const weeks = res.weeks || [];
|
||
const current = res.current_week || (weeks[0] || '');
|
||
this.setData({
|
||
weekList: weeks,
|
||
classDate: this.data.classDate || current,
|
||
});
|
||
}).catch(() => {
|
||
// 获取周列表失败不影响主流程
|
||
console.warn('获取上课周列表失败');
|
||
});
|
||
},
|
||
|
||
/** 打开周选择器 */
|
||
openWeekPicker() {
|
||
if (!this.data.weekList.length) {
|
||
return;
|
||
}
|
||
this.setData({ showWeekPicker: true });
|
||
},
|
||
|
||
/** 关闭周选择器 */
|
||
closeWeekPicker() {
|
||
this.setData({ showWeekPicker: false });
|
||
},
|
||
|
||
/** 选择某一周 */
|
||
onWeekSelect(e) {
|
||
const classDate = e.currentTarget.dataset.date;
|
||
if (!classDate || classDate === this.data.classDate) {
|
||
this.setData({ showWeekPicker: false });
|
||
return;
|
||
}
|
||
this.setData({
|
||
classDate,
|
||
showWeekPicker: false,
|
||
});
|
||
this.loadWeek();
|
||
},
|
||
|
||
/**
|
||
* 加载当前周数据:
|
||
* - 老师:加载小组统计总览
|
||
* - 学员:加载小组统计 + 本组成员列表 + 个人历史(个人 tab 延迟加载)
|
||
*/
|
||
loadWeek() {
|
||
this.setData({ loading: true });
|
||
const classDate = this.data.classDate;
|
||
const params = classDate ? { class_date: classDate } : {};
|
||
return api.request({ url: '/api/v1/checkin/groups', data: params }).then((res) => {
|
||
const date = res.class_date || '';
|
||
const weekday = WEEKDAYS[new Date(date + 'T00:00:00').getDay()] || '';
|
||
const dayNum = date.split('-')[2] || '';
|
||
const statusIndex = res.record && res.record.status
|
||
? Math.max(0, STATUSES.indexOf(res.record.status))
|
||
: -1;
|
||
const statusColor = res.record ? (STATUS_META[res.record.status] || '#C67B30') : '';
|
||
const isTeacher = !!res.is_teacher;
|
||
|
||
// 计算每个小组的百分比
|
||
const groups = (res.groups || []).map((g) => ({
|
||
...g,
|
||
percent: g.total ? Math.round(((g.checked_count || 0) / g.total) * 100) : 0
|
||
}));
|
||
|
||
const baseData = {
|
||
classDate: date,
|
||
weekday,
|
||
dayNum,
|
||
checked: !!res.checked,
|
||
record: res.record || null,
|
||
statusIndex,
|
||
statusColor,
|
||
isTeacher,
|
||
myGroup: res.my_group || '',
|
||
unassignedCount: res.unassigned_count || 0,
|
||
groups,
|
||
};
|
||
|
||
// 如果当前在详情页(老师),重新加载该组详情
|
||
if (isTeacher && this.data.view === 'detail' && this.data.selectedGroupName) {
|
||
this.setData({ ...baseData, members: [], loading: true });
|
||
return this.loadGroup(this.data.selectedGroupName).then(() => {
|
||
this.setData({ loading: false });
|
||
});
|
||
}
|
||
|
||
this.setData(baseData);
|
||
|
||
if (!isTeacher) {
|
||
// 学员:先设置兜底的小组名(避免/group失败时显示"暂无小组")
|
||
if (res.my_group) {
|
||
this.setData({ groupName: res.my_group });
|
||
}
|
||
// 学员:加载本组成员列表
|
||
return this.loadGroup(res.my_group || '').then(() => {
|
||
this.setData({ loading: false });
|
||
});
|
||
}
|
||
|
||
this.setData({ loading: false });
|
||
}).catch(() => {
|
||
this.setData({ loading: false });
|
||
});
|
||
},
|
||
|
||
/** 加载指定小组的成员列表,不传则看自己小组 */
|
||
loadGroup(groupName) {
|
||
const data = { class_date: this.data.classDate };
|
||
if (groupName) {
|
||
data.group_name = groupName;
|
||
}
|
||
return api.request({ url: '/api/v1/checkin/group', data }).then((res) => {
|
||
const members = (res.members || []).map((m) => ({
|
||
...m,
|
||
shortName: (m.name || '?').charAt(0),
|
||
timeText: m.checked && m.record ? fmtTime(m.record.checked_at) : ''
|
||
}));
|
||
const checkedCount = members.filter((m) => m.checked).length;
|
||
this.setData({
|
||
members,
|
||
groupName: res.group_name || groupName || '',
|
||
checkedCount,
|
||
total: members.length
|
||
});
|
||
}).catch((err) => {
|
||
console.warn('加载小组成员失败:', err);
|
||
wx.showToast({ title: '加载小组成员失败', icon: 'none' });
|
||
});
|
||
},
|
||
|
||
/** 老师:打开小组详情 */
|
||
openGroup(e) {
|
||
const groupName = e.currentTarget.dataset.group;
|
||
if (!groupName) return;
|
||
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
|
||
this.loadGroup(groupName).then(() => {
|
||
this.setData({ loading: false });
|
||
});
|
||
},
|
||
|
||
/** 老师:返回总览 */
|
||
backToOverview() {
|
||
this.setData({ view: 'overview', selectedGroupName: '', members: [] });
|
||
},
|
||
|
||
/** 加载个人历史(第一页) */
|
||
loadHistory() {
|
||
this.setData({ historyPage: 1, historyFinished: false, historyLoading: true });
|
||
return this._loadHistoryPage(1, true).then(() => {
|
||
this.setData({ historyLoading: false });
|
||
}).catch(() => {
|
||
this.setData({ historyLoading: false });
|
||
});
|
||
},
|
||
|
||
/** 加载更多历史(翻页) */
|
||
loadMoreHistory() {
|
||
if (this.data.historyLoading || this.data.historyFinished) return;
|
||
const nextPage = this.data.historyPage + 1;
|
||
this.setData({ historyLoading: true });
|
||
return this._loadHistoryPage(nextPage, false).then(() => {
|
||
this.setData({ historyLoading: false, historyPage: nextPage });
|
||
}).catch(() => {
|
||
this.setData({ historyLoading: false });
|
||
});
|
||
},
|
||
|
||
_loadHistoryPage(page, replace) {
|
||
return api.request({ url: '/api/v1/checkin/list', data: { page, size: 20 } }).then((res) => {
|
||
const list = (res.list || []).map((item) => {
|
||
const color = STATUS_META[item.status] || '#C67B30';
|
||
return {
|
||
...item,
|
||
timeText: fmtTime(item.checked_at),
|
||
color,
|
||
bg: color + '1A'
|
||
};
|
||
});
|
||
const total = res.total || 0;
|
||
const history = replace ? list : this.data.history.concat(list);
|
||
const finished = history.length >= total;
|
||
this.setData({ history, historyTotal: total, historyFinished: finished });
|
||
});
|
||
},
|
||
|
||
|
||
onStatusTap(e) {
|
||
if (this.data.submitting) {
|
||
return;
|
||
}
|
||
const statusIndex = Number(e.currentTarget.dataset.index);
|
||
const status = STATUSES[statusIndex];
|
||
if (!status) {
|
||
return;
|
||
}
|
||
const prevIndex = this.data.statusIndex;
|
||
this.setData({ statusIndex, submitting: true });
|
||
api.request({
|
||
url: '/api/v1/checkin',
|
||
method: 'POST',
|
||
data: {
|
||
status,
|
||
class_date: this.data.classDate,
|
||
}
|
||
}).then((res) => {
|
||
const record = res.record || null;
|
||
const statusColor = record ? (STATUS_META[record.status] || '#C67B30') : '';
|
||
this.setData({ submitting: false, checked: true, record, statusColor });
|
||
wx.showToast({ title: '打卡成功', icon: 'success' });
|
||
// 学员:本地更新 members 中自己的记录,避免全量 reload
|
||
if (!this.data.isTeacher && this.data.members.length > 0) {
|
||
const myUserId = record && record.user_id;
|
||
if (myUserId) {
|
||
const members = this.data.members.map((m) => {
|
||
if (m.user_id === myUserId) {
|
||
return {
|
||
...m,
|
||
checked: true,
|
||
record: { status: record.status, checked_at: record.checked_at, note: record.note || '' },
|
||
timeText: fmtTime(record.checked_at),
|
||
};
|
||
}
|
||
return m;
|
||
});
|
||
const checkedCount = members.filter((m) => m.checked).length;
|
||
this.setData({ members, checkedCount });
|
||
}
|
||
}
|
||
// 老师视图:刷新小组统计(需要更新各小组计数)
|
||
if (this.data.isTeacher) {
|
||
this.loadWeek();
|
||
}
|
||
// 个人历史 tab:本地插入最新记录,避免全量 reload
|
||
if (!this.data.isTeacher && this.data.tab === 'personal' && record && this.data.history.length > 0) {
|
||
const color = STATUS_META[record.status] || '#C67B30';
|
||
const newItem = {
|
||
...record,
|
||
timeText: fmtTime(record.checked_at),
|
||
color,
|
||
bg: color + '1A',
|
||
};
|
||
// 检查是否已有同一天的记录(更新而非插入)
|
||
const existingIdx = this.data.history.findIndex((h) => h.class_date === record.class_date);
|
||
let history;
|
||
if (existingIdx >= 0) {
|
||
history = this.data.history.slice();
|
||
history[existingIdx] = newItem;
|
||
} else {
|
||
history = [newItem].concat(this.data.history);
|
||
}
|
||
const historyTotal = Math.max(this.data.historyTotal, this.data.historyTotal + (existingIdx >= 0 ? 0 : 1));
|
||
this.setData({ history, historyTotal });
|
||
} else if (!this.data.isTeacher && this.data.tab === 'personal') {
|
||
this.loadHistory();
|
||
}
|
||
}).catch((err) => {
|
||
this.setData({ submitting: false, statusIndex: prevIndex });
|
||
wx.showToast({ title: errMessage(err) || '打卡失败', icon: 'none' });
|
||
});
|
||
},
|
||
|
||
/** 学员 tab 切换 */
|
||
switchTab(e) {
|
||
const tab = e.currentTarget.dataset.tab;
|
||
if (tab !== 'group' && tab !== 'personal') {
|
||
return;
|
||
}
|
||
this.setData({ tab });
|
||
if (tab === 'personal') {
|
||
// 每次切到个人 tab 都刷新历史数据
|
||
this.loadHistory();
|
||
} else if (tab === 'group' && this.data.myGroup) {
|
||
// 每次切回小组 tab 都刷新小组成员数据
|
||
this.loadGroup(this.data.myGroup);
|
||
}
|
||
},
|
||
|
||
onRefresh() {
|
||
this.setData({ refreshing: true });
|
||
this.loadWeek().then(() => {
|
||
// 学员个人 tab 也刷新一下
|
||
if (!this.data.isTeacher && this.data.tab === 'personal') {
|
||
return this.loadHistory();
|
||
}
|
||
}).then(() => {
|
||
this.setData({ refreshing: false });
|
||
});
|
||
},
|
||
|
||
// ---- 分享 ----
|
||
onShareAppMessage() {
|
||
const checked = this.data.checked;
|
||
const status = this.data.record && this.data.record.status;
|
||
const title = checked
|
||
? '我今天' + status + '啦,一起在明缘云道场打卡吧'
|
||
: '明缘云道场 · 每日打卡,共同成长';
|
||
return {
|
||
title,
|
||
path: '/pages/checkin/checkin'
|
||
};
|
||
},
|
||
|
||
onShareTimeline() {
|
||
return {
|
||
title: '明缘云道场 · 每周打卡,共同进步',
|
||
query: 'from=timeline'
|
||
};
|
||
},
|
||
|
||
/** 上拉加载更多(个人历史 tab) */
|
||
onReachBottom() {
|
||
if (!this.data.isTeacher && this.data.tab === 'personal') {
|
||
this.loadMoreHistory();
|
||
}
|
||
},
|
||
}); |