- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
410 lines
14 KiB
JavaScript
410 lines
14 KiB
JavaScript
const api = require('../../utils/api');
|
||
const auth = require('../../utils/auth');
|
||
|
||
const MAX_IMAGES = 4;
|
||
const MAX_IMAGE_SIZE = 1024 * 1024; // 超过 1MB 才压缩
|
||
const COMPRESS_LEVELS = [80, 60, 45]; // 逐级降低质量,直到满足大小限制
|
||
const ALLOWED_IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'];
|
||
const TYPE_LABELS = { weekly: '周计划', monthly: '月计划', yearly: '年计划' };
|
||
|
||
function errDetail(err) {
|
||
if (err && err.detail) return String(err.detail);
|
||
if (err && err.error) return String(err.error);
|
||
if (err && err.message) return String(err.message);
|
||
return '';
|
||
}
|
||
|
||
function getFileSize(filePath) {
|
||
return new Promise((resolve) => {
|
||
wx.getFileInfo({
|
||
filePath,
|
||
success: (res) => resolve(Number(res.size) || 0),
|
||
fail: () => resolve(0)
|
||
});
|
||
});
|
||
}
|
||
|
||
function hasImageExt(path) {
|
||
if (!path) return false;
|
||
const ext = (String(path).split('.').pop() || '').toLowerCase();
|
||
return !!ext && ALLOWED_IMAGE_EXT.indexOf(ext) !== -1;
|
||
}
|
||
|
||
function baseName(path) {
|
||
const noQuery = String(path || '').split('?')[0];
|
||
const parts = noQuery.split('/');
|
||
return parts[parts.length - 1] || '';
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
planType: 'weekly',
|
||
typeLabel: '周计划',
|
||
periodKey: '',
|
||
groupName: '',
|
||
loading: true,
|
||
loadError: '',
|
||
canSubmit: true,
|
||
message: '',
|
||
content: '',
|
||
checked: false,
|
||
record: null,
|
||
attachments: [],
|
||
attachmentThumbs: [],
|
||
uploading: false,
|
||
submitting: false,
|
||
history: []
|
||
},
|
||
|
||
onLoad(options) {
|
||
const planType = options.plan_type || 'weekly';
|
||
this.setData({
|
||
planType,
|
||
typeLabel: TYPE_LABELS[planType] || '计划',
|
||
periodKey: options.period_key ? decodeURIComponent(options.period_key) : '',
|
||
groupName: options.group_name ? decodeURIComponent(options.group_name) : ''
|
||
});
|
||
auth.ensureLogin().then((token) => {
|
||
if (!token) {
|
||
this.setData({ loading: false, loadError: '请先在「我的」页面绑定手机号' });
|
||
setTimeout(() => wx.switchTab({ url: '/pages/profile/profile' }), 600);
|
||
return;
|
||
}
|
||
this.loadStatus();
|
||
}).catch(() => {
|
||
this.setData({ loading: false, loadError: '登录失败,请重试' });
|
||
});
|
||
},
|
||
|
||
onRetry() {
|
||
this.setData({ loading: true, loadError: '' });
|
||
auth.ensureLogin().then((token) => {
|
||
if (!token) {
|
||
this.setData({ loading: false, loadError: '请先在「我的」页面绑定手机号' });
|
||
setTimeout(() => wx.switchTab({ url: '/pages/profile/profile' }), 600);
|
||
return;
|
||
}
|
||
this.loadStatus();
|
||
}).catch(() => {
|
||
this.setData({ loading: false, loadError: '登录失败,请重试' });
|
||
});
|
||
},
|
||
|
||
loadStatus() {
|
||
api.request({
|
||
url: '/api/v1/plan/status',
|
||
data: { type: this.data.planType, period_key: this.data.periodKey }
|
||
}).then((res) => {
|
||
this.applyRecord(res);
|
||
if (this.data.planType !== 'weekly') {
|
||
this.loadHistory();
|
||
}
|
||
}).catch((err) => {
|
||
this.setData({ loading: false, loadError: errDetail(err) || '加载计划失败' });
|
||
});
|
||
},
|
||
|
||
applyRecord(res) {
|
||
const record = res.record;
|
||
const attachments = record ? (record.attachments || []) : [];
|
||
this.setData({
|
||
loading: false,
|
||
loadError: '',
|
||
periodKey: res.period_key || this.data.periodKey,
|
||
canSubmit: res.can_submit !== false,
|
||
message: res.message || '',
|
||
checked: !!res.checked,
|
||
record,
|
||
content: record ? (record.content || '') : '',
|
||
// attachments 是规范相对路径,提交打卡时会被原样发回后端,**不要**拿它做展示
|
||
attachments,
|
||
// 展示用签名地址,与 attachments 同长同序
|
||
attachmentThumbs: api.attachmentThumbs(record)
|
||
});
|
||
},
|
||
|
||
loadHistory() {
|
||
api.request({ url: '/api/v1/plan/history', data: { type: this.data.planType, page: 1, size: 20 } })
|
||
.then((res) => {
|
||
const history = (res.list || []).map((item) => ({
|
||
...item,
|
||
thumbSrcs: api.attachmentThumbs(item)
|
||
}));
|
||
this.setData({ history });
|
||
}).catch(() => {});
|
||
},
|
||
|
||
onContentInput(e) {
|
||
this.setData({ content: e.detail.value });
|
||
},
|
||
|
||
chooseImages() {
|
||
const remain = MAX_IMAGES - this.data.attachments.length;
|
||
if (remain <= 0) {
|
||
wx.showToast({ title: '最多上传4张图片', icon: 'none' });
|
||
return;
|
||
}
|
||
if (!this.data.canSubmit || !this.data.periodKey) {
|
||
wx.showToast({ title: '当前不可上传', icon: 'none' });
|
||
return;
|
||
}
|
||
wx.chooseMedia({
|
||
count: remain,
|
||
mediaType: ['image'],
|
||
sourceType: ['album', 'camera'],
|
||
success: (res) => {
|
||
const paths = (res.tempFiles || []).map((f) => f.tempFilePath);
|
||
this.uploadImages(paths);
|
||
}
|
||
});
|
||
},
|
||
|
||
// 把一张图压缩到 MAX_IMAGE_SIZE 以内,返回实际要上传的本地路径。
|
||
// 必须是独立方法:压缩会**换掉临时文件名**,而直传的签名是按目录签的,
|
||
// 真正的最终文件名只能在上传那一刻才确定(见 putToOss)。
|
||
prepareImage(src, level) {
|
||
return getFileSize(src).then((size) => {
|
||
if (!size || size <= MAX_IMAGE_SIZE || level >= COMPRESS_LEVELS.length) {
|
||
return src;
|
||
}
|
||
return new Promise((resolve) => {
|
||
wx.compressImage({
|
||
src,
|
||
quality: COMPRESS_LEVELS[level],
|
||
success: (res) => {
|
||
const compressed = res.tempFilePath || src;
|
||
if (!hasImageExt(compressed)) {
|
||
// 压缩产物扩展名丢失时回退当前图,避免按扩展名校验失败
|
||
resolve(src);
|
||
return;
|
||
}
|
||
resolve(this.prepareImage(compressed, level + 1));
|
||
},
|
||
fail: () => resolve(src)
|
||
});
|
||
});
|
||
});
|
||
},
|
||
|
||
// 直传 OSS:图片字节只走「小程序 → OSS」,服务器带宽为零。
|
||
// 缩略图直接用本地临时路径,省一次「取签名 URL」的请求 ——
|
||
// 它只在本次会话有效,页面重进后由 applyRecord 用后端的 attachment_urls 重建。
|
||
putToOss(src, sig) {
|
||
const filename = baseName(src);
|
||
if (!filename || !hasImageExt(filename)) {
|
||
return Promise.resolve({ error: '不支持的图片格式' });
|
||
}
|
||
const key = sig.key_prefix + filename;
|
||
return new Promise((resolve) => {
|
||
wx.uploadFile({
|
||
// 直传的地址是 OSS 域名,不是自家后端
|
||
url: sig.host,
|
||
filePath: src,
|
||
name: 'file',
|
||
// formData 的字段名由 OSS 约定,不能改名。
|
||
// `file` 必须排在最后 —— wx.uploadFile 的原生实现天然满足,无需手动控制顺序。
|
||
formData: Object.assign({}, sig.form_data, { key }),
|
||
timeout: 60000,
|
||
success: (res) => {
|
||
// PostObject 成功默认返回 204(没让 OSS 返回 body,省几百字节)
|
||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||
resolve({ relPath: sig.rel_prefix + filename, thumb: src });
|
||
return;
|
||
}
|
||
resolve({ error: '上传失败(' + res.statusCode + ')' });
|
||
},
|
||
fail: (err) => {
|
||
const msg = (err && err.errMsg) || '';
|
||
if (/url not in domain list|domain not/i.test(msg)) {
|
||
resolve({ error: 'OSS 域名未加入上传域名白名单' });
|
||
return;
|
||
}
|
||
resolve({ error: msg || '上传失败' });
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
uploadImages(paths) {
|
||
if (this.data.uploading || !paths.length) {
|
||
return;
|
||
}
|
||
const planType = this.data.planType;
|
||
const periodKey = this.data.periodKey;
|
||
let attachments = this.data.attachments.slice();
|
||
// 展示地址必须和 attachments 平行推进,不能在结尾用 attachments 现算:
|
||
// 新上传的图只在 OSS 上,光有相对路径拼不出可用地址
|
||
let thumbs = (this.data.attachmentThumbs || []).slice();
|
||
this.setData({ uploading: true });
|
||
|
||
// 先取一次直传签名。一份 policy 覆盖本次所有图片(它锁的是目录、不锁文件名)。
|
||
// 取不到签名(网络异常 / 后端异常 / 后端没配对象存储)就无法上传,直接报错。
|
||
api.request({
|
||
url: '/api/v1/plan/upload-signature',
|
||
method: 'POST',
|
||
data: { plan_type: planType, period_key: periodKey }
|
||
}).then((res) => {
|
||
if (!res || !res.success || !res.enabled) {
|
||
this.setData({ uploading: false });
|
||
wx.showToast({ title: '上传服务不可用,请稍后再试', icon: 'none' });
|
||
return;
|
||
}
|
||
const sig = res;
|
||
const run = (index) => {
|
||
if (index >= paths.length) {
|
||
// ⚠️ 不能直接 setData:上一个文件成功时刚在**同一个同步块**里写过
|
||
// setData({attachments, attachmentThumbs}),紧接着 run(下一个) 就走到这里,
|
||
// 两次更新会撞在一起(本项目的数据更新纪律:同一同步树内只发一次 setData)。
|
||
// 用 nextTick 错开一个 tick,UI 表现不变。
|
||
wx.nextTick(() => this.setData({ uploading: false, attachmentThumbs: thumbs }));
|
||
return;
|
||
}
|
||
// 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE
|
||
this.prepareImage(paths[index], 0).then((src) => {
|
||
this.putToOss(src, sig).then((item) => {
|
||
if (item && item.relPath) {
|
||
attachments = attachments.concat([item.relPath]).slice(0, MAX_IMAGES);
|
||
thumbs = thumbs.concat([item.thumb || src]).slice(0, MAX_IMAGES);
|
||
this.setData({ attachments, attachmentThumbs: thumbs });
|
||
} else {
|
||
wx.showToast({ title: (item && item.error) || '上传失败', icon: 'none' });
|
||
}
|
||
run(index + 1);
|
||
});
|
||
});
|
||
};
|
||
run(0);
|
||
}).catch(() => {
|
||
this.setData({ uploading: false });
|
||
wx.showToast({ title: '获取上传签名失败,请稍后再试', icon: 'none' });
|
||
});
|
||
},
|
||
|
||
removeImage(e) {
|
||
if (this.data.uploading) {
|
||
return;
|
||
}
|
||
const index = Number(e.currentTarget.dataset.index);
|
||
const path = this.data.attachments[index];
|
||
if (!path) {
|
||
return;
|
||
}
|
||
const parts = path.replace(/^attachments\//, '').split('/');
|
||
const dropLocal = () => {
|
||
const attachments = this.data.attachments.slice();
|
||
attachments.splice(index, 1);
|
||
// 展示数组与 attachments 同长同序,必须一起删;否则缩略图会和实际文件错位
|
||
const thumbs = (this.data.attachmentThumbs || []).slice();
|
||
thumbs.splice(index, 1);
|
||
// 顺带清掉这条的签名缓存:否则以后重传**同名**文件时会命中已删对象的旧地址
|
||
api.forgetSignedUrl(path);
|
||
this.setData({ attachments, attachmentThumbs: thumbs });
|
||
};
|
||
if (parts.length < 4) {
|
||
dropLocal();
|
||
return;
|
||
}
|
||
const sid = parts[0];
|
||
const planType = parts[1];
|
||
const period = parts[2];
|
||
const filename = parts[3];
|
||
api.request({
|
||
url: `/api/v1/plan/file/${sid}/${planType}/${period}/${encodeURIComponent(filename)}`,
|
||
method: 'DELETE'
|
||
}).then(dropLocal).catch(() => {
|
||
wx.showToast({ title: '删除失败', icon: 'none' });
|
||
});
|
||
},
|
||
|
||
previewImage(e) {
|
||
const index = Number(e.currentTarget.dataset.index);
|
||
const urls = this.data.attachmentThumbs;
|
||
const src = urls[index];
|
||
if (!src) {
|
||
return;
|
||
}
|
||
wx.previewImage({ current: src, urls });
|
||
},
|
||
|
||
previewHistoryImage(e) {
|
||
const historyId = e.currentTarget.dataset.id;
|
||
const index = Number(e.currentTarget.dataset.index);
|
||
const item = this.data.history.find((h) => String(h.id) === String(historyId));
|
||
if (!item) {
|
||
return;
|
||
}
|
||
const urls = item.thumbSrcs || [];
|
||
const src = urls[index];
|
||
if (!src) {
|
||
return;
|
||
}
|
||
wx.previewImage({ current: src, urls });
|
||
},
|
||
|
||
submit() {
|
||
if (this.data.submitting) {
|
||
return;
|
||
}
|
||
if (!this.data.canSubmit) {
|
||
wx.showToast({ title: this.data.message || '当前不可提交', icon: 'none' });
|
||
return;
|
||
}
|
||
const hasText = !!(this.data.content || '').trim();
|
||
const hasImages = !!this.data.attachments.length;
|
||
if (!hasText && !hasImages) {
|
||
wx.showToast({ title: '请填写内容或上传图片', icon: 'none' });
|
||
return;
|
||
}
|
||
const doPost = () => {
|
||
this.setData({ submitting: true });
|
||
api.request({
|
||
url: '/api/v1/plan/checkin',
|
||
method: 'POST',
|
||
data: {
|
||
plan_type: this.data.planType,
|
||
period_key: this.data.periodKey,
|
||
status: '已完成',
|
||
content: this.data.content,
|
||
attachments: this.data.attachments
|
||
}
|
||
}).then(() => {
|
||
this.setData({ submitting: false });
|
||
wx.showToast({ title: '已保存', icon: 'success' });
|
||
wx.setStorageSync('plan_overview_dirty', true);
|
||
setTimeout(() => wx.navigateBack(), 600);
|
||
}).catch((err) => {
|
||
this.setData({ submitting: false });
|
||
const message = errDetail(err) || '保存失败';
|
||
wx.showToast({ title: message, icon: 'none' });
|
||
});
|
||
};
|
||
if (this.data.checked || this.data.record) {
|
||
wx.showModal({
|
||
title: '覆盖确认',
|
||
content: '将覆盖本周期已提交的计划,确定保存?',
|
||
success: (r) => {
|
||
if (r.confirm) {
|
||
doPost();
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
doPost();
|
||
}
|
||
},
|
||
// ---- 分享 ----
|
||
onShareAppMessage() {
|
||
const typeLabel = this.data.typeLabel;
|
||
return {
|
||
title: '明缘云道场 · ' + typeLabel + '提交',
|
||
path: '/pages/plan/plan'
|
||
};
|
||
},
|
||
|
||
onShareTimeline() {
|
||
return {
|
||
title: '明缘云道场 · 周/月/年计划,让成长有迹可循',
|
||
query: 'from=timeline'
|
||
};
|
||
}
|
||
}); |