From ee3abed69f1e6d9564fbfe2c38f2c785bf73e830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=AE=8F=E8=B1=AA?= <2443753184@qq.com> Date: Sun, 20 Sep 2026 16:53:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=B7=BB=E5=8A=A0=E8=AF=BE?= =?UTF-8?q?=E7=A8=8B=E9=A1=B5=E9=9D=A2=E5=B9=B6=E4=BC=98=E5=8C=96=E6=89=93?= =?UTF-8?q?=E5=8D=A1=E5=92=8C=E8=AE=A1=E5=88=92=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 pages/course/course 页面,集成 web-view 显示线上课程列表 - 在 app.json 中注册课程页面并添加底部 tabbar 导航 - 优化打卡页面,添加学号显示和改进成员列表布局 - 重构计划页面,添加内容详情弹层和优化加载逻辑 - 添加阿里云 OSS 上传工具模块用于文件上传功能 - 统一姓名和学号在同一行显示,改善 UI 体验 - 修复多个页面中的绑定事件和样式问题 --- app.json | 7 ++ assets/tabbar/course-active.png | Bin 0 -> 963 bytes assets/tabbar/course.png | Bin 0 -> 914 bytes pages/checkin/checkin.wxml | 11 +- pages/checkin/checkin.wxss | 15 +++ pages/course/course.js | 103 +++++++++++++++++ pages/course/course.json | 4 + pages/course/course.wxml | 9 ++ pages/course/course.wxss | 51 +++++++++ pages/plan/plan.js | 172 ++++++++++++++++++++-------- pages/plan/plan.wxml | 114 +++++++++++++++---- pages/plan/plan.wxss | 102 ++++++++++++++++- utils/oss-upload.js | 194 ++++++++++++++++++++++++++++++++ 13 files changed, 701 insertions(+), 81 deletions(-) create mode 100644 assets/tabbar/course-active.png create mode 100644 assets/tabbar/course.png create mode 100644 pages/course/course.js create mode 100644 pages/course/course.json create mode 100644 pages/course/course.wxml create mode 100644 pages/course/course.wxss create mode 100644 utils/oss-upload.js diff --git a/app.json b/app.json index fa5e273..6ddbd20 100644 --- a/app.json +++ b/app.json @@ -1,6 +1,7 @@ { "pages": [ "pages/home/home", + "pages/course/course", "pages/checkin/checkin", "pages/plan/plan", "pages/plan-submit/plan-submit", @@ -27,6 +28,12 @@ "iconPath": "assets/tabbar/home.png", "selectedIconPath": "assets/tabbar/home-active.png" }, + { + "pagePath": "pages/course/course", + "text": "课程", + "iconPath": "assets/tabbar/course.png", + "selectedIconPath": "assets/tabbar/course-active.png" + }, { "pagePath": "pages/checkin/checkin", "text": "打卡", diff --git a/assets/tabbar/course-active.png b/assets/tabbar/course-active.png new file mode 100644 index 0000000000000000000000000000000000000000..49fb1fdded7eb36d40abf0c9992e491ecc643ff3 GIT binary patch literal 963 zcmV;!13dhRP)>5*rI!QSZ3k<%j(|ABH68Sdcg;@m=owmaLPk$ho}o*yrTz2e2&5vMkH8 zEX%Si%UW@=>}vpD0l@Aw{;u0`Hx4INaL&~pIim`2&V`p^aB%19j^x8{nQcds z_p4KdNJ1b$f9JKATaFLANp$)7*n<>AP9c!a;;|~Y*iv~xMWC|0LviT?&L`1>!JVs+ zyx$09y%ABQDgYxSUtDNMm!BP!lhTs}%hHn&@r#EBpejAdz^d}(QkFoLRS2W0_jAaP zCGS_vwxd&lNJ8YJ!Vhl0dLEDu_D7z8#3B%x?czdGjvRbN1Ol1;T*?B7h^P>sP_emA=-JOAsV7(h=yp0hG-e0 zAsV6~T83zdhG>YEAsV6~8lq*0hG>X}Xc?j*8sbLe1Dm~!q#7wXzq$&i^JaI5s&52Q z1ni}7@7y2mk@&G4s{kxd4<3CS3CCr4I{Ax30+RNMu=bIdUdbo`TOyxL%Mdz=i%A4R z0)apZk6gnxTL%QP>4hP3>MnL7=gIum{o!trrh44DBx+ssEqNYhciumK)c^FY_qW>a zUeeMx0mhwJu{&aNWVhMs#H!w_sy1{hAZPC;XYb}8Pxq_4H@_Ty!z@$DbKltX1tMUU zLAUdtzt1xK@S$G6Hm%q_C_Oy*`#%p1;giWETpCRSqNt&LxSGA1Y`v!^V3zmi@G1yZCb@cFL=^Z5)R%%FHZm7+KqfN4<#!G_GI lV_BAES(as4mSwH7zX2{Ro~_CgpWXlf002ovPDHLkV1gU!$Xx&c literal 0 HcmV?d00001 diff --git a/assets/tabbar/course.png b/assets/tabbar/course.png new file mode 100644 index 0000000000000000000000000000000000000000..7181e06876b3b04c316c390c59b1bbfdfc018798 GIT binary patch literal 914 zcmV;D18w|?P)AEot;_7Nl=awiu?g5QJ~Pog1CtY7f#o0WT2r*{)h-^HA+LErAn%kq9lr| zq`87L+Z>1=LNwQshK_=WQ_i=ZotMJC5rN2TP5=cERnc+Doxw&Aey3;zvet-rlw}5hMiXvzQg{BK zEDvK;1u%}P3Yh9xTM(kEDl|k@oyDfAT2(QIP*qdN>%J&L-tXIGL6mF5VYRi@djr77 z^2r_r7X$4Tu~t9*6+U=bM) - + {{weekday}} {{dayNum}} ▾ @@ -38,7 +38,10 @@ {{item.shortName}} - {{item.name}} + + {{item.name}} + 学号 {{item.student_id}} + {{item.checked ? ('打卡 ' + item.timeText) : ''}} {{item.checked ? item.record.status : '未打卡'}} @@ -115,7 +118,7 @@ ‹ 返回 {{selectedGroupName}} - + {{classDate}} ▾ @@ -163,7 +166,7 @@ 本周暂无打卡记录 - + diff --git a/pages/checkin/checkin.wxss b/pages/checkin/checkin.wxss index 61e949b..26e44bc 100644 --- a/pages/checkin/checkin.wxss +++ b/pages/checkin/checkin.wxss @@ -257,9 +257,24 @@ min-width: 0; margin-left: 16rpx; } +/* 姓名与学号同一行;姓名过长时省略,学号始终完整 */ +.member-line { + display: flex; + align-items: baseline; + gap: 12rpx; +} .member-name { font-size: 28rpx; color: #2C1810; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.member-sid { + font-size: 22rpx; + color: #8B7A6A; + flex-shrink: 0; } .member.ok .member-name { color: #2E7D32; diff --git a/pages/course/course.js b/pages/course/course.js new file mode 100644 index 0000000..310d242 --- /dev/null +++ b/pages/course/course.js @@ -0,0 +1,103 @@ +// 课程中心:全屏 web-view 加载线上课程列表 +// +// 注意:课程页部署在 www.muguaclub.cn,与首页 H5 的 m.muguaclub.cn 是不同主机名, +// www 必须单独加入微信公众平台「开发设置 → 业务域名」并在该站点根目录放校验文件, +// 否则真机打开会白屏。 +// 登录态:课程页本体是公开的,但产品要求「未登录不进课程页」——未登录时切到「我的」引导登录, +// 因此也不需要走 webview/redirect 换 cookie。 +const auth = require('../../utils/auth'); + +const COURSE_URL = 'https://www.muguaclub.cn/courses/index.html'; + +Page({ + data: { + webviewUrl: '', + webviewError: false, + errorMsg: '', + webviewKey: 0 + }, + + _webviewKey: 0, + _redirecting: false, + + onShow() { + this.ensureAccess(); + }, + + // 未登录不进课程页,直接切到「我的」引导登录 + ensureAccess() { + if (auth.getToken()) { + this.loadWebview(); + return; + } + // token 可能还在 App.onLaunch 发起的登录流程里,复用同一个登录单例再判一次,避免冷启动误跳 + return auth.ensureLogin() + .then((token) => { + if (token) { + this.loadWebview(); + return; + } + this.denyAccess(); + }) + .catch(() => { + this.denyAccess(); + }); + }, + + // 清掉可能残留的 web-view(如退出登录后切回本 tab),再切到「我的」 + denyAccess() { + if (this.data.webviewUrl) { + this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' }); + } + if (this._redirecting) { + return; + } + this._redirecting = true; + wx.switchTab({ + url: '/pages/profile/profile', + complete: () => { + this._redirecting = false; + } + }); + }, + + loadWebview() { + // key 自增 + 时间戳:强制 web-view 销毁重建,避免切 tab 回来白屏或读到上一次的缓存 + this._webviewKey += 1; + this.setData({ + webviewUrl: COURSE_URL + '?_t=' + Date.now(), + webviewError: false, + errorMsg: '', + webviewKey: this._webviewKey + }); + }, + + onWebviewError(e) { + this.setData({ + webviewError: true, + errorMsg: (e && e.detail && e.detail.errMsg) || '加载失败,请检查网络后重试' + }); + }, + + onRetry() { + this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' }); + setTimeout(() => { + this.loadWebview(); + }, 60); + }, + + // ---- 分享 ---- + onShareAppMessage() { + return { + title: '明缘云道场 · 全部课程', + path: '/pages/course/course' + }; + }, + + onShareTimeline() { + return { + title: '明缘云道场 · 全部课程', + query: 'from=timeline' + }; + } +}); diff --git a/pages/course/course.json b/pages/course/course.json new file mode 100644 index 0000000..6b712ed --- /dev/null +++ b/pages/course/course.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationBarTitleText": "全部课程" +} diff --git a/pages/course/course.wxml b/pages/course/course.wxml new file mode 100644 index 0000000..3086f8c --- /dev/null +++ b/pages/course/course.wxml @@ -0,0 +1,9 @@ + + + + ! + 页面加载失败 + {{errorMsg}} + 重新加载 + + diff --git a/pages/course/course.wxss b/pages/course/course.wxss new file mode 100644 index 0000000..f031b6e --- /dev/null +++ b/pages/course/course.wxss @@ -0,0 +1,51 @@ +.page { + min-height: 100vh; + background: #FDFBF7; +} + +/* ---- 加载失败兜底(web-view 为原生组件,仅在不渲染时才展示) ---- */ +.error-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 120rpx 60rpx; + height: 100vh; + box-sizing: border-box; +} +.error-icon { + width: 120rpx; + height: 120rpx; + border-radius: 50%; + background: #FDEAEA; + color: #C0392B; + font-size: 64rpx; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 32rpx; +} +.error-title { + font-size: 32rpx; + font-weight: 700; + color: #2C1810; + margin-bottom: 16rpx; +} +.error-desc { + font-size: 26rpx; + color: #8B7A6A; + text-align: center; + line-height: 1.6; + margin-bottom: 40rpx; +} +.error-retry { + padding: 20rpx 60rpx; + background: #C67B30; + color: #fff; + font-size: 28rpx; + border-radius: 999rpx; +} +.btn-hover { + opacity: 0.7; +} diff --git a/pages/plan/plan.js b/pages/plan/plan.js index 9cab3d3..2aa96ac 100644 --- a/pages/plan/plan.js +++ b/pages/plan/plan.js @@ -5,6 +5,16 @@ const auth = require('../../utils/auth'); const TYPES = ['weekly', 'monthly', 'yearly']; const TYPE_LABELS = ['周计划', '月计划', '年计划']; +// 个人历史卡片里最多展示的缩略图数量,其余图片在「查看详情」弹层里看全部 +const HISTORY_THUMB_LIMIT = 2; + +// 说明:本页所有「切换状态 + 紧跟一次加载」的流程,都要求把 loading 合并进 +// 调用方那一次 setData(加载函数传 silent=true),不要在同一个 tick 里连发两次 +// setData —— 否则会触发 "recursive update detected: a data update is applying +// during another data update" 警告(数据应用到 WXML 是同步过程,见官方文档 +// 「高级数据更新方法 / 递归更新问题」)。 +// 同理,refreshing 只由 onRefresh 写:它绑定的是 scroll-view 的 +// refresher-triggered(受控属性),别的流程顺手写它容易与组件自身状态互相回写。 Page({ data: { // 类型切换相关 @@ -43,6 +53,8 @@ Page({ // 周期切换相关 periodList: [], // 可用周期列表 showPeriodPicker: false, + // 内容详情弹层(点击文章 / 图片后展示全文与全部图片) + contentDetail: null, }, onShow() { @@ -87,6 +99,7 @@ Page({ return this.loadHistory(); } }).then(() => { + // refreshing 只在这里复位:它是 scroll-view refresher-triggered 的受控值 this.setData({ refreshing: false }); }); }, @@ -130,27 +143,25 @@ Page({ this.setData({ showPeriodPicker: false }); return; } - this.setData({ - periodKey: period, - showPeriodPicker: false, - }); - if (this.data.view === 'detail') { + // 详情判断要在 setData 之前取,视图切换与 loading 合并成同一次更新 + const inDetail = this.data.view === 'detail'; + this.setData({ periodKey: period, showPeriodPicker: false, loading: true }); + if (inDetail) { // 详情页:重新加载当前小组的该周期数据 - this.loadDetail(); - } else if (!this.data.isTeacher && this.data.studentTab === 'group' && this.data.myGroup) { - // 学员总览 + 小组 tab:刷新本组成员 - this.loadOverview(); + this.loadDetail(true); } else { - // 老师总览 / 学员个人 tab:重新加载总览 - this.loadOverview(); + // 总览:老师看小组统计,学员看本组成员(loadOverview 内部会接着拉成员) + this.loadOverview(true); } }, - /** 加载总览数据 */ - loadOverview() { + /** 加载总览数据(silent=true 时不写 loading,由调用方合并到同一次 setData) */ + loadOverview(silent) { const type = TYPES[this.data.typeIndex]; const periodKey = this.data.periodKey; - this.setData({ loading: true }); + if (!silent) { + this.setData({ loading: true }); + } return api.request({ url: '/api/v1/plan/groups', data: { type, period_key: periodKey } }) .then((res) => { const groups = (res.groups || []).map((g) => { @@ -162,9 +173,12 @@ Page({ }; }); const isTeacher = !!res.is_teacher; + // 学员总览的「小组」tab 紧接着还要拉一次本组成员:把 loading 直接定成 + // 最终值,避免「先置 false 再置 true」的连续更新,也避免中间态闪出空列表 + const willLoadMembers = !isTeacher && this.data.view === 'overview' + && this.data.studentTab === 'group' && !!res.my_group; this.setData({ - loading: false, - refreshing: false, + loading: willLoadMembers, periodKey: res.period_key || periodKey, canSubmit: !!res.can_submit, message: res.message || '', @@ -177,13 +191,13 @@ Page({ myGroup: res.my_group || '', groups, }); - // 学员:如果在总览 + 小组 tab,加载本组成员 - if (!isTeacher && this.data.view === 'overview' && this.data.studentTab === 'group' && res.my_group) { - return this.loadDetailForGroup(res.my_group); + // 学员:如果在总览 + 小组 tab,加载本组成员(loading 已在上面一并写入) + if (willLoadMembers) { + return this.loadDetailForGroup(res.my_group, undefined, true); } }) .catch(() => { - this.setData({ loading: false, refreshing: false }); + this.setData({ loading: false }); wx.showToast({ title: '加载失败', icon: 'none' }); }); }, @@ -194,8 +208,9 @@ Page({ if (!groupName) { return; } - this.setData({ selectedGroupName: groupName, view: 'detail', members: [] }); - this.loadDetail(); + // 视图切换与 loading 合并成同一次 setData,避免同一 tick 连发两次更新 + this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true }); + this.loadDetail(true); }, /** 学员直接查看自己小组(只有一个小组时点击卡片直接进) */ @@ -204,30 +219,37 @@ Page({ if (!groupName) { return; } - this.setData({ selectedGroupName: groupName, view: 'detail', members: [] }); - this.loadDetail(); + this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true }); + this.loadDetail(true); }, - /** 加载小组详情 */ - loadDetail() { + /** 加载小组详情(silent=true 表示 loading 已由调用方写入) */ + loadDetail(silent) { const type = TYPES[this.data.typeIndex]; - return this.loadDetailForGroup(this.data.selectedGroupName, type); + return this.loadDetailForGroup(this.data.selectedGroupName, type, silent); }, /** 加载指定小组的成员数据(不改变视图状态) */ - loadDetailForGroup(groupName, type) { + loadDetailForGroup(groupName, type, silent) { type = type || TYPES[this.data.typeIndex]; - this.setData({ loading: true }); + if (!silent) { + this.setData({ loading: true }); + } return api.request({ 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 }; + }); this.setData({ loading: false, periodKey: res.period_key || this.data.periodKey, canSubmit: !!res.can_submit, message: res.message || '', - members: res.members || [] + members }); }).catch(() => { this.setData({ loading: false }); @@ -239,13 +261,18 @@ Page({ switchStudentTab(e) { const tab = e.currentTarget.dataset.tab; if (tab !== 'group' && tab !== 'personal') return; - this.setData({ studentTab: tab }); if (tab === 'personal') { // 每次切到个人 tab 都刷新历史数据 + this.setData({ studentTab: tab }); this.loadHistory(); - } else if (tab === 'group' && this.data.myGroup) { - // 每次切回小组 tab 都刷新小组成员数据 - this.loadDetailForGroup(this.data.myGroup); + return; + } + if (this.data.myGroup) { + // 每次切回小组 tab 都刷新小组成员数据;loading 合并进这次 setData + this.setData({ studentTab: tab, loading: true }); + this.loadDetailForGroup(this.data.myGroup, undefined, true); + } else { + this.setData({ studentTab: tab }); } }, @@ -256,31 +283,76 @@ Page({ url: '/api/v1/plan/history', data: { type, page: 1, size: 20 } }).then((res) => { - const history = (res.list || []).map((item) => ({ - ...item, - thumbs: (item.attachments || []).map((p) => api.planFileUrl(p)).slice(0, 3), - statusColor: item.status === '未完成' ? '#C67B30' : '#2E7D32', - statusBg: item.status === '未完成' ? '#FFF3E6' : '#E8F4EB', - })); + const history = (res.list || []).map((item) => { + const all = (item.attachments || []).map((p) => api.planFileUrl(p)); + return { + ...item, + // 卡片里只铺前 2 张缩略图,弹层用 allThumbs 看全部 + thumbs: all.slice(0, HISTORY_THUMB_LIMIT), + allThumbs: all, + statusColor: item.status === '未完成' ? '#C67B30' : '#2E7D32', + statusBg: item.status === '未完成' ? '#FFF3E6' : '#E8F4EB', + }; + }); this.setData({ history }); }).catch(() => {}); }, /** 返回总览 */ backToOverview() { - this.setData({ view: 'overview', selectedGroupName: '', members: [] }); - this.loadOverview(); + this.setData({ view: 'overview', selectedGroupName: '', members: [], loading: true }); + this.loadOverview(true); }, - /** 预览我的图片 */ - previewMyImage(e) { - const index = Number(e.currentTarget.dataset.index); - const urls = this.data.myThumbs; - const src = urls[index]; - if (!src) return; - wx.previewImage({ current: src, urls }); + /** 点击某个成员的信息行 → 打开详情弹层 */ + openMemberDetail(e) { + const mi = Number(e.currentTarget.dataset.mi); + const m = this.data.members[mi]; + if (!m || !m.record) return; + const rec = m.record || {}; + this.setData({ + contentDetail: { + title: m.name || '', + sub: m.student_id ? '学号 ' + m.student_id : '', + meta: [this.data.periodKey, rec.checked_at ? rec.checked_at + ' 提交' : ''].filter(Boolean).join(' · '), + content: rec.content || '', + thumbs: m.allThumbs || [], + }, + }); + }, + + /** 点击我的一条历史记录 → 打开详情弹层 */ + openHistoryDetail(e) { + const hi = Number(e.currentTarget.dataset.hi); + const item = this.data.history[hi]; + if (!item) return; + this.setData({ + contentDetail: { + title: item.period_key || '', + sub: item.status || '', + meta: item.checked_at ? item.checked_at + ' 提交' : '', + content: item.content || '', + thumbs: item.allThumbs || [], + }, + }); }, + /** 关闭内容详情弹层 */ + closeContentDetail() { + this.setData({ contentDetail: null }); + }, + + /** 在详情弹层内放大某张图片 */ + previewDetailImage(e) { + const ci = Number(e.currentTarget.dataset.ci) || 0; + const urls = (this.data.contentDetail && this.data.contentDetail.thumbs) || []; + if (!urls.length) return; + wx.previewImage({ current: urls[ci] || urls[0], urls }); + }, + + /** 占位:遮罩层用 catchtouchmove 阻止滚动穿透 */ + noop() {}, + /** 去提交计划 */ goSubmit() { const type = TYPES[this.data.typeIndex]; @@ -316,4 +388,4 @@ Page({ query: 'from=timeline' }; } -}); \ No newline at end of file +}); diff --git a/pages/plan/plan.wxml b/pages/plan/plan.wxml index 25ef6b3..2820389 100644 --- a/pages/plan/plan.wxml +++ b/pages/plan/plan.wxml @@ -6,7 +6,7 @@ {{types[typeIndex]}} - + {{periodKey}} ▾ @@ -93,8 +93,8 @@ {{myGroup}} · {{periodKey}} ▾ @@ -103,16 +103,22 @@ ◌ 加载中... + - - - {{index + 1}} - - {{member.name}} - {{member.record.checked_at}} 提交 + + + + {{mi + 1}} + + + {{member.name}} + 学号 {{member.student_id}} + + {{member.record.checked_at}} 提交 + + {{member.filled ? '已完成' : '未完成'}} - {{member.filled ? '已完成' : '未完成'}} @@ -131,7 +137,13 @@ 📒 暂无{{types[typeIndex]}}记录 - + + {{item.period_key}} {{item.status}} @@ -139,8 +151,17 @@ {{item.checked_at}} 提交 {{item.content}} - + + 查看详情 › @@ -176,7 +197,7 @@ ‹ 返回 {{selectedGroupName}} - + {{periodKey}} ▾ @@ -187,15 +208,36 @@ 加载中... - - - {{index + 1}} - - {{member.name}} - {{member.record.checked_at}} 提交 + + + + + {{mi + 1}} + + + {{member.name}} + 学号 {{member.student_id}} + + {{member.record.checked_at}} 提交 + + {{member.filled ? '已完成' : '未完成'}} - {{member.filled ? '已完成' : '未完成'}} + + 点击查看详情 › 暂无成员 @@ -220,4 +262,34 @@ + + + + + + + {{contentDetail.title}} + {{contentDetail.sub}} + + ✕ + + {{contentDetail.meta}} + + {{contentDetail.content}} + 未填写文字内容 + + + + + diff --git a/pages/plan/plan.wxss b/pages/plan/plan.wxss index adff177..f1526fa 100644 --- a/pages/plan/plan.wxss +++ b/pages/plan/plan.wxss @@ -62,13 +62,17 @@ .member-list { background: #fff; border-radius: 20rpx; padding: 8rpx 24rpx; box-shadow: 0 6rpx 20rpx rgba(44,24,16,0.05); } .member-left { display: flex; align-items: center; min-width: 0; } -.member { display: flex; align-items: center; justify-content: space-between; padding: 22rpx 0; border-bottom: 1rpx solid #F2EDE7; } +.member { display: flex; flex-direction: column; align-items: stretch; padding: 22rpx 0; border-bottom: 1rpx solid #F2EDE7; } .member:last-child { border-bottom: none; } +.member-row { display: flex; align-items: center; justify-content: space-between; } .member-avatar { width: 64rpx; height: 64rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 26rpx; font-weight: 800; flex-shrink: 0; } .member-avatar.done { background: linear-gradient(135deg, #E8F4EB, #CFEAD5); color: #2E7D32; } .member-avatar.unchecked { background: linear-gradient(135deg, #FDECEA, #F9D9D5); color: #C0392B; } .member-info { margin-left: 18rpx; min-width: 0; } -.member-name { font-size: 28rpx; font-weight: 700; color: #2C1810; } +/* 姓名与学号同一行;姓名过长时省略,学号始终完整 */ +.member-line { display: flex; align-items: baseline; gap: 12rpx; } +.member-name { font-size: 28rpx; font-weight: 700; color: #2C1810; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.member-sid { font-size: 22rpx; color: #A99A89; flex-shrink: 0; } .member-meta { margin-top: 6rpx; font-size: 22rpx; color: #A99A89; } .badge { flex-shrink: 0; padding: 8rpx 18rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 700; } .badge.done { background: #E8F4EB; color: #2E7D32; } @@ -158,6 +162,7 @@ font-size: 22rpx; color: #A99A89; } +/* 历史卡片内的正文预览:最多 3 行,超出截断 */ .history-content { margin-top: 14rpx; padding: 16rpx 20rpx; @@ -173,6 +178,7 @@ -webkit-line-clamp: 3; overflow: hidden; } +/* 历史卡片内的缩略图:最多 2 张,其余进详情弹层 */ .history-thumbs { display: flex; flex-wrap: wrap; @@ -180,8 +186,8 @@ margin-top: 14rpx; } .history-thumb { - width: 120rpx; - height: 120rpx; + width: 148rpx; + height: 148rpx; border-radius: 12rpx; background: #F2EDE7; } @@ -205,6 +211,7 @@ max-height: 70vh; display: flex; flex-direction: column; + overflow: hidden; transform: translateY(100%); transition: transform .28s ease; } @@ -220,9 +227,11 @@ text-align: center; border-bottom: 1rpx solid #F2EDE7; } +/* flex:1 撑满剩余高度,max-height 兜底(同 .cd-body,防 flex 高度解析失败后滚不动) */ .picker-list { - flex: 1; + flex: 1 1 auto; min-height: 0; + max-height: 64vh; padding: 8rpx 0 32rpx; } .picker-item { @@ -246,4 +255,85 @@ } /* 底部留白 */ -.bottom-space { height: 40rpx; } \ No newline at end of file +.bottom-space { height: 40rpx; } + +/* 信息行的点击反馈;正文与图片统一在详情弹层里看 */ +.member-row-hover { opacity: .7; } +.tap-hint { margin-top: 10rpx; text-align: right; font-size: 22rpx; color: #C67B30; } +.tap-hint-hover { opacity: .7; } + +/* 内容详情弹层 */ +.cd-mask { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.45); + z-index: 200; +} +.cd-panel { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 201; + max-height: 86vh; + display: flex; + flex-direction: column; + overflow: hidden; + background: #fff; + border-radius: 28rpx 28rpx 0 0; + animation: cdIn .22s ease; +} +@keyframes cdIn { + from { transform: translateY(40rpx); opacity: .5; } + to { transform: translateY(0); opacity: 1; } +} +.cd-head { flex-shrink: 0; display: flex; align-items: flex-start; gap: 16rpx; padding: 28rpx 32rpx 16rpx; } +.cd-head-text { flex: 1; min-width: 0; } +.cd-title { font-size: 34rpx; font-weight: 800; color: #2C1810; } +.cd-sub { margin-top: 8rpx; font-size: 24rpx; color: #A99A89; } +.cd-close { + flex-shrink: 0; + width: 56rpx; height: 56rpx; line-height: 56rpx; + text-align: center; + border-radius: 50%; + background: #F6F2EC; + color: #8B7A6A; + font-size: 26rpx; +} +.cd-close-hover { opacity: .7; } +.cd-meta { + flex-shrink: 0; + padding: 0 32rpx 16rpx; + font-size: 22rpx; + color: #A99A89; + border-bottom: 1rpx solid #F2EDE7; +} +/* + * 滚动区:flex:1 吃掉剩余高度;max-height 兜底 —— 部分基础库下 + * max-height 父容器里的 flex 子项算不出高度,会导致内容被截断且滚不动。 + * 双保险下无论哪种情况都有明确高度上限,内容超出即可上下滑动。 + */ +.cd-body { + flex: 1 1 auto; + min-height: 0; + max-height: 64vh; + padding: 20rpx 32rpx calc(60rpx + env(safe-area-inset-bottom)); + box-sizing: border-box; +} +.cd-content { + padding: 20rpx 24rpx; + background: #FBF7F1; + border-radius: 14rpx; + font-size: 28rpx; + line-height: 1.8; + color: #2C1810; + white-space: pre-wrap; + word-break: break-all; +} +.cd-no-content { padding: 12rpx 4rpx; font-size: 26rpx; color: #A99A89; } +.cd-thumbs { margin-top: 20rpx; } +.cd-thumb { + display: block; + width: 100%; + margin-bottom: 16rpx; + border-radius: 14rpx; + background: #F2EDE7; +} \ No newline at end of file diff --git a/utils/oss-upload.js b/utils/oss-upload.js new file mode 100644 index 0000000..fa4cccd --- /dev/null +++ b/utils/oss-upload.js @@ -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>} + */ +function uploadDirectBatch(filePaths, options) { + const results = []; + return filePaths.reduce((chain, path) => { + return chain.then(() => uploadDirect(path, options)).then((r) => { + results.push(r); + if (options && options.onItemDone) options.onItemDone(r, results.length); + }); + }, Promise.resolve()).then(() => results); +} + +/** + * 模式A:走自家后端中转(与现有 plan-submit.js 的写法一致) + * 好处是不用配 OSS 域名白名单,改造时把 url 从 /plan/upload 换成本接口即可。 + */ +function uploadViaServer(filePath, options) { + const opts = options || {}; + const token = wx.getStorageSync('token'); + + return new Promise((resolve, reject) => { + wx.uploadFile({ + url: api.BASE_URL + SERVER_UPLOAD_API, + filePath, + name: 'files', + header: token ? { Authorization: 'Bearer ' + token } : {}, + formData: { + plan_type: opts.planType || 'weekly', + period_key: opts.periodKey || '' + }, + success(res) { + let data = {}; + try { + data = JSON.parse(res.data); + } catch (e) { + reject({ error: '返回格式异常' }); + return; + } + if (res.statusCode >= 200 && res.statusCode < 300 && data.success) { + resolve({ key: (data.attachments || [])[0] || '', items: data.items || [] }); + } else { + reject({ error: data.detail || data.error || '上传失败' }); + } + }, + fail: reject + }); + }); +} + +/** + * 私有 Bucket 取临时可读 URL(给 用) + * key 必须是 attachments/{userId}/... 形式 + */ +function getFileUrl(key) { + return api.request({ + url: '/api/v1/oss-demo/file-url?key=' + encodeURIComponent(key) + }).then((res) => (res && res.url) || ''); +} + +module.exports = { + uploadDirect, + uploadDirectBatch, + uploadViaServer, + getFileUrl, + ALLOWED_EXT +};