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' }; } });