- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
391 lines
13 KiB
JavaScript
391 lines
13 KiB
JavaScript
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: api.attachmentThumbs(res.my_record),
|
||
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) => {
|
||
// 附件转展示地址(列表已不渲染缩略图,allThumbs 全量留给详情弹层)
|
||
// 注意走 attachmentThumbs 而不是自己 map:后端返回的签名 URL 在 attachment_urls 里,
|
||
// 而 attachments 必须保持规范相对路径(提交打卡时会被原样回写)
|
||
const members = (res.members || []).map((m) => ({ ...m, allThumbs: api.attachmentThumbs(m.record) }));
|
||
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 = api.attachmentThumbs(item);
|
||
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'
|
||
};
|
||
}
|
||
});
|