feat(app): 添加课程页面并优化打卡和计划功能

- 新增 pages/course/course 页面,集成 web-view 显示线上课程列表
- 在 app.json 中注册课程页面并添加底部 tabbar 导航
- 优化打卡页面,添加学号显示和改进成员列表布局
- 重构计划页面,添加内容详情弹层和优化加载逻辑
- 添加阿里云 OSS 上传工具模块用于文件上传功能
- 统一姓名和学号在同一行显示,改善 UI 体验
- 修复多个页面中的绑定事件和样式问题
This commit is contained in:
王宏豪
2026-09-20 16:53:36 +08:00
parent 01a7f4ab2d
commit ee3abed69f
13 changed files with 701 additions and 81 deletions

194
utils/oss-upload.js Normal file
View File

@@ -0,0 +1,194 @@
/**
* 阿里云 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
};