Compare commits
2 Commits
ee3abed69f
...
ba2d9245ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba2d9245ef | ||
|
|
ca21b1b9dc |
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
const run = (index) => {
|
|
||||||
if (index >= paths.length) {
|
// 先取一次直传签名。一份 policy 覆盖本次所有图片(它锁的是目录、不锁文件名)。
|
||||||
this.setData({
|
// 取不到签名(网络异常 / 后端异常 / 后端没配对象存储)就无法上传,直接报错。
|
||||||
uploading: false,
|
api.request({
|
||||||
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const rawPath = paths[index];
|
const sig = res;
|
||||||
const doUpload = (src) => {
|
const run = (index) => {
|
||||||
wx.uploadFile({
|
if (index >= paths.length) {
|
||||||
url: api.BASE_URL + '/api/v1/plan/upload',
|
// ⚠️ 不能直接 setData:上一个文件成功时刚在**同一个同步块**里写过
|
||||||
filePath: src,
|
// setData({attachments, attachmentThumbs}),紧接着 run(下一个) 就走到这里,
|
||||||
name: 'files',
|
// 两次更新会撞在一起(本项目的数据更新纪律:同一同步树内只发一次 setData)。
|
||||||
formData: { plan_type: planType, period_key: periodKey },
|
// 用 nextTick 错开一个 tick,UI 表现不变。
|
||||||
header: { Authorization: 'Bearer ' + auth.getToken() },
|
wx.nextTick(() => this.setData({ uploading: false, attachmentThumbs: thumbs }));
|
||||||
success: (res) => {
|
return;
|
||||||
let data = {};
|
}
|
||||||
try {
|
// 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE
|
||||||
data = JSON.parse(res.data);
|
this.prepareImage(paths[index], 0).then((src) => {
|
||||||
} catch (e) {
|
this.putToOss(src, sig).then((item) => {
|
||||||
data = {};
|
if (item && item.relPath) {
|
||||||
}
|
attachments = attachments.concat([item.relPath]).slice(0, MAX_IMAGES);
|
||||||
if (data.success && Array.isArray(data.attachments)) {
|
thumbs = thumbs.concat([item.thumb || src]).slice(0, MAX_IMAGES);
|
||||||
attachments = attachments.concat(data.attachments).slice(0, MAX_IMAGES);
|
this.setData({ attachments, attachmentThumbs: thumbs });
|
||||||
this.setData({
|
|
||||||
attachments,
|
|
||||||
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
wx.showToast({ title: data.error || '上传失败', icon: 'none' });
|
wx.showToast({ title: (item && item.error) || '上传失败', icon: 'none' });
|
||||||
}
|
}
|
||||||
run(index + 1);
|
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);
|
||||||
};
|
}).catch(() => {
|
||||||
run(0);
|
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();
|
||||||
|
|||||||
@@ -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 || [],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
176
utils/api.js
176
utils/api.js
@@ -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
|
||||||
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user