Compare commits

...

2 Commits

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

View File

@@ -40,7 +40,7 @@
<view class="member-info"> <view class="member-info">
<view class="member-line"> <view class="member-line">
<text class="member-name">{{item.name}}</text> <text class="member-name">{{item.name}}</text>
<text class="member-sid" wx:if="{{item.student_id}}">学号 {{item.student_id}}</text> <text class="member-sid" wx:if="{{item.student_id}}">- {{item.student_id}}</text>
</view> </view>
<view class="member-meta">{{item.checked ? ('打卡 ' + item.timeText) : ''}}</view> <view class="member-meta">{{item.checked ? ('打卡 ' + item.timeText) : ''}}</view>
</view> </view>

View File

@@ -9,16 +9,30 @@ const auth = require('../../utils/auth');
const COURSE_URL = 'https://www.muguaclub.cn/courses/index.html'; 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({ Page({
data: { data: {
webviewUrl: '', webviewUrl: '',
webviewError: false, webviewError: false,
errorMsg: '', errorMsg: '',
webviewKey: 0 webviewKey: 0,
// 「去上课」浮窗是否已挂载。**故意不跟 web-view 同帧渲染**,见 _scheduleFloat
showFloat: false
}, },
_webviewKey: 0, _webviewKey: 0,
_redirecting: false, _redirecting: false,
_jumping: false,
_floatTimer: null,
onShow() { onShow() {
this.ensureAccess(); this.ensureAccess();
@@ -46,8 +60,9 @@ Page({
// 清掉可能残留的 web-view(如退出登录后切回本 tab),再切到「我的」 // 清掉可能残留的 web-view(如退出登录后切回本 tab),再切到「我的」
denyAccess() { denyAccess() {
if (this.data.webviewUrl) { clearTimeout(this._floatTimer);
this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' }); if (this.data.webviewUrl || this.data.showFloat) {
this.setData({ webviewUrl: '', webviewError: false, errorMsg: '', showFloat: false });
} }
if (this._redirecting) { if (this._redirecting) {
return; return;
@@ -68,24 +83,72 @@ Page({
webviewUrl: COURSE_URL + '?_t=' + Date.now(), webviewUrl: COURSE_URL + '?_t=' + Date.now(),
webviewError: false, webviewError: false,
errorMsg: '', 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) { onWebviewError(e) {
// 加载失败时把浮窗收掉(错误页上不叠浮窗),同时取消待触发的显示定时器
clearTimeout(this._floatTimer);
this.setData({ this.setData({
webviewError: true, webviewError: true,
errorMsg: (e && e.detail && e.detail.errMsg) || '加载失败,请检查网络后重试' errorMsg: (e && e.detail && e.detail.errMsg) || '加载失败,请检查网络后重试',
showFloat: false
}); });
}, },
onRetry() { onRetry() {
this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' }); clearTimeout(this._floatTimer);
this.setData({ webviewUrl: '', webviewError: false, errorMsg: '', showFloat: false });
setTimeout(() => { setTimeout(() => {
this.loadWebview(); this.loadWebview();
}, 60); }, 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() { onShareAppMessage() {
return { return {

View File

@@ -1,5 +1,23 @@
<view class="page"> <view class="page">
<web-view src="{{webviewUrl}}" wx:if="{{webviewUrl && !webviewError}}" key="{{webviewKey}}" binderror="onWebviewError"></web-view> <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-page" wx:if="{{webviewError}}">
<view class="error-icon">!</view> <view class="error-icon">!</view>
<view class="error-title">页面加载失败</view> <view class="error-title">页面加载失败</view>

View File

@@ -49,3 +49,47 @@
.btn-hover { .btn-hover {
opacity: 0.7; 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; 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({ Page({
data: { data: {
planType: 'weekly', planType: 'weekly',
@@ -110,8 +116,10 @@ Page({
checked: !!res.checked, checked: !!res.checked,
record, record,
content: record ? (record.content || '') : '', content: record ? (record.content || '') : '',
// attachments 是规范相对路径,提交打卡时会被原样发回后端,**不要**拿它做展示
attachments, attachments,
attachmentThumbs: attachments.map((p) => api.planFileUrl(p)) // 展示用签名地址,与 attachments 同长同序
attachmentThumbs: api.attachmentThumbs(record)
}); });
}, },
@@ -120,7 +128,7 @@ Page({
.then((res) => { .then((res) => {
const history = (res.list || []).map((item) => ({ const history = (res.list || []).map((item) => ({
...item, ...item,
thumbSrcs: (item.attachments || []).map((p) => api.planFileUrl(p)) thumbSrcs: api.attachmentThumbs(item)
})); }));
this.setData({ history }); this.setData({ history });
}).catch(() => {}); }).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) { uploadImages(paths) {
if (this.data.uploading || !paths.length) { if (this.data.uploading || !paths.length) {
return; return;
@@ -158,73 +232,52 @@ Page({
const planType = this.data.planType; const planType = this.data.planType;
const periodKey = this.data.periodKey; const periodKey = this.data.periodKey;
let attachments = this.data.attachments.slice(); let attachments = this.data.attachments.slice();
// 展示地址必须和 attachments 平行推进,不能在结尾用 attachments 现算:
// 新上传的图只在 OSS 上,光有相对路径拼不出可用地址
let thumbs = (this.data.attachmentThumbs || []).slice();
this.setData({ uploading: true }); 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) => { const run = (index) => {
if (index >= paths.length) { if (index >= paths.length) {
this.setData({ // ⚠️ 不能直接 setData:上一个文件成功时刚在**同一个同步块**里写过
uploading: false, // setData({attachments, attachmentThumbs}),紧接着 run(下一个) 就走到这里,
attachmentThumbs: attachments.map((p) => api.planFileUrl(p)) // 两次更新会撞在一起(本项目的数据更新纪律:同一同步树内只发一次 setData)。
}); // 用 nextTick 错开一个 tick,UI 表现不变。
wx.nextTick(() => this.setData({ uploading: false, attachmentThumbs: thumbs }));
return; 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))
});
} else {
wx.showToast({ title: data.error || '上传失败', icon: 'none' });
}
run(index + 1);
},
fail: () => {
wx.showToast({ title: '上传失败', icon: 'none' });
run(index + 1);
}
});
};
// 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE // 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE
const tryCompress = (src, level) => { this.prepareImage(paths[index], 0).then((src) => {
getFileSize(src).then((size) => { this.putToOss(src, sig).then((item) => {
if (!size || size <= MAX_IMAGE_SIZE || level >= COMPRESS_LEVELS.length) { if (item && item.relPath) {
doUpload(src); attachments = attachments.concat([item.relPath]).slice(0, MAX_IMAGES);
return; thumbs = thumbs.concat([item.thumb || src]).slice(0, MAX_IMAGES);
this.setData({ attachments, attachmentThumbs: thumbs });
} else {
wx.showToast({ title: (item && item.error) || '上传失败', icon: 'none' });
} }
wx.compressImage({ run(index + 1);
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) { removeImage(e) {
@@ -240,10 +293,12 @@ Page({
const dropLocal = () => { const dropLocal = () => {
const attachments = this.data.attachments.slice(); const attachments = this.data.attachments.slice();
attachments.splice(index, 1); attachments.splice(index, 1);
this.setData({ // 展示数组与 attachments 同长同序,必须一起删;否则缩略图会和实际文件错位
attachments, const thumbs = (this.data.attachmentThumbs || []).slice();
attachmentThumbs: attachments.map((p) => api.planFileUrl(p)) thumbs.splice(index, 1);
}); // 顺带清掉这条的签名缓存:否则以后重传**同名**文件时会命中已删对象的旧地址
api.forgetSignedUrl(path);
this.setData({ attachments, attachmentThumbs: thumbs });
}; };
if (parts.length < 4) { if (parts.length < 4) {
dropLocal(); dropLocal();

View File

@@ -185,7 +185,7 @@ Page({
myChecked: !!res.my_checked, myChecked: !!res.my_checked,
myStatus: res.my_status || '', myStatus: res.my_status || '',
myRecord: res.my_record || null, 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, unassignedCount: res.unassigned_count || 0,
isTeacher, isTeacher,
myGroup: res.my_group || '', myGroup: res.my_group || '',
@@ -239,11 +239,10 @@ Page({
url: '/api/v1/plan/group', url: '/api/v1/plan/group',
data: { type, group_name: groupName, period_key: this.data.periodKey } data: { type, group_name: groupName, period_key: this.data.periodKey }
}).then((res) => { }).then((res) => {
// 附件相对路径转完整 URL(列表已不渲染缩略图,allThumbs 全量留给详情弹层) // 附件转展示地址(列表已不渲染缩略图,allThumbs 全量留给详情弹层)
const members = (res.members || []).map((m) => { // 注意走 attachmentThumbs 而不是自己 map:后端返回的签名 URL 在 attachment_urls 里,
const all = (((m.record || {}).attachments) || []).map((p) => api.planFileUrl(p)); // 而 attachments 必须保持规范相对路径(提交打卡时会被原样回写)
return { ...m, allThumbs: all }; const members = (res.members || []).map((m) => ({ ...m, allThumbs: api.attachmentThumbs(m.record) }));
});
this.setData({ this.setData({
loading: false, loading: false,
periodKey: res.period_key || this.data.periodKey, periodKey: res.period_key || this.data.periodKey,
@@ -284,7 +283,7 @@ Page({
data: { type, page: 1, size: 20 } data: { type, page: 1, size: 20 }
}).then((res) => { }).then((res) => {
const history = (res.list || []).map((item) => { const history = (res.list || []).map((item) => {
const all = (item.attachments || []).map((p) => api.planFileUrl(p)); const all = api.attachmentThumbs(item);
return { return {
...item, ...item,
// 卡片里只铺前 2 张缩略图,弹层用 allThumbs 看全部 // 卡片里只铺前 2 张缩略图,弹层用 allThumbs 看全部
@@ -313,7 +312,7 @@ Page({
this.setData({ this.setData({
contentDetail: { contentDetail: {
title: m.name || '', title: m.name || '',
sub: m.student_id ? '学号 ' + m.student_id : '', sub: m.student_id ? '- ' + m.student_id : '',
meta: [this.data.periodKey, rec.checked_at ? rec.checked_at + ' 提交' : ''].filter(Boolean).join(' · '), meta: [this.data.periodKey, rec.checked_at ? rec.checked_at + ' 提交' : ''].filter(Boolean).join(' · '),
content: rec.content || '', content: rec.content || '',
thumbs: m.allThumbs || [], thumbs: m.allThumbs || [],

View File

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

View File

@@ -140,7 +140,9 @@
padding: 24rpx; padding: 24rpx;
margin-bottom: 18rpx; margin-bottom: 18rpx;
box-shadow: 0 6rpx 20rpx rgba(44, 24, 16, 0.05); 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 { .history-head {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -107,7 +107,10 @@ Page({
profile: res, profile: res,
phoneError: '', phoneError: '',
loadError: '', loadError: '',
avatarSrc: this.fullUrl(res.wechat_avatar), // 必须走 api.avatarUrl(内部先查本地签名缓存),不能直接取 res.avatar_url:
// 后端每次请求都现签一个新 URL,直接用会让地址每次进页面都变,
// 而微信是按 URL 缓存图片的 —— 等于每次重新下载头像。
avatarSrc: this.fullUrl(res),
avatarFallback: this.firstChar(displayName), avatarFallback: this.firstChar(displayName),
roleLabel: this.roleLabel(res.role), roleLabel: this.roleLabel(res.role),
loginTypeLabel: this.loginTypeLabel(res.login_type), loginTypeLabel: this.loginTypeLabel(res.login_type),
@@ -131,9 +134,13 @@ Page({
return LOGIN_TYPE_LABELS[type] || type || '手机号'; return LOGIN_TYPE_LABELS[type] || type || '手机号';
}, },
fullUrl(rel) { /**
if (!rel) return ''; * 头像转可显示地址。整个 payload 交给 api.avatarUrl,这样它既能查本地签名缓存,
return /^https?:\/\//i.test(rel) ? rel : api.STATIC_BASE_URL + rel; * 也能在后端给了 avatar_url 时写回缓存 —— 与打卡附件走的是同一套逻辑。
* 兼容 avatars/{uid}/x 与 /clock/static/avatars/{uid}/x 两种历史格式。
*/
fullUrl(payload) {
return api.avatarUrl(payload);
}, },
firstChar(str) { firstChar(str) {
@@ -162,7 +169,11 @@ Page({
} }
if (data.success && data.wechat_avatar) { if (data.success && data.wechat_avatar) {
const newAvatar = 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({ this.setData({
'profile.wechat_avatar': newAvatar, 'profile.wechat_avatar': newAvatar,
avatarSrc: newUrl avatarSrc: newUrl

View File

@@ -12,7 +12,8 @@ function _getBaseUrls() {
case 'develop': case 'develop':
// 开发版:本地服务 // 开发版:本地服务
return { return {
baseUrl: 'https://test.muguaclub.cn/clock', //baseUrl: 'https://test.muguaclub.cn/clock',
baseUrl: 'http://localhost:8000',
staticUrl: 'https://test.muguaclub.cn', staticUrl: 'https://test.muguaclub.cn',
h5Url: 'https://test.muguaclub.cn' h5Url: 'https://test.muguaclub.cn'
}; };
@@ -36,6 +37,8 @@ function _getBaseUrls() {
const _urls = _getBaseUrls(); const _urls = _getBaseUrls();
const BASE_URL = _urls.baseUrl; const BASE_URL = _urls.baseUrl;
// ⚠️ 图片**不再**用它拼地址了:图片只在 OSS 上,展示地址一律由后端签发
// (attachment_urls / avatar_url)。保留它是因为它和 H5_BASE_URL 同属"部署域名"描述。
const STATIC_BASE_URL = _urls.staticUrl; const STATIC_BASE_URL = _urls.staticUrl;
const H5_BASE_URL = _urls.h5Url; const H5_BASE_URL = _urls.h5Url;
@@ -109,16 +112,165 @@ function request(options) {
}); });
} }
/** // ── 签名 URL 本地缓存 ─────────────────────────────────────────────────────
* 把计划附件的相对路径转为完整的静态资源 URL。 // 为什么值得缓存:微信 <image> 是**按 URL** 缓存图片的。后端每次现签的 URL 里
* 路径格式:attachments/{user_id}/{plan_type}/{period_key}/{filename} // Expires 都不同(= 现在 + 有效期),URL 一变,微信就当新图**重新下载整个文件**。
*/ // 把 URL 留在本地复用,同一张图在窗口期内才是同一个地址,图片缓存才真正生效。
function planFileUrl(path) { //
if (!path) return ''; // 判断失效**不需要后端额外给字段**:签名 URL 自带绝对过期的 `Expires` 参数,
if (/^https?:\/\//i.test(path)) return path; // 前端解析它就够了。
const parts = String(path).replace(/^attachments\//, '').split('/');
if (parts.length < 4) return ''; const SIGN_CACHE_KEY = 'oss_signed_urls_v1';
return STATIC_BASE_URL + '/clock/static/plan/' + parts.slice(0, 4).map(encodeURIComponent).join('/'); 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
};

View File

@@ -12,6 +12,8 @@ function setToken(token) {
function clearToken() { function clearToken() {
wx.removeStorageSync('token'); wx.removeStorageSync('token');
// 顺带清掉签名 URL 缓存:换账号后不该复用上一个人的图片地址
api.clearSignedCache();
} }
function wxLogin(retries) { function wxLogin(retries) {

View File

@@ -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<Array<{key:string,url:string}>>}
*/
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(给 <image src> 用)
* 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
};