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

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