feat(app): 添加课程页面并优化打卡和计划功能

- 新增 pages/course/course 页面,集成 web-view 显示线上课程列表
- 在 app.json 中注册课程页面并添加底部 tabbar 导航
- 优化打卡页面,添加学号显示和改进成员列表布局
- 重构计划页面,添加内容详情弹层和优化加载逻辑
- 添加阿里云 OSS 上传工具模块用于文件上传功能
- 统一姓名和学号在同一行显示,改善 UI 体验
- 修复多个页面中的绑定事件和样式问题
This commit is contained in:
王宏豪
2026-09-20 16:53:36 +08:00
parent 01a7f4ab2d
commit ee3abed69f
13 changed files with 701 additions and 81 deletions

View File

@@ -5,6 +5,16 @@ const auth = require('../../utils/auth');
const TYPES = ['weekly', 'monthly', 'yearly'];
const TYPE_LABELS = ['周计划', '月计划', '年计划'];
// 个人历史卡片里最多展示的缩略图数量,其余图片在「查看详情」弹层里看全部
const HISTORY_THUMB_LIMIT = 2;
// 说明:本页所有「切换状态 + 紧跟一次加载」的流程,都要求把 loading 合并进
// 调用方那一次 setData(加载函数传 silent=true),不要在同一个 tick 里连发两次
// setData —— 否则会触发 "recursive update detected: a data update is applying
// during another data update" 警告(数据应用到 WXML 是同步过程,见官方文档
// 「高级数据更新方法 / 递归更新问题」)。
// 同理,refreshing 只由 onRefresh 写:它绑定的是 scroll-view 的
// refresher-triggered(受控属性),别的流程顺手写它容易与组件自身状态互相回写。
Page({
data: {
// 类型切换相关
@@ -43,6 +53,8 @@ Page({
// 周期切换相关
periodList: [], // 可用周期列表
showPeriodPicker: false,
// 内容详情弹层(点击文章 / 图片后展示全文与全部图片)
contentDetail: null,
},
onShow() {
@@ -87,6 +99,7 @@ Page({
return this.loadHistory();
}
}).then(() => {
// refreshing 只在这里复位:它是 scroll-view refresher-triggered 的受控值
this.setData({ refreshing: false });
});
},
@@ -130,27 +143,25 @@ Page({
this.setData({ showPeriodPicker: false });
return;
}
this.setData({
periodKey: period,
showPeriodPicker: false,
});
if (this.data.view === 'detail') {
// 详情判断要在 setData 之前取,视图切换与 loading 合并成同一次更新
const inDetail = this.data.view === 'detail';
this.setData({ periodKey: period, showPeriodPicker: false, loading: true });
if (inDetail) {
// 详情页:重新加载当前小组的该周期数据
this.loadDetail();
} else if (!this.data.isTeacher && this.data.studentTab === 'group' && this.data.myGroup) {
// 学员总览 + 小组 tab:刷新本组成员
this.loadOverview();
this.loadDetail(true);
} else {
// 老师总览 / 学员个人 tab:重新加载总览
this.loadOverview();
// 总览:老师看小组统计,学员看本组成员(loadOverview 内部会接着拉成员)
this.loadOverview(true);
}
},
/** 加载总览数据 */
loadOverview() {
/** 加载总览数据(silent=true 时不写 loading,由调用方合并到同一次 setData) */
loadOverview(silent) {
const type = TYPES[this.data.typeIndex];
const periodKey = this.data.periodKey;
this.setData({ loading: true });
if (!silent) {
this.setData({ loading: true });
}
return api.request({ url: '/api/v1/plan/groups', data: { type, period_key: periodKey } })
.then((res) => {
const groups = (res.groups || []).map((g) => {
@@ -162,9 +173,12 @@ Page({
};
});
const isTeacher = !!res.is_teacher;
// 学员总览的「小组」tab 紧接着还要拉一次本组成员:把 loading 直接定成
// 最终值,避免「先置 false 再置 true」的连续更新,也避免中间态闪出空列表
const willLoadMembers = !isTeacher && this.data.view === 'overview'
&& this.data.studentTab === 'group' && !!res.my_group;
this.setData({
loading: false,
refreshing: false,
loading: willLoadMembers,
periodKey: res.period_key || periodKey,
canSubmit: !!res.can_submit,
message: res.message || '',
@@ -177,13 +191,13 @@ Page({
myGroup: res.my_group || '',
groups,
});
// 学员:如果在总览 + 小组 tab,加载本组成员
if (!isTeacher && this.data.view === 'overview' && this.data.studentTab === 'group' && res.my_group) {
return this.loadDetailForGroup(res.my_group);
// 学员:如果在总览 + 小组 tab,加载本组成员(loading 已在上面一并写入)
if (willLoadMembers) {
return this.loadDetailForGroup(res.my_group, undefined, true);
}
})
.catch(() => {
this.setData({ loading: false, refreshing: false });
this.setData({ loading: false });
wx.showToast({ title: '加载失败', icon: 'none' });
});
},
@@ -194,8 +208,9 @@ Page({
if (!groupName) {
return;
}
this.setData({ selectedGroupName: groupName, view: 'detail', members: [] });
this.loadDetail();
// 视图切换与 loading 合并成同一次 setData,避免同一 tick 连发两次更新
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
this.loadDetail(true);
},
/** 学员直接查看自己小组(只有一个小组时点击卡片直接进) */
@@ -204,30 +219,37 @@ Page({
if (!groupName) {
return;
}
this.setData({ selectedGroupName: groupName, view: 'detail', members: [] });
this.loadDetail();
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
this.loadDetail(true);
},
/** 加载小组详情 */
loadDetail() {
/** 加载小组详情(silent=true 表示 loading 已由调用方写入) */
loadDetail(silent) {
const type = TYPES[this.data.typeIndex];
return this.loadDetailForGroup(this.data.selectedGroupName, type);
return this.loadDetailForGroup(this.data.selectedGroupName, type, silent);
},
/** 加载指定小组的成员数据(不改变视图状态) */
loadDetailForGroup(groupName, type) {
loadDetailForGroup(groupName, type, silent) {
type = type || TYPES[this.data.typeIndex];
this.setData({ loading: true });
if (!silent) {
this.setData({ loading: true });
}
return api.request({
url: '/api/v1/plan/group',
data: { type, group_name: groupName, period_key: this.data.periodKey }
}).then((res) => {
// 附件相对路径转完整 URL(列表已不渲染缩略图,allThumbs 全量留给详情弹层)
const members = (res.members || []).map((m) => {
const all = (((m.record || {}).attachments) || []).map((p) => api.planFileUrl(p));
return { ...m, allThumbs: all };
});
this.setData({
loading: false,
periodKey: res.period_key || this.data.periodKey,
canSubmit: !!res.can_submit,
message: res.message || '',
members: res.members || []
members
});
}).catch(() => {
this.setData({ loading: false });
@@ -239,13 +261,18 @@ Page({
switchStudentTab(e) {
const tab = e.currentTarget.dataset.tab;
if (tab !== 'group' && tab !== 'personal') return;
this.setData({ studentTab: tab });
if (tab === 'personal') {
// 每次切到个人 tab 都刷新历史数据
this.setData({ studentTab: tab });
this.loadHistory();
} else if (tab === 'group' && this.data.myGroup) {
// 每次切回小组 tab 都刷新小组成员数据
this.loadDetailForGroup(this.data.myGroup);
return;
}
if (this.data.myGroup) {
// 每次切回小组 tab 都刷新小组成员数据;loading 合并进这次 setData
this.setData({ studentTab: tab, loading: true });
this.loadDetailForGroup(this.data.myGroup, undefined, true);
} else {
this.setData({ studentTab: tab });
}
},
@@ -256,31 +283,76 @@ Page({
url: '/api/v1/plan/history',
data: { type, page: 1, size: 20 }
}).then((res) => {
const history = (res.list || []).map((item) => ({
...item,
thumbs: (item.attachments || []).map((p) => api.planFileUrl(p)).slice(0, 3),
statusColor: item.status === '未完成' ? '#C67B30' : '#2E7D32',
statusBg: item.status === '未完成' ? '#FFF3E6' : '#E8F4EB',
}));
const history = (res.list || []).map((item) => {
const all = (item.attachments || []).map((p) => api.planFileUrl(p));
return {
...item,
// 卡片里只铺前 2 张缩略图,弹层用 allThumbs 看全部
thumbs: all.slice(0, HISTORY_THUMB_LIMIT),
allThumbs: all,
statusColor: item.status === '未完成' ? '#C67B30' : '#2E7D32',
statusBg: item.status === '未完成' ? '#FFF3E6' : '#E8F4EB',
};
});
this.setData({ history });
}).catch(() => {});
},
/** 返回总览 */
backToOverview() {
this.setData({ view: 'overview', selectedGroupName: '', members: [] });
this.loadOverview();
this.setData({ view: 'overview', selectedGroupName: '', members: [], loading: true });
this.loadOverview(true);
},
/** 预览我的图片 */
previewMyImage(e) {
const index = Number(e.currentTarget.dataset.index);
const urls = this.data.myThumbs;
const src = urls[index];
if (!src) return;
wx.previewImage({ current: src, urls });
/** 点击某个成员的信息行 → 打开详情弹层 */
openMemberDetail(e) {
const mi = Number(e.currentTarget.dataset.mi);
const m = this.data.members[mi];
if (!m || !m.record) return;
const rec = m.record || {};
this.setData({
contentDetail: {
title: m.name || '',
sub: m.student_id ? '学号 ' + m.student_id : '',
meta: [this.data.periodKey, rec.checked_at ? rec.checked_at + ' 提交' : ''].filter(Boolean).join(' · '),
content: rec.content || '',
thumbs: m.allThumbs || [],
},
});
},
/** 点击我的一条历史记录 → 打开详情弹层 */
openHistoryDetail(e) {
const hi = Number(e.currentTarget.dataset.hi);
const item = this.data.history[hi];
if (!item) return;
this.setData({
contentDetail: {
title: item.period_key || '',
sub: item.status || '',
meta: item.checked_at ? item.checked_at + ' 提交' : '',
content: item.content || '',
thumbs: item.allThumbs || [],
},
});
},
/** 关闭内容详情弹层 */
closeContentDetail() {
this.setData({ contentDetail: null });
},
/** 在详情弹层内放大某张图片 */
previewDetailImage(e) {
const ci = Number(e.currentTarget.dataset.ci) || 0;
const urls = (this.data.contentDetail && this.data.contentDetail.thumbs) || [];
if (!urls.length) return;
wx.previewImage({ current: urls[ci] || urls[0], urls });
},
/** 占位:遮罩层用 catchtouchmove 阻止滚动穿透 */
noop() {},
/** 去提交计划 */
goSubmit() {
const type = TYPES[this.data.typeIndex];
@@ -316,4 +388,4 @@ Page({
query: 'from=timeline'
};
}
});
});