Files
wechat-clock-mini/pages/plan/plan.js
王宏豪 a76696c37b feat(api): 修改oss
- 替换开发环境API基础URL为本地服务地址
- 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题
- 添加缓存清理功能,包括过期清理和按需删除特定缓存项
- 集成阿里云OSS上传功能,支持直传减少服务器带宽占用
- 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化
- 移除过时的OSS上传示例文件oss-upload.js
- 更新课程页面实现跳转第三方小程序功能
- 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑
- 优化图片上传流程,增加压缩处理和批量上传支持
- 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存
- 添加后端环境变量配置模板和代理文档说明
2026-10-10 10:26:59 +08:00

396 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 = ['周计划', '月计划', '年计划'];
// 说明:本页所有「切换状态 + 紧跟一次加载」的流程,都要求把 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;
}
// 从提交/修改页返回(plan-submit 保存成功后写入 dirty 标记)时,除了刷新总览,
// 还要刷新「个人」tab 的历史列表:否则停留在个人 tab 时,刚提交/修改的内容
// 不会回显,列表会一直显示提交前的旧数据。
const dirty = !!wx.getStorageSync('plan_overview_dirty');
wx.removeStorageSync('plan_overview_dirty');
this.setData({ view: 'overview' });
// 先加载周期列表,再加载总览(总览接口会返回 is_teacher)
this.loadPeriods().then(() => {
return this.loadOverview();
}).then(() => {
if (dirty && !this.data.isTeacher) {
return this.loadHistory();
}
});
}).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,
// 卡片直接铺全部缩略图;allThumbs 仍保留给详情弹层(现在两者一致)
thumbs: all,
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'
};
}
});