feat(api): 重构图片地址管理与上传机制

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

View File

@@ -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 {

View File

@@ -1,5 +1,23 @@
<view class="page">
<web-view src="{{webviewUrl}}" wx:if="{{webviewUrl && !webviewError}}" key="{{webviewKey}}" binderror="onWebviewError"></web-view>
<!-- 去上课浮窗:web-view 会自动铺满页面并覆盖其他组件(普通 view 加多大 z-index 都盖不住),
所以只能用 cover-view —— 它是原生组件,靠「后插入的原生组件覆盖先插入的」生效。
⚠️ 因此这个 wx:if 绑定的是 showFloat 而不是 webviewUrl:浮窗**必须比 web-view 晚一步挂载**
(见 course.js 的 _scheduleFloat)。web-view 上挂着 key,每次 onShow 都会销毁重建,
同帧渲染时重建后的 web-view 反压在浮窗上 —— 真机上表现为「浮窗不见了」。
开发者工具里则完全看不到覆盖效果,**只能真机验证**,别在工具里判断它是否生效。
⚠️ cover-view 不支持 box-shadow / background-image / 单边 border,样式只能写简单属性,
改样式时别加阴影,否则真机上静默不生效。只嵌套 cover-view / cover-image。 -->
<cover-view
class="class-float"
wx:if="{{showFloat}}"
hover-class="class-float-hover"
bindtap="onGoClass"
>
<cover-view class="class-float-text">去上课</cover-view>
</cover-view>
<view class="error-page" wx:if="{{webviewError}}">
<view class="error-icon">!</view>
<view class="error-title">页面加载失败</view>

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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 看全部

View File

@@ -137,12 +137,19 @@
<view class="empty-icon">📒</view>
<view class="empty-text">暂无{{types[typeIndex]}}记录</view>
</view>
<!-- 卡片直接铺内容:正文 3 行截断、缩略图最多 2 张;全部文字与图片在「查看详情」弹层里看 -->
<!-- 卡片直接铺内容:正文 3 行截断、缩略图最多 2 张;点卡片任意位置(文字或图片)
打开该条详情弹层,弹层里看全文全图。
注意:绑定只能在这层,卡片内不得再绑 openHistoryDetail —— 子节点再绑一次会先执行自己的
handler 再冒泡到卡片,同 tick 连发 2 次 setData 触发 recursive update detected。
缩略图故意不加 catchtap,让点击冒泡到卡片即打开弹层(放大预览在弹层内做)。 -->
<view
class="history-card"
wx:for="{{history}}"
wx:key="id"
wx:for-index="hi"
data-hi="{{hi}}"
hover-class="history-card-hover"
bindtap="openHistoryDetail"
>
<view class="history-head">
<view class="history-period">{{item.period_key}}</view>
@@ -161,7 +168,6 @@
mode="aspectFill"
></image>
</view>
<view class="tap-hint" data-hi="{{hi}}" hover-class="tap-hint-hover" bindtap="openHistoryDetail">查看详情 ›</view>
</view>
</view>
</block>

View File

@@ -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;

View File

@@ -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