diff --git a/pages/course/course.js b/pages/course/course.js
index 310d242..0c44ebe 100644
--- a/pages/course/course.js
+++ b/pages/course/course.js
@@ -9,16 +9,30 @@ const auth = require('../../utils/auth');
const COURSE_URL = 'https://www.muguaclub.cn/courses/index.html';
+// 「去上课」浮窗的目标小程序(第三方,appId 由业务方提供)。
+// 只用 navigateToMiniProgram,不传 path → 落对方首页;若将来要直达某个页面,
+// 在这里补 path: '/pages/xxx/xxx'(以 / 开头)。
+// 白名单:app.json 的 navigateToMiniProgramAppIdList 已于 2020-04-24 取消(配置里若还留着反而报 appJSON 无效),
+// 所以不需要任何平台/工程配置。但下面两条绕不过去:
+// ① 必须由用户点击触发(基础库 2.3.0 起),所以在 onShow 里自动调会报
+// `navigateToMiniProgram:fail can only be invoked by user TAP gesture`;
+// ② 跳转前微信一定弹系统确认框,用户取消时 fail.errMsg 含 'cancel'。
+const CLASS_MINI_APP_ID = 'wxa3589f1193170aa3';
+
Page({
data: {
webviewUrl: '',
webviewError: false,
errorMsg: '',
- webviewKey: 0
+ webviewKey: 0,
+ // 「去上课」浮窗是否已挂载。**故意不跟 web-view 同帧渲染**,见 _scheduleFloat
+ showFloat: false
},
_webviewKey: 0,
_redirecting: false,
+ _jumping: false,
+ _floatTimer: null,
onShow() {
this.ensureAccess();
@@ -46,8 +60,9 @@ Page({
// 清掉可能残留的 web-view(如退出登录后切回本 tab),再切到「我的」
denyAccess() {
- if (this.data.webviewUrl) {
- this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' });
+ clearTimeout(this._floatTimer);
+ if (this.data.webviewUrl || this.data.showFloat) {
+ this.setData({ webviewUrl: '', webviewError: false, errorMsg: '', showFloat: false });
}
if (this._redirecting) {
return;
@@ -68,24 +83,72 @@ Page({
webviewUrl: COURSE_URL + '?_t=' + Date.now(),
webviewError: false,
errorMsg: '',
- webviewKey: this._webviewKey
+ webviewKey: this._webviewKey,
+ // 与上面的字段合并成同一次 setData,别拆开发(同同步树连发会撞 recursive update)
+ showFloat: false
});
+ this._scheduleFloat();
+ },
+
+ // 延迟一拍再把浮窗插进去。
+ //
+ // 为什么不能跟 web-view 同帧渲染:原生组件按「后插入的盖住先插入的」决定层级,
+ // 而 web-view 每次 onShow 都因 key 自增被销毁重建 —— 同帧渲染时重建后的 web-view
+ // 反而压在浮窗上,**真机上是「浮窗不见了」**(安卓默认如此,iOS 多数机型不出现;
+ // 开发者工具里则是完全看不到覆盖效果,无论怎么改都只能真机验证)。
+ // 等 web-view 挂载完(250ms)再 setData,浮窗就成了后插入的那一个。
+ _scheduleFloat() {
+ clearTimeout(this._floatTimer);
+ this._floatTimer = setTimeout(() => {
+ // 定时器可能落在 webviewError / 已切走的场景里,必须再判一次
+ if (this.data.webviewUrl && !this.data.webviewError) {
+ this.setData({ showFloat: true });
+ }
+ }, 250);
},
onWebviewError(e) {
+ // 加载失败时把浮窗收掉(错误页上不叠浮窗),同时取消待触发的显示定时器
+ clearTimeout(this._floatTimer);
this.setData({
webviewError: true,
- errorMsg: (e && e.detail && e.detail.errMsg) || '加载失败,请检查网络后重试'
+ errorMsg: (e && e.detail && e.detail.errMsg) || '加载失败,请检查网络后重试',
+ showFloat: false
});
},
onRetry() {
- this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' });
+ clearTimeout(this._floatTimer);
+ this.setData({ webviewUrl: '', webviewError: false, errorMsg: '', showFloat: false });
setTimeout(() => {
this.loadWebview();
}, 60);
},
+ // ---- 去上课:跳转到第三方授课小程序 ----
+ onGoClass() {
+ // 防重入:连点会连续弹系统确认框。complete 里复位(跳转成功时本页被挂起,
+ // 用户从对方小程序返回会走 onShow → 重新加载课程页,这是本页既有行为,不在这里动)
+ if (this._jumping) {
+ return;
+ }
+ this._jumping = true;
+ wx.navigateToMiniProgram({
+ appId: CLASS_MINI_APP_ID,
+ fail: (err) => {
+ const msg = (err && err.errMsg) || '';
+ // 用户点了取消,不是错误
+ if (msg.indexOf('cancel') >= 0) {
+ return;
+ }
+ wx.showToast({ title: '打开失败,请稍后再试', icon: 'none' });
+ },
+ complete: () => {
+ this._jumping = false;
+ }
+ });
+ },
+
// ---- 分享 ----
onShareAppMessage() {
return {
diff --git a/pages/course/course.wxml b/pages/course/course.wxml
index 3086f8c..313968d 100644
--- a/pages/course/course.wxml
+++ b/pages/course/course.wxml
@@ -1,5 +1,23 @@
+
+
+
+ 去上课
+
+
!
页面加载失败
diff --git a/pages/course/course.wxss b/pages/course/course.wxss
index f031b6e..5007532 100644
--- a/pages/course/course.wxss
+++ b/pages/course/course.wxss
@@ -49,3 +49,47 @@
.btn-hover {
opacity: 0.7;
}
+
+/* ---- 「去上课」浮窗 ----
+ 是 cover-view(原生组件),只能用基本定位/布局/文本样式:
+ 不支持 box-shadow、background-image、单边 border、overflow: visible。
+ 层次靠「主色底 + 浅描边」表达,别改成阴影 —— 真机上会静默不生效。
+ **2026-09-21 起改为「右侧垂直居中」**(原为右下角 bottom: 40rpx)。
+ 本页是 tabBar 页面,可视区已不含 tabBar,所以 top: 50% 直接就是「内容区正中」,
+ 不需要再为 tabBar 做偏移。
+ **2026-09-21 加大尺寸**(水平 padding 40 → 64 → 80 → **96**;垂直一直是 36,从未变过)。
+ 尺寸约 286×112rpx(≈143×56px),满足微信建议的点击热区 ≥ 88rpx。 */
+.class-float {
+ position: fixed;
+ right: 24rpx;
+ /* 垂直居中 = top 50% + 负 margin 抵消自身高度的一半。
+ ⚠️ 不要改成 transform: translateY(-50%) —— cover-view 对 transform 没有官方支持承诺,
+ 真机上一旦静默失效,按钮会掉回页面顶部(不是掉到中间,是掉到最上面)。
+ ⚠️ 不能同时保留 bottom:position: fixed 下 top/bottom 同时给出且 height 为 auto 时,
+ 盒子会被拉伸填满整个视口高度,视觉上变成一条竖条。
+ ⚠️ -56rpx 与按钮高度强耦合:高度 = padding 36×2 + 文本 30×1.2(=36) + 边框 2×2 = 112rpx,
+ 补偿值恒等于「-高度/2」。**只调水平 padding 不会影响它** —— 想调尺寸又不想碰这个坑时,
+ 优先只动水平方向(垂直方向一改就必须重算,否则垂直居中静默偏掉:
+ 不是报错,只是看着不太居中,很难肉眼发现)。
+ 验证脚本 `verify_course_float.js` 的 [7c] 会按上述算式自动推导并断言,漏改会直接红。
+ ⚠️ 宽度同理 = 文本 3字×30(=90) + padding 水平 96×2 + 边框 2×2 = 286rpx(≈屏宽 38%)。
+ 按钮越宽,横向压住课程列表的区域越大 —— 真机若发现挡住列表可点元素,优先收窄水平 padding。
+ **要调宽度只需改这一个数字**(水平 padding),不必动其他任何地方。 */
+ top: 50%;
+ margin-top: -56rpx;
+ z-index: 999;
+ padding: 36rpx 96rpx;
+ background: #C67B30;
+ border: 2rpx solid #E8C79A;
+ border-radius: 999rpx;
+}
+.class-float-text {
+ color: #FFFFFF;
+ font-size: 30rpx;
+ font-weight: 700;
+ line-height: 1.2;
+ text-align: center;
+}
+.class-float-hover {
+ background: #B06A26;
+}
diff --git a/pages/plan-submit/plan-submit.js b/pages/plan-submit/plan-submit.js
index 9804c5d..1498af7 100644
--- a/pages/plan-submit/plan-submit.js
+++ b/pages/plan-submit/plan-submit.js
@@ -30,6 +30,12 @@ function hasImageExt(path) {
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',
@@ -110,8 +116,10 @@ Page({
checked: !!res.checked,
record,
content: record ? (record.content || '') : '',
+ // attachments 是规范相对路径,提交打卡时会被原样发回后端,**不要**拿它做展示
attachments,
- attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
+ // 展示用签名地址,与 attachments 同长同序
+ attachmentThumbs: api.attachmentThumbs(record)
});
},
@@ -120,7 +128,7 @@ Page({
.then((res) => {
const history = (res.list || []).map((item) => ({
...item,
- thumbSrcs: (item.attachments || []).map((p) => api.planFileUrl(p))
+ thumbSrcs: api.attachmentThumbs(item)
}));
this.setData({ history });
}).catch(() => {});
@@ -151,6 +159,72 @@ Page({
});
},
+ // 把一张图压缩到 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;
@@ -158,73 +232,52 @@ Page({
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 });
- const run = (index) => {
- if (index >= paths.length) {
- this.setData({
- uploading: false,
- attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
- });
+
+ // 先取一次直传签名。一份 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 rawPath = paths[index];
- const doUpload = (src) => {
- wx.uploadFile({
- url: api.BASE_URL + '/api/v1/plan/upload',
- filePath: src,
- name: 'files',
- formData: { plan_type: planType, period_key: periodKey },
- header: { Authorization: 'Bearer ' + auth.getToken() },
- success: (res) => {
- let data = {};
- try {
- data = JSON.parse(res.data);
- } catch (e) {
- data = {};
- }
- if (data.success && Array.isArray(data.attachments)) {
- attachments = attachments.concat(data.attachments).slice(0, MAX_IMAGES);
- this.setData({
- attachments,
- attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
- });
+ 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: data.error || '上传失败', icon: 'none' });
+ wx.showToast({ title: (item && item.error) || '上传失败', icon: 'none' });
}
run(index + 1);
- },
- fail: () => {
- wx.showToast({ title: '上传失败', icon: 'none' });
- run(index + 1);
- }
- });
- };
- // 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE
- const tryCompress = (src, level) => {
- getFileSize(src).then((size) => {
- if (!size || size <= MAX_IMAGE_SIZE || level >= COMPRESS_LEVELS.length) {
- doUpload(src);
- return;
- }
- wx.compressImage({
- src,
- quality: COMPRESS_LEVELS[level],
- success: (res) => {
- const compressed = res.tempFilePath || src;
- if (!hasImageExt(compressed)) {
- // 压缩产物扩展名丢失时回退当前图,避免后端按扩展名校验失败
- doUpload(src);
- return;
- }
- tryCompress(compressed, level + 1);
- },
- fail: () => doUpload(src)
});
});
};
- tryCompress(rawPath, 0);
- };
- run(0);
+ run(0);
+ }).catch(() => {
+ this.setData({ uploading: false });
+ wx.showToast({ title: '获取上传签名失败,请稍后再试', icon: 'none' });
+ });
},
removeImage(e) {
@@ -240,10 +293,12 @@ Page({
const dropLocal = () => {
const attachments = this.data.attachments.slice();
attachments.splice(index, 1);
- this.setData({
- attachments,
- attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
- });
+ // 展示数组与 attachments 同长同序,必须一起删;否则缩略图会和实际文件错位
+ const thumbs = (this.data.attachmentThumbs || []).slice();
+ thumbs.splice(index, 1);
+ // 顺带清掉这条的签名缓存:否则以后重传**同名**文件时会命中已删对象的旧地址
+ api.forgetSignedUrl(path);
+ this.setData({ attachments, attachmentThumbs: thumbs });
};
if (parts.length < 4) {
dropLocal();
diff --git a/pages/plan/plan.js b/pages/plan/plan.js
index c19dd42..e3e1a9d 100644
--- a/pages/plan/plan.js
+++ b/pages/plan/plan.js
@@ -185,7 +185,7 @@ Page({
myChecked: !!res.my_checked,
myStatus: res.my_status || '',
myRecord: res.my_record || null,
- myThumbs: ((res.my_record && res.my_record.attachments) || []).map((p) => api.planFileUrl(p)),
+ myThumbs: api.attachmentThumbs(res.my_record),
unassignedCount: res.unassigned_count || 0,
isTeacher,
myGroup: res.my_group || '',
@@ -239,11 +239,10 @@ Page({
url: '/api/v1/plan/group',
data: { type, group_name: groupName, period_key: this.data.periodKey }
}).then((res) => {
- // 附件相对路径转完整 URL(列表已不渲染缩略图,allThumbs 全量留给详情弹层)
- const members = (res.members || []).map((m) => {
- const all = (((m.record || {}).attachments) || []).map((p) => api.planFileUrl(p));
- return { ...m, allThumbs: all };
- });
+ // 附件转展示地址(列表已不渲染缩略图,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,
@@ -284,7 +283,7 @@ Page({
data: { type, page: 1, size: 20 }
}).then((res) => {
const history = (res.list || []).map((item) => {
- const all = (item.attachments || []).map((p) => api.planFileUrl(p));
+ const all = api.attachmentThumbs(item);
return {
...item,
// 卡片里只铺前 2 张缩略图,弹层用 allThumbs 看全部
diff --git a/pages/plan/plan.wxml b/pages/plan/plan.wxml
index ffc3caf..9c521bd 100644
--- a/pages/plan/plan.wxml
+++ b/pages/plan/plan.wxml
@@ -137,12 +137,19 @@
📒
暂无{{types[typeIndex]}}记录
-
+
{{item.period_key}}
@@ -161,7 +168,6 @@
mode="aspectFill"
>
- 查看详情 ›
diff --git a/pages/plan/plan.wxss b/pages/plan/plan.wxss
index f1526fa..b40d8ec 100644
--- a/pages/plan/plan.wxss
+++ b/pages/plan/plan.wxss
@@ -140,7 +140,9 @@
padding: 24rpx;
margin-bottom: 18rpx;
box-shadow: 0 6rpx 20rpx rgba(44, 24, 16, 0.05);
+ transition: transform .15s ease, box-shadow .15s ease;
}
+.history-card-hover { transform: scale(.99); box-shadow: 0 10rpx 24rpx rgba(44, 24, 16, 0.10); }
.history-head {
display: flex;
align-items: center;
diff --git a/pages/profile/profile.js b/pages/profile/profile.js
index 65e460c..8e21e59 100644
--- a/pages/profile/profile.js
+++ b/pages/profile/profile.js
@@ -107,7 +107,10 @@ Page({
profile: res,
phoneError: '',
loadError: '',
- avatarSrc: this.fullUrl(res.wechat_avatar),
+ // 必须走 api.avatarUrl(内部先查本地签名缓存),不能直接取 res.avatar_url:
+ // 后端每次请求都现签一个新 URL,直接用会让地址每次进页面都变,
+ // 而微信是按 URL 缓存图片的 —— 等于每次重新下载头像。
+ avatarSrc: this.fullUrl(res),
avatarFallback: this.firstChar(displayName),
roleLabel: this.roleLabel(res.role),
loginTypeLabel: this.loginTypeLabel(res.login_type),
@@ -131,9 +134,13 @@ Page({
return LOGIN_TYPE_LABELS[type] || type || '手机号';
},
- fullUrl(rel) {
- if (!rel) return '';
- return /^https?:\/\//i.test(rel) ? rel : api.STATIC_BASE_URL + rel;
+ /**
+ * 头像转可显示地址。整个 payload 交给 api.avatarUrl,这样它既能查本地签名缓存,
+ * 也能在后端给了 avatar_url 时写回缓存 —— 与打卡附件走的是同一套逻辑。
+ * 兼容 avatars/{uid}/x 与 /clock/static/avatars/{uid}/x 两种历史格式。
+ */
+ fullUrl(payload) {
+ return api.avatarUrl(payload);
},
firstChar(str) {
@@ -162,7 +169,11 @@ Page({
}
if (data.success && data.wechat_avatar) {
const newAvatar = data.wechat_avatar;
- const newUrl = this.fullUrl(newAvatar);
+ // ⚠️ 换头像 = **key 没变、内容变了**。必须清掉缓存里的那一条,
+ // 否则下次进「我的」页会命中旧 URL —— 微信按 URL 缓存图片,用户会看到旧头像。
+ api.forgetSignedUrl(newAvatar);
+ // 本页立刻用后端刚签的地址显示(不走缓存),确保看到的就是新头像
+ const newUrl = data.avatar_url || this.fullUrl(data);
this.setData({
'profile.wechat_avatar': newAvatar,
avatarSrc: newUrl
diff --git a/utils/api.js b/utils/api.js
index a2780a2..3945e25 100644
--- a/utils/api.js
+++ b/utils/api.js
@@ -12,7 +12,8 @@ function _getBaseUrls() {
case 'develop':
// 开发版:本地服务
return {
- baseUrl: 'https://test.muguaclub.cn/clock',
+ //baseUrl: 'https://test.muguaclub.cn/clock',
+ baseUrl: 'http://localhost:8000',
staticUrl: 'https://test.muguaclub.cn',
h5Url: 'https://test.muguaclub.cn'
};
@@ -36,6 +37,8 @@ function _getBaseUrls() {
const _urls = _getBaseUrls();
const BASE_URL = _urls.baseUrl;
+// ⚠️ 图片**不再**用它拼地址了:图片只在 OSS 上,展示地址一律由后端签发
+// (attachment_urls / avatar_url)。保留它是因为它和 H5_BASE_URL 同属"部署域名"描述。
const STATIC_BASE_URL = _urls.staticUrl;
const H5_BASE_URL = _urls.h5Url;
@@ -109,16 +112,165 @@ function request(options) {
});
}
-/**
- * 把计划附件的相对路径转为完整的静态资源 URL。
- * 路径格式:attachments/{user_id}/{plan_type}/{period_key}/{filename}
- */
-function planFileUrl(path) {
- if (!path) return '';
- if (/^https?:\/\//i.test(path)) return path;
- const parts = String(path).replace(/^attachments\//, '').split('/');
- if (parts.length < 4) return '';
- return STATIC_BASE_URL + '/clock/static/plan/' + parts.slice(0, 4).map(encodeURIComponent).join('/');
+// ── 签名 URL 本地缓存 ─────────────────────────────────────────────────────
+// 为什么值得缓存:微信 是**按 URL** 缓存图片的。后端每次现签的 URL 里
+// Expires 都不同(= 现在 + 有效期),URL 一变,微信就当新图**重新下载整个文件**。
+// 把 URL 留在本地复用,同一张图在窗口期内才是同一个地址,图片缓存才真正生效。
+//
+// 判断失效**不需要后端额外给字段**:签名 URL 自带绝对过期的 `Expires` 参数,
+// 前端解析它就够了。
+
+const SIGN_CACHE_KEY = 'oss_signed_urls_v1';
+const SIGN_CACHE_MAX = 400; // 条数上限,超出时丢最早过期的
+const SIGN_CACHE_SKEW = 3600; // 提前 1 小时判过期,留出时钟偏差与请求耗时
+
+let _signCache = null;
+
+function _loadSignCache() {
+ if (_signCache) return _signCache;
+ let raw = null;
+ try {
+ raw = wx.getStorageSync(SIGN_CACHE_KEY);
+ } catch (e) {
+ raw = null;
+ }
+ _signCache = raw && typeof raw === 'object' ? raw : {};
+ return _signCache;
}
-module.exports = { request, planFileUrl, BASE_URL, STATIC_BASE_URL, H5_BASE_URL };
+function _saveSignCache() {
+ try {
+ wx.setStorageSync(SIGN_CACHE_KEY, _signCache || {});
+ } catch (e) {
+ // 写失败(配额满等)不影响展示,只是下次还得重新签
+ }
+}
+
+/** 签名 URL 里的绝对过期时间戳(秒);解析不到返回 0(当作不可用)。 */
+function _expiresAt(url) {
+ const m = /[?&]Expires=(\d+)/.exec(String(url || ''));
+ return m ? Number(m[1]) : 0;
+}
+
+function _isFresh(url) {
+ const exp = _expiresAt(url);
+ return !!exp && Date.now() / 1000 + SIGN_CACHE_SKEW < exp;
+}
+
+function _pruneSignCache(cache) {
+ const keys = Object.keys(cache);
+ if (keys.length <= SIGN_CACHE_MAX) return;
+ keys.sort((a, b) => _expiresAt(cache[a]) - _expiresAt(cache[b]));
+ keys.slice(0, keys.length - SIGN_CACHE_MAX).forEach((k) => { delete cache[k]; });
+}
+
+/**
+ * 取一个相对路径的展示地址。
+ * @param {string} relPath 规范相对路径(attachments/…);外链原样返回
+ * @param {string} freshUrl 后端本次随记录返回的签名 URL(可能为空串)
+ *
+ * 顺序:**缓存未过期就用缓存**(这是 URL 能稳定的关键),否则用 freshUrl 并写回缓存;
+ * 两者都不能用则返回空串 —— 调用方当作「这张图不显示」。**没有静态地址可退**:
+ * 图片只在 OSS 上,nginx 那条路已经拆掉了。
+ */
+function signedOrFresh(relPath, freshUrl) {
+ if (!relPath) return '';
+ if (/^https?:\/\//i.test(relPath)) return relPath;
+
+ const cache = _loadSignCache();
+ const hit = cache[relPath];
+ if (hit && _isFresh(hit)) return hit;
+
+ const fresh = String(freshUrl || '');
+ if (_isFresh(fresh)) {
+ cache[relPath] = fresh;
+ _pruneSignCache(cache);
+ _saveSignCache();
+ return fresh;
+ }
+
+ // ⚠️ 「解析不出过期时间」≠「这个 URL 不能用」。后端**刚签出来**的地址必然有效,
+ // 只是格式我们不认识(例如签名版本切到 V4 后参数名会变成 x-oss-expires)。
+ // 所以这里必须仍然用它 —— 直接退回静态地址,在 OSS 化之后就是**裂图**。
+ if (fresh) return fresh;
+
+ // 缓存已过期、后端这次也没给签名:清掉过期项,交给调用方降级
+ if (hit) {
+ delete cache[relPath];
+ _saveSignCache();
+ }
+ return '';
+}
+
+/**
+ * 删掉某一条缓存(不动其它)。
+ *
+ * 两个**必须**调用它的时机,漏了都会出现"显示旧图":
+ * · 换了头像:key 没变、内容变了。不清掉就会一直沿用旧 URL,
+ * 而微信是按 URL 缓存图片的 → 用户看到旧头像。
+ * · 删了附件:之后若重传同名文件,会命中已删对象的旧地址。
+ *
+ * 清掉之后,本函数不需要自己写新值 —— 下一次 `signedOrFresh` 拿到后端给的新签名
+ * 会自然写回去。
+ */
+function forgetSignedUrl(relPath) {
+ const key = String(relPath || '').trim();
+ if (!key) return;
+ const cache = _loadSignCache();
+ if (Object.prototype.hasOwnProperty.call(cache, key)) {
+ delete cache[key];
+ _saveSignCache();
+ }
+}
+
+/** 退出登录 / 换账号时调用,避免把上一个人的图片地址留给下一个人。 */
+function clearSignedCache() {
+ _signCache = {};
+ try {
+ wx.removeStorageSync(SIGN_CACHE_KEY);
+ } catch (e) {
+ // ignore
+ }
+}
+
+/**
+ * 取一条打卡记录的附件展示地址。
+ *
+ * 后端并排给两个字段,**长度与顺序严格一一对应**:
+ * attachments 规范相对路径。会被原样提交回 /plan/checkin,所以**不能拿来展示**,
+ * 否则一保存就把带签名的地址写进数据库了。
+ * attachment_urls OSS 签名 URL,展示专用;OSS 未启用时全是空串。
+ *
+ * 取值顺序:本地签名缓存 → 后端这次的签名 → 外链原样 → **空串**。
+ * 空串表示那张图不显示。**不再退回 nginx 静态地址**:图片只在 OSS 上,静态地址必然
+ * 404,退过去只会多出一张裂图,反而把「OSS 没配对」这个真问题掩盖掉。
+ */
+function attachmentThumbs(record) {
+ const rec = record || {};
+ const canon = rec.attachments || [];
+ const urls = rec.attachment_urls || [];
+ // signedOrFresh 内部先查本地缓存(URL 稳定 → 微信图片缓存才命中),再兜后端这次的
+ return canon.map((path, i) => signedOrFresh(path, urls[i]));
+}
+
+/**
+ * 头像展示地址。
+ *
+ * 缓存与打卡附件共用同一套:缓存 key 用的是库里的原值,所以新旧两种历史格式各自成键,
+ * 但都指向同一个 OSS 对象:
+ * avatars/{uid}/avatar.jpg 新格式(等于对象 key)
+ * /clock/static/avatars/{uid}/avatar.jpg 旧格式,库里仍有存量值
+ * 微信头像(https://wx.qlogo.cn/...)是外链,原样返回。
+ * 都取不到就是空串,**不退回 nginx 静态地址**(理由同 attachmentThumbs)。
+ */
+function avatarUrl(payload) {
+ const data = payload || {};
+ const path = data.wechat_avatar || '';
+ if (/^https?:\/\//i.test(path)) return path;
+ return signedOrFresh(path, data.avatar_url);
+}
+
+module.exports = {
+ request, attachmentThumbs, avatarUrl, forgetSignedUrl, clearSignedCache,
+ BASE_URL, STATIC_BASE_URL, H5_BASE_URL
+};
diff --git a/utils/auth.js b/utils/auth.js
index 4deb415..4479a5e 100644
--- a/utils/auth.js
+++ b/utils/auth.js
@@ -12,6 +12,8 @@ function setToken(token) {
function clearToken() {
wx.removeStorageSync('token');
+ // 顺带清掉签名 URL 缓存:换账号后不该复用上一个人的图片地址
+ api.clearSignedCache();
}
function wxLogin(retries) {
diff --git a/utils/oss-upload.js b/utils/oss-upload.js
deleted file mode 100644
index fa4cccd..0000000
--- a/utils/oss-upload.js
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * 阿里云 OSS 上传封装(demo)
- *
- * 提供两种模式,二选一即可:
- * · uploadDirect() 模式B:向后端取签名 → 小程序直接传 OSS(省服务器带宽,推荐)
- * · uploadViaServer() 模式A:文件先传后端,由后端转存 OSS(改动最小,兼容现有流程)
- *
- * 依赖:utils/api.js 里的 request / BASE_URL
- *
- * ⚠️ 用模式B之前,必须先把 OSS 域名加进微信后台「上传域名(uploadFile 合法域名)」,
- * 否则真机上会直接 fail(errMsg: url not in domain list)。
- */
-
-const api = require('./api');
-
-const SIGN_API = '/api/v1/oss-demo/signature';
-const CONFIRM_API = '/api/v1/oss-demo/confirm';
-const SERVER_UPLOAD_API = '/api/v1/oss-demo/upload';
-
-/** 支持的扩展名(与后端白名单保持一致) */
-const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'];
-/** 单文件上限,需与后端 OSS_MAX_SIZE 一致,前端只是提前拦截省流量 */
-const MAX_FILE_SIZE = 10 * 1024 * 1024;
-
-function extOf(path) {
- const m = /\.([a-zA-Z0-9]+)(?:\?.*)?$/.exec(path || '');
- return m ? m[1].toLowerCase() : '';
-}
-
-function baseNameOf(path) {
- const noQuery = String(path || '').split('?')[0];
- const parts = noQuery.split('/');
- return parts[parts.length - 1] || 'upload.jpg';
-}
-
-/**
- * 前端先做一次类型/大小拦截,避免无谓的上传流量。
- * @param {string} filePath
- * @param {number} [size] wx.chooseMedia 返回的 size(字节),不传则跳过体积校验
- */
-function checkFile(filePath, size) {
- const ext = extOf(filePath);
- if (ALLOWED_EXT.indexOf(ext) === -1) {
- return { ok: false, msg: '仅支持 jpg/png/gif/webp/bmp 图片' };
- }
- if (size && size > MAX_FILE_SIZE) {
- return { ok: false, msg: '单张图片不能超过 ' + Math.round(MAX_FILE_SIZE / 1024 / 1024) + 'MB' };
- }
- return { ok: true };
-}
-
-/**
- * 模式B:取签名 → 直传 OSS → 回执后端
- * @param {string} filePath wx.chooseMedia 返回的 tempFilePath
- * @param {object} options { planType, periodKey, filename, onProgress }
- * @returns {Promise<{key:string,url:string,signedUrl:string}>}
- */
-function uploadDirect(filePath, options) {
- const opts = options || {};
- const filename = opts.filename || baseNameOf(filePath);
-
- const guard = checkFile(filePath);
- if (!guard.ok) return Promise.reject({ error: guard.msg });
-
- const query = [
- 'plan_type=' + encodeURIComponent(opts.planType || ''),
- 'period_key=' + encodeURIComponent(opts.periodKey || ''),
- 'filename=' + encodeURIComponent(filename)
- ].join('&');
-
- return api.request({ url: SIGN_API + '?' + query }).then((sign) => {
- if (!sign || !sign.success) {
- return Promise.reject({ error: (sign && sign.error) || '获取上传签名失败' });
- }
-
- // 注意:formData 的字段名由 OSS 约定,不能改名;
- // `key` 决定对象落到哪个路径;`file` 必须是最后一个表单域(原生实现已保证)。
- const formData = Object.assign({}, sign.form_data, {
- key: sign.key,
- 'x-oss-content-type': sign.content_type || 'image/jpeg'
- });
-
- return new Promise((resolve, reject) => {
- const task = wx.uploadFile({
- // 直传的地址是 OSS Bucket 域名,不是自家后端
- url: sign.host,
- filePath,
- name: 'file',
- formData,
- timeout: 60000,
- success(res) {
- // success_action_status=200 时成功即 200,返回体为空
- if (res.statusCode === 200 || res.statusCode === 204) {
- resolve({ key: sign.key, host: sign.host });
- } else {
- reject({ error: '上传 OSS 失败(' + res.statusCode + ')', raw: res.data });
- }
- },
- fail(err) {
- const msg = (err && err.errMsg) || '';
- if (/url not in domain list|domain not/i.test(msg)) {
- reject({ error: 'OSS 域名未加入小程序上传域名白名单' });
- return;
- }
- reject({ error: msg || '上传失败' });
- }
- });
-
- if (opts.onProgress && task && task.onProgressUpdate) {
- task.onProgressUpdate((p) => opts.onProgress(p.progress));
- }
- });
- }).then((uploaded) => {
- // 直传时后端不知情,必须回执一次:后端会校验对象确实存在,并写业务表
- return api.request({
- url: CONFIRM_API,
- method: 'POST',
- data: { key: uploaded.key, size: 0 }
- }).then((res) => ({
- key: uploaded.key,
- url: (res && res.url) || '',
- signedUrl: (res && res.signed_url) || ''
- }));
- });
-}
-
-/**
- * 模式B-批量:顺序上传(串行避免并发把弱网打爆)
- * @returns {Promise>}
- */
-function uploadDirectBatch(filePaths, options) {
- const results = [];
- return filePaths.reduce((chain, path) => {
- return chain.then(() => uploadDirect(path, options)).then((r) => {
- results.push(r);
- if (options && options.onItemDone) options.onItemDone(r, results.length);
- });
- }, Promise.resolve()).then(() => results);
-}
-
-/**
- * 模式A:走自家后端中转(与现有 plan-submit.js 的写法一致)
- * 好处是不用配 OSS 域名白名单,改造时把 url 从 /plan/upload 换成本接口即可。
- */
-function uploadViaServer(filePath, options) {
- const opts = options || {};
- const token = wx.getStorageSync('token');
-
- return new Promise((resolve, reject) => {
- wx.uploadFile({
- url: api.BASE_URL + SERVER_UPLOAD_API,
- filePath,
- name: 'files',
- header: token ? { Authorization: 'Bearer ' + token } : {},
- formData: {
- plan_type: opts.planType || 'weekly',
- period_key: opts.periodKey || ''
- },
- success(res) {
- let data = {};
- try {
- data = JSON.parse(res.data);
- } catch (e) {
- reject({ error: '返回格式异常' });
- return;
- }
- if (res.statusCode >= 200 && res.statusCode < 300 && data.success) {
- resolve({ key: (data.attachments || [])[0] || '', items: data.items || [] });
- } else {
- reject({ error: data.detail || data.error || '上传失败' });
- }
- },
- fail: reject
- });
- });
-}
-
-/**
- * 私有 Bucket 取临时可读 URL(给 用)
- * key 必须是 attachments/{userId}/... 形式
- */
-function getFileUrl(key) {
- return api.request({
- url: '/api/v1/oss-demo/file-url?key=' + encodeURIComponent(key)
- }).then((res) => (res && res.url) || '');
-}
-
-module.exports = {
- uploadDirect,
- uploadDirectBatch,
- uploadViaServer,
- getFileUrl,
- ALLOWED_EXT
-};