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