Files
wechat-clock-mini/pages/plan/plan.js
王宏豪 ca21b1b9dc style(ui): 统一学生ID显示格式
- 将学号标签从"学号"改为"-"
- 在checkin页面统一学生ID显示格式
- 在plan页面统一学生ID显示格式
- 保持UI界面的一致性
2026-09-20 17:13:39 +08:00

392 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const api = require('../../utils/api');
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: {
// 类型切换相关
types: TYPE_LABELS,
typeIndex: 0,
// 视图模式:overview 总览 / detail 小组详情(仅老师)
view: 'overview',
// 加载状态
loading: false,
refreshing: false,
// 当前周期
periodKey: '',
// 提交相关
canSubmit: false,
message: '',
// 我的计划状态
myChecked: false,
myStatus: '',
myRecord: null,
myThumbs: [],
// 未分组人数(仅老师可见)
unassignedCount: 0,
// 小组列表
groups: [],
// 当前选中的小组
selectedGroupName: '',
// 小组成员列表(老师详情 & 学员小组 tab)
members: [],
// 用户角色
isTeacher: false,
myGroup: '',
// 学员视图 tab:group / personal
studentTab: 'group',
// 个人历史列表
history: [],
// 周期切换相关
periodList: [], // 可用周期列表
showPeriodPicker: false,
// 内容详情弹层(点击文章 / 图片后展示全文与全部图片)
contentDetail: null,
},
onShow() {
auth.ensureLogin().then((token) => {
if (!token) {
wx.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => wx.switchTab({ url: '/pages/profile/profile' }), 500);
return;
}
wx.removeStorageSync('plan_overview_dirty');
this.setData({ view: 'overview' });
// 先加载周期列表,再加载总览(总览接口会返回 is_teacher)
this.loadPeriods().then(() => {
this.loadOverview();
});
}).catch((err) => {
console.error('plan page init error:', err);
});
},
/** 切换计划类型(周/月/年) */
onTypeChange(e) {
const typeIndex = Number(e.currentTarget.dataset.index);
if (typeIndex === this.data.typeIndex) {
return;
}
this.setData({ typeIndex, view: 'overview', selectedGroupName: '', members: [], history: [], studentTab: 'group' });
// 切换类型后重新加载周期列表和总览
this.loadPeriods().then(() => {
this.loadOverview();
});
},
/** 下拉刷新 */
onRefresh() {
this.setData({ refreshing: true });
this.loadPeriods().then(() => {
return this.loadOverview();
}).then(() => {
// 学员个人 tab 也刷一下历史
if (!this.data.isTeacher && this.data.studentTab === 'personal') {
return this.loadHistory();
}
}).then(() => {
// refreshing 只在这里复位:它是 scroll-view refresher-triggered 的受控值
this.setData({ refreshing: false });
});
},
/** 加载可用周期列表 */
loadPeriods() {
const type = TYPES[this.data.typeIndex];
return api.request({ url: '/api/v1/plan/periods', data: { type } })
.then((res) => {
const periods = res.periods || [];
// 默认选第一个(最新)周期,或者后端返回的当前周期
let periodKey = periods[0] || res.current_period || '';
this.setData({
periodList: periods,
periodKey: periodKey || this.data.periodKey,
});
})
.catch(() => {
// 获取失败不影响主流程
console.warn('获取周期列表失败');
});
},
/** 打开周期选择器 */
openPeriodPicker() {
if (!this.data.periodList.length) {
return;
}
this.setData({ showPeriodPicker: true });
},
/** 关闭周期选择器 */
closePeriodPicker() {
this.setData({ showPeriodPicker: false });
},
/** 选择某个周期 */
onPeriodSelect(e) {
const period = e.currentTarget.dataset.period;
if (!period || period === this.data.periodKey) {
this.setData({ showPeriodPicker: false });
return;
}
// 详情判断要在 setData 之前取,视图切换与 loading 合并成同一次更新
const inDetail = this.data.view === 'detail';
this.setData({ periodKey: period, showPeriodPicker: false, loading: true });
if (inDetail) {
// 详情页:重新加载当前小组的该周期数据
this.loadDetail(true);
} else {
// 总览:老师看小组统计,学员看本组成员(loadOverview 内部会接着拉成员)
this.loadOverview(true);
}
},
/** 加载总览数据(silent=true 时不写 loading,由调用方合并到同一次 setData) */
loadOverview(silent) {
const type = TYPES[this.data.typeIndex];
const periodKey = this.data.periodKey;
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) => {
const percent = g.total ? Math.round(((g.checked_count || 0) / g.total) * 100) : 0;
return {
...g,
percent,
progressStyle: 'width:' + percent + '%;'
};
});
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: willLoadMembers,
periodKey: res.period_key || periodKey,
canSubmit: !!res.can_submit,
message: res.message || '',
myChecked: !!res.my_checked,
myStatus: res.my_status || '',
myRecord: res.my_record || null,
myThumbs: ((res.my_record && res.my_record.attachments) || []).map((p) => api.planFileUrl(p)),
unassignedCount: res.unassigned_count || 0,
isTeacher,
myGroup: res.my_group || '',
groups,
});
// 学员:如果在总览 + 小组 tab,加载本组成员(loading 已在上面一并写入)
if (willLoadMembers) {
return this.loadDetailForGroup(res.my_group, undefined, true);
}
})
.catch(() => {
this.setData({ loading: false });
wx.showToast({ title: '加载失败', icon: 'none' });
});
},
/** 打开小组详情 */
openGroup(e) {
const groupName = e.currentTarget.dataset.group;
if (!groupName) {
return;
}
// 视图切换与 loading 合并成同一次 setData,避免同一 tick 连发两次更新
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
this.loadDetail(true);
},
/** 学员直接查看自己小组(只有一个小组时点击卡片直接进) */
openMyGroup() {
const groupName = this.data.myGroup;
if (!groupName) {
return;
}
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
this.loadDetail(true);
},
/** 加载小组详情(silent=true 表示 loading 已由调用方写入) */
loadDetail(silent) {
const type = TYPES[this.data.typeIndex];
return this.loadDetailForGroup(this.data.selectedGroupName, type, silent);
},
/** 加载指定小组的成员数据(不改变视图状态) */
loadDetailForGroup(groupName, type, silent) {
type = type || TYPES[this.data.typeIndex];
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
});
}).catch(() => {
this.setData({ loading: false });
wx.showToast({ title: '加载成员失败', icon: 'none' });
});
},
/** 学员切换 tab */
switchStudentTab(e) {
const tab = e.currentTarget.dataset.tab;
if (tab !== 'group' && tab !== 'personal') return;
if (tab === 'personal') {
// 每次切到个人 tab 都刷新历史数据
this.setData({ studentTab: tab });
this.loadHistory();
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 });
}
},
/** 加载个人计划历史 */
loadHistory() {
const type = TYPES[this.data.typeIndex];
return api.request({
url: '/api/v1/plan/history',
data: { type, page: 1, size: 20 }
}).then((res) => {
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: [], loading: true });
this.loadOverview(true);
},
/** 点击某个成员的信息行 → 打开详情弹层 */
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];
if (!this.data.canSubmit) {
wx.showToast({ title: this.data.message || '当前不可提交', icon: 'none' });
return;
}
const params = [
'plan_type=' + type,
'period_key=' + encodeURIComponent(this.data.periodKey),
'group_name='
].join('&');
wx.navigateTo({ url: '/pages/plan-submit/plan-submit?' + params });
},
// ---- 分享 ----
onShareAppMessage() {
const type = this.data.types[this.data.typeIndex];
const checked = this.data.myChecked;
const title = checked
? '我的' + type + '已提交,来明缘云道场一起成长吧'
: '明缘云道场 · ' + type + '管理工具';
return {
title,
path: '/pages/plan/plan'
};
},
onShareTimeline() {
return {
title: '明缘云道场 · 周/月/年计划,让成长有迹可循',
query: 'from=timeline'
};
}
});