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

View File

@@ -1,6 +1,7 @@
{ {
"pages": [ "pages": [
"pages/home/home", "pages/home/home",
"pages/course/course",
"pages/checkin/checkin", "pages/checkin/checkin",
"pages/plan/plan", "pages/plan/plan",
"pages/plan-submit/plan-submit", "pages/plan-submit/plan-submit",
@@ -27,6 +28,12 @@
"iconPath": "assets/tabbar/home.png", "iconPath": "assets/tabbar/home.png",
"selectedIconPath": "assets/tabbar/home-active.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", "pagePath": "pages/checkin/checkin",
"text": "打卡", "text": "打卡",

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

BIN
assets/tabbar/course.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

View File

@@ -2,7 +2,7 @@
<template name="top-card"> <template name="top-card">
<view class="card top-card {{checked ? 'done-card' : ''}}"> <view class="card top-card {{checked ? 'done-card' : ''}}">
<view class="status-row"> <view class="status-row">
<view class="date-chip" bindtap="{{weekList.length > 0 ? 'openWeekPicker' : ''}}" hover-class="{{weekList.length > 0 ? 'date-chip-hover' : ''}}"> <view class="date-chip" bindtap="openWeekPicker" hover-class="{{weekList.length > 0 ? 'date-chip-hover' : 'none'}}">
<view class="date-week">{{weekday}}</view> <view class="date-week">{{weekday}}</view>
<view class="date-num">{{dayNum}}<text class="date-arrow" wx:if="{{weekList.length > 0}}"> ▾</text></view> <view class="date-num">{{dayNum}}<text class="date-arrow" wx:if="{{weekList.length > 0}}"> ▾</text></view>
</view> </view>
@@ -38,7 +38,10 @@
<view class="member {{item.checked ? 'ok' : 'idle'}}"> <view class="member {{item.checked ? 'ok' : 'idle'}}">
<view class="member-avatar {{item.checked ? 'ok' : ''}}">{{item.shortName}}</view> <view class="member-avatar {{item.checked ? 'ok' : ''}}">{{item.shortName}}</view>
<view class="member-info"> <view class="member-info">
<view class="member-name">{{item.name}}</view> <view class="member-line">
<text class="member-name">{{item.name}}</text>
<text class="member-sid" wx:if="{{item.student_id}}">学号 {{item.student_id}}</text>
</view>
<view class="member-meta">{{item.checked ? ('打卡 ' + item.timeText) : ''}}</view> <view class="member-meta">{{item.checked ? ('打卡 ' + item.timeText) : ''}}</view>
</view> </view>
<view class="member-badge {{item.checked ? 'ok' : 'idle'}}">{{item.checked ? item.record.status : '未打卡'}}</view> <view class="member-badge {{item.checked ? 'ok' : 'idle'}}">{{item.checked ? item.record.status : '未打卡'}}</view>
@@ -115,7 +118,7 @@
<view class="back" hover-class="back-hover" bindtap="backToOverview">‹ 返回</view> <view class="back" hover-class="back-hover" bindtap="backToOverview">‹ 返回</view>
<view class="detail-title-wrap"> <view class="detail-title-wrap">
<view class="detail-title">{{selectedGroupName}}</view> <view class="detail-title">{{selectedGroupName}}</view>
<view class="detail-sub" bindtap="{{weekList.length > 0 ? 'openWeekPicker' : ''}}" hover-class="{{weekList.length > 0 ? 'head-hover' : ''}}"> <view class="detail-sub" bindtap="openWeekPicker" hover-class="{{weekList.length > 0 ? 'head-hover' : 'none'}}">
{{classDate}} <text class="head-arrow" wx:if="{{weekList.length > 0}}"> ▾</text> {{classDate}} <text class="head-arrow" wx:if="{{weekList.length > 0}}"> ▾</text>
</view> </view>
</view> </view>
@@ -163,7 +166,7 @@
<view class="empty-text">本周暂无打卡记录</view> <view class="empty-text">本周暂无打卡记录</view>
</view> </view>
<view class="member-list" wx:else> <view class="member-list" wx:else>
<block wx:for="{{members}}" wx:key="student_id" wx:for-item="item"> <block wx:for="{{members}}" wx:key="user_id" wx:for-item="item">
<template is="member-item" data="{{item: item}}"></template> <template is="member-item" data="{{item: item}}"></template>
</block> </block>
</view> </view>

View File

@@ -257,9 +257,24 @@
min-width: 0; min-width: 0;
margin-left: 16rpx; margin-left: 16rpx;
} }
/* 姓名与学号同一行;姓名过长时省略,学号始终完整 */
.member-line {
display: flex;
align-items: baseline;
gap: 12rpx;
}
.member-name { .member-name {
font-size: 28rpx; font-size: 28rpx;
color: #2C1810; 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 { .member.ok .member-name {
color: #2E7D32; color: #2E7D32;

103
pages/course/course.js Normal file
View File

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

4
pages/course/course.json Normal file
View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "全部课程"
}

9
pages/course/course.wxml Normal file
View File

@@ -0,0 +1,9 @@
<view class="page">
<web-view src="{{webviewUrl}}" wx:if="{{webviewUrl && !webviewError}}" key="{{webviewKey}}" binderror="onWebviewError"></web-view>
<view class="error-page" wx:if="{{webviewError}}">
<view class="error-icon">!</view>
<view class="error-title">页面加载失败</view>
<view class="error-desc">{{errorMsg}}</view>
<view class="error-retry" hover-class="btn-hover" bindtap="onRetry">重新加载</view>
</view>
</view>

51
pages/course/course.wxss Normal file
View File

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

View File

@@ -5,6 +5,16 @@ const auth = require('../../utils/auth');
const TYPES = ['weekly', 'monthly', 'yearly']; const TYPES = ['weekly', 'monthly', 'yearly'];
const TYPE_LABELS = ['周计划', '月计划', '年计划']; 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({ Page({
data: { data: {
// 类型切换相关 // 类型切换相关
@@ -43,6 +53,8 @@ Page({
// 周期切换相关 // 周期切换相关
periodList: [], // 可用周期列表 periodList: [], // 可用周期列表
showPeriodPicker: false, showPeriodPicker: false,
// 内容详情弹层(点击文章 / 图片后展示全文与全部图片)
contentDetail: null,
}, },
onShow() { onShow() {
@@ -87,6 +99,7 @@ Page({
return this.loadHistory(); return this.loadHistory();
} }
}).then(() => { }).then(() => {
// refreshing 只在这里复位:它是 scroll-view refresher-triggered 的受控值
this.setData({ refreshing: false }); this.setData({ refreshing: false });
}); });
}, },
@@ -130,27 +143,25 @@ Page({
this.setData({ showPeriodPicker: false }); this.setData({ showPeriodPicker: false });
return; return;
} }
this.setData({ // 详情判断要在 setData 之前取,视图切换与 loading 合并成同一次更新
periodKey: period, const inDetail = this.data.view === 'detail';
showPeriodPicker: false, this.setData({ periodKey: period, showPeriodPicker: false, loading: true });
}); if (inDetail) {
if (this.data.view === 'detail') {
// 详情页:重新加载当前小组的该周期数据 // 详情页:重新加载当前小组的该周期数据
this.loadDetail(); this.loadDetail(true);
} else if (!this.data.isTeacher && this.data.studentTab === 'group' && this.data.myGroup) {
// 学员总览 + 小组 tab:刷新本组成员
this.loadOverview();
} else { } else {
// 老师总览 / 学员个人 tab:重新加载总览 // 总览:老师看小组统计,学员看本组成员(loadOverview 内部会接着拉成员)
this.loadOverview(); this.loadOverview(true);
} }
}, },
/** 加载总览数据 */ /** 加载总览数据(silent=true 时不写 loading,由调用方合并到同一次 setData) */
loadOverview() { loadOverview(silent) {
const type = TYPES[this.data.typeIndex]; const type = TYPES[this.data.typeIndex];
const periodKey = this.data.periodKey; 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 } }) return api.request({ url: '/api/v1/plan/groups', data: { type, period_key: periodKey } })
.then((res) => { .then((res) => {
const groups = (res.groups || []).map((g) => { const groups = (res.groups || []).map((g) => {
@@ -162,9 +173,12 @@ Page({
}; };
}); });
const isTeacher = !!res.is_teacher; 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({ this.setData({
loading: false, loading: willLoadMembers,
refreshing: false,
periodKey: res.period_key || periodKey, periodKey: res.period_key || periodKey,
canSubmit: !!res.can_submit, canSubmit: !!res.can_submit,
message: res.message || '', message: res.message || '',
@@ -177,13 +191,13 @@ Page({
myGroup: res.my_group || '', myGroup: res.my_group || '',
groups, groups,
}); });
// 学员:如果在总览 + 小组 tab,加载本组成员 // 学员:如果在总览 + 小组 tab,加载本组成员(loading 已在上面一并写入)
if (!isTeacher && this.data.view === 'overview' && this.data.studentTab === 'group' && res.my_group) { if (willLoadMembers) {
return this.loadDetailForGroup(res.my_group); return this.loadDetailForGroup(res.my_group, undefined, true);
} }
}) })
.catch(() => { .catch(() => {
this.setData({ loading: false, refreshing: false }); this.setData({ loading: false });
wx.showToast({ title: '加载失败', icon: 'none' }); wx.showToast({ title: '加载失败', icon: 'none' });
}); });
}, },
@@ -194,8 +208,9 @@ Page({
if (!groupName) { if (!groupName) {
return; return;
} }
this.setData({ selectedGroupName: groupName, view: 'detail', members: [] }); // 视图切换与 loading 合并成同一次 setData,避免同一 tick 连发两次更新
this.loadDetail(); this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
this.loadDetail(true);
}, },
/** 学员直接查看自己小组(只有一个小组时点击卡片直接进) */ /** 学员直接查看自己小组(只有一个小组时点击卡片直接进) */
@@ -204,30 +219,37 @@ Page({
if (!groupName) { if (!groupName) {
return; return;
} }
this.setData({ selectedGroupName: groupName, view: 'detail', members: [] }); this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
this.loadDetail(); this.loadDetail(true);
}, },
/** 加载小组详情 */ /** 加载小组详情(silent=true 表示 loading 已由调用方写入) */
loadDetail() { loadDetail(silent) {
const type = TYPES[this.data.typeIndex]; 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]; type = type || TYPES[this.data.typeIndex];
this.setData({ loading: true }); if (!silent) {
this.setData({ loading: true });
}
return api.request({ return api.request({
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 全量留给详情弹层)
const members = (res.members || []).map((m) => {
const all = (((m.record || {}).attachments) || []).map((p) => api.planFileUrl(p));
return { ...m, allThumbs: all };
});
this.setData({ this.setData({
loading: false, loading: false,
periodKey: res.period_key || this.data.periodKey, periodKey: res.period_key || this.data.periodKey,
canSubmit: !!res.can_submit, canSubmit: !!res.can_submit,
message: res.message || '', message: res.message || '',
members: res.members || [] members
}); });
}).catch(() => { }).catch(() => {
this.setData({ loading: false }); this.setData({ loading: false });
@@ -239,13 +261,18 @@ Page({
switchStudentTab(e) { switchStudentTab(e) {
const tab = e.currentTarget.dataset.tab; const tab = e.currentTarget.dataset.tab;
if (tab !== 'group' && tab !== 'personal') return; if (tab !== 'group' && tab !== 'personal') return;
this.setData({ studentTab: tab });
if (tab === 'personal') { if (tab === 'personal') {
// 每次切到个人 tab 都刷新历史数据 // 每次切到个人 tab 都刷新历史数据
this.setData({ studentTab: tab });
this.loadHistory(); this.loadHistory();
} else if (tab === 'group' && this.data.myGroup) { return;
// 每次切回小组 tab 都刷新小组成员数据 }
this.loadDetailForGroup(this.data.myGroup); 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', url: '/api/v1/plan/history',
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) => {
...item, const all = (item.attachments || []).map((p) => api.planFileUrl(p));
thumbs: (item.attachments || []).map((p) => api.planFileUrl(p)).slice(0, 3), return {
statusColor: item.status === '未完成' ? '#C67B30' : '#2E7D32', ...item,
statusBg: item.status === '未完成' ? '#FFF3E6' : '#E8F4EB', // 卡片里只铺前 2 张缩略图,弹层用 allThumbs 看全部
})); thumbs: all.slice(0, HISTORY_THUMB_LIMIT),
allThumbs: all,
statusColor: item.status === '未完成' ? '#C67B30' : '#2E7D32',
statusBg: item.status === '未完成' ? '#FFF3E6' : '#E8F4EB',
};
});
this.setData({ history }); this.setData({ history });
}).catch(() => {}); }).catch(() => {});
}, },
/** 返回总览 */ /** 返回总览 */
backToOverview() { backToOverview() {
this.setData({ view: 'overview', selectedGroupName: '', members: [] }); this.setData({ view: 'overview', selectedGroupName: '', members: [], loading: true });
this.loadOverview(); this.loadOverview(true);
}, },
/** 预览我的图片 */ /** 点击某个成员的信息行 → 打开详情弹层 */
previewMyImage(e) { openMemberDetail(e) {
const index = Number(e.currentTarget.dataset.index); const mi = Number(e.currentTarget.dataset.mi);
const urls = this.data.myThumbs; const m = this.data.members[mi];
const src = urls[index]; if (!m || !m.record) return;
if (!src) return; const rec = m.record || {};
wx.previewImage({ current: src, urls }); 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() { goSubmit() {
const type = TYPES[this.data.typeIndex]; const type = TYPES[this.data.typeIndex];
@@ -316,4 +388,4 @@ Page({
query: 'from=timeline' query: 'from=timeline'
}; };
} }
}); });

View File

@@ -6,7 +6,7 @@
<view <view
class="type-tab {{typeIndex === index ? 'active' : ''}}" class="type-tab {{typeIndex === index ? 'active' : ''}}"
wx:for="{{types}}" wx:for="{{types}}"
wx:key="index" wx:key="*this"
data-index="{{index}}" data-index="{{index}}"
hover-class="type-tab-hover" hover-class="type-tab-hover"
bindtap="onTypeChange" bindtap="onTypeChange"
@@ -24,7 +24,7 @@
<view class="overview-head"> <view class="overview-head">
<view class="head-title-wrap"> <view class="head-title-wrap">
<view class="head-title">{{types[typeIndex]}}</view> <view class="head-title">{{types[typeIndex]}}</view>
<view class="head-sub" wx:if="{{isTeacher}}" bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}" hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}"> <view class="head-sub" wx:if="{{isTeacher}}" bindtap="openPeriodPicker" hover-class="{{periodList.length > 0 ? 'head-hover' : 'none'}}">
{{periodKey}} {{periodKey}}
<text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text> <text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
</view> </view>
@@ -93,8 +93,8 @@
<view <view
class="group-period-row" class="group-period-row"
wx:if="{{myGroup}}" wx:if="{{myGroup}}"
bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}" bindtap="openPeriodPicker"
hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}" hover-class="{{periodList.length > 0 ? 'head-hover' : 'none'}}"
> >
<text class="group-period-text">{{myGroup}} · {{periodKey}}</text> <text class="group-period-text">{{myGroup}} · {{periodKey}}</text>
<text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text> <text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
@@ -103,16 +103,22 @@
<view class="state-icon">◌</view> <view class="state-icon">◌</view>
<view>加载中...</view> <view>加载中...</view>
</view> </view>
<!-- 学员小组 tab:只展示成员信息,不展示任何人的打卡内容,也不提供详情入口 -->
<view class="member-list" wx:elif="{{members.length > 0}}"> <view class="member-list" wx:elif="{{members.length > 0}}">
<view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member"> <view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member" wx:for-index="mi">
<view class="member-left"> <view class="member-row">
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{index + 1}}</view> <view class="member-left">
<view class="member-info"> <view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{mi + 1}}</view>
<view class="member-name">{{member.name}}</view> <view class="member-info">
<view class="member-meta" wx:if="{{member.filled && member.record}}">{{member.record.checked_at}} 提交</view> <view class="member-line">
<text class="member-name">{{member.name}}</text>
<text class="member-sid" wx:if="{{member.student_id}}">学号 {{member.student_id}}</text>
</view>
<view class="member-meta" wx:if="{{member.filled && member.record}}">{{member.record.checked_at}} 提交</view>
</view>
</view> </view>
<view class="badge {{member.filled ? 'done' : 'unchecked'}}">{{member.filled ? '已完成' : '未完成'}}</view>
</view> </view>
<view class="badge {{member.filled ? 'done' : 'unchecked'}}">{{member.filled ? '已完成' : '未完成'}}</view>
</view> </view>
</view> </view>
<view class="empty" wx:elif="{{!loading && !myGroup}}"> <view class="empty" wx:elif="{{!loading && !myGroup}}">
@@ -131,7 +137,13 @@
<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>
<view class="history-card" wx:for="{{history}}" wx:key="id"> <!-- 卡片直接铺内容:正文 3 行截断、缩略图最多 2 张;全部文字与图片在「查看详情」弹层里看 -->
<view
class="history-card"
wx:for="{{history}}"
wx:key="id"
wx:for-index="hi"
>
<view class="history-head"> <view class="history-head">
<view class="history-period">{{item.period_key}}</view> <view class="history-period">{{item.period_key}}</view>
<view class="history-status" style="color:{{item.statusColor}};background:{{item.statusBg}}">{{item.status}}</view> <view class="history-status" style="color:{{item.statusColor}};background:{{item.statusBg}}">{{item.status}}</view>
@@ -139,8 +151,17 @@
<view class="history-meta" wx:if="{{item.checked_at}}">{{item.checked_at}} 提交</view> <view class="history-meta" wx:if="{{item.checked_at}}">{{item.checked_at}} 提交</view>
<view class="history-content" wx:if="{{item.content}}">{{item.content}}</view> <view class="history-content" wx:if="{{item.content}}">{{item.content}}</view>
<view class="history-thumbs" wx:if="{{item.thumbs.length > 0}}"> <view class="history-thumbs" wx:if="{{item.thumbs.length > 0}}">
<image class="history-thumb" wx:for="{{item.thumbs}}" wx:for-item="thumb" wx:key="*this" src="{{thumb}}" mode="aspectFill"></image> <image
class="history-thumb"
wx:for="{{item.thumbs}}"
wx:for-item="thumb"
wx:for-index="ti"
wx:key="*this"
src="{{thumb}}"
mode="aspectFill"
></image>
</view> </view>
<view class="tap-hint" data-hi="{{hi}}" hover-class="tap-hint-hover" bindtap="openHistoryDetail">查看详情 ›</view>
</view> </view>
</view> </view>
</block> </block>
@@ -176,7 +197,7 @@
<view class="back" hover-class="back-hover" bindtap="backToOverview">‹ 返回</view> <view class="back" hover-class="back-hover" bindtap="backToOverview">‹ 返回</view>
<view class="detail-title-wrap"> <view class="detail-title-wrap">
<view class="head-title">{{selectedGroupName}}</view> <view class="head-title">{{selectedGroupName}}</view>
<view class="head-sub" bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}" hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}"> <view class="head-sub" bindtap="openPeriodPicker" hover-class="{{periodList.length > 0 ? 'head-hover' : 'none'}}">
{{periodKey}} <text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text> {{periodKey}} <text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
</view> </view>
</view> </view>
@@ -187,15 +208,36 @@
<view>加载中...</view> <view>加载中...</view>
</view> </view>
<view class="member-list"> <view class="member-list">
<view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member"> <view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member" wx:for-index="mi">
<view class="member-left"> <!-- 已提交者整行可点,全文与全部图片在详情弹层里看(未提交者 handler 内部会直接返回) -->
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{index + 1}}</view> <view
<view class="member-info"> class="member-row"
<view class="member-name">{{member.name}}</view> data-mi="{{mi}}"
<view class="member-meta" wx:if="{{member.filled && member.record}}">{{member.record.checked_at}} 提交</view> hover-class="{{member.filled ? 'member-row-hover' : 'none'}}"
bindtap="openMemberDetail"
>
<view class="member-left">
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{mi + 1}}</view>
<view class="member-info">
<view class="member-line">
<text class="member-name">{{member.name}}</text>
<text class="member-sid" wx:if="{{member.student_id}}">学号 {{member.student_id}}</text>
</view>
<view class="member-meta" wx:if="{{member.filled && member.record}}">{{member.record.checked_at}} 提交</view>
</view>
</view> </view>
<view class="badge {{member.filled ? 'done' : 'unchecked'}}">{{member.filled ? '已完成' : '未完成'}}</view>
</view> </view>
<view class="badge {{member.filled ? 'done' : 'unchecked'}}">{{member.filled ? '已完成' : '未完成'}}</view> <!-- 提示文字是 .member-row 的兄弟节点,必须自己绑事件,否则点它没有任何节点接收
(不要挪进 .member-row 里 —— tap 会冒泡到父行导致 openMemberDetail 执行两次,
同 tick 连发 setData 会触发 recursive update detected) -->
<view
class="tap-hint"
wx:if="{{member.filled}}"
data-mi="{{mi}}"
hover-class="tap-hint-hover"
bindtap="openMemberDetail"
>点击查看详情 ›</view>
</view> </view>
</view> </view>
<view class="empty" wx:if="{{!loading && members.length === 0}}">暂无成员</view> <view class="empty" wx:if="{{!loading && members.length === 0}}">暂无成员</view>
@@ -220,4 +262,34 @@
</view> </view>
</view> </view>
</block> </block>
<!-- 内容详情弹层:完整文章 + 全部图片 -->
<view class="cd-mask" wx:if="{{contentDetail}}" bindtap="closeContentDetail" catchtouchmove="noop"></view>
<view class="cd-panel" wx:if="{{contentDetail}}">
<view class="cd-head">
<view class="cd-head-text">
<view class="cd-title">{{contentDetail.title}}</view>
<view class="cd-sub" wx:if="{{contentDetail.sub}}">{{contentDetail.sub}}</view>
</view>
<view class="cd-close" hover-class="cd-close-hover" bindtap="closeContentDetail">✕</view>
</view>
<view class="cd-meta" wx:if="{{contentDetail.meta}}">{{contentDetail.meta}}</view>
<scroll-view class="cd-body" scroll-y>
<view class="cd-content" wx:if="{{contentDetail.content}}">{{contentDetail.content}}</view>
<view class="cd-no-content" wx:if="{{!contentDetail.content}}">未填写文字内容</view>
<view class="cd-thumbs" wx:if="{{contentDetail.thumbs.length > 0}}">
<image
class="cd-thumb"
wx:for="{{contentDetail.thumbs}}"
wx:for-item="pic"
wx:for-index="ci"
wx:key="*this"
src="{{pic}}"
mode="widthFix"
data-ci="{{ci}}"
bindtap="previewDetailImage"
></image>
</view>
</scroll-view>
</view>
</view> </view>

View File

@@ -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-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-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: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 { 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.done { background: linear-gradient(135deg, #E8F4EB, #CFEAD5); color: #2E7D32; }
.member-avatar.unchecked { background: linear-gradient(135deg, #FDECEA, #F9D9D5); color: #C0392B; } .member-avatar.unchecked { background: linear-gradient(135deg, #FDECEA, #F9D9D5); color: #C0392B; }
.member-info { margin-left: 18rpx; min-width: 0; } .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; } .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 { flex-shrink: 0; padding: 8rpx 18rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 700; }
.badge.done { background: #E8F4EB; color: #2E7D32; } .badge.done { background: #E8F4EB; color: #2E7D32; }
@@ -158,6 +162,7 @@
font-size: 22rpx; font-size: 22rpx;
color: #A99A89; color: #A99A89;
} }
/* 历史卡片内的正文预览:最多 3 行,超出截断 */
.history-content { .history-content {
margin-top: 14rpx; margin-top: 14rpx;
padding: 16rpx 20rpx; padding: 16rpx 20rpx;
@@ -173,6 +178,7 @@
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
overflow: hidden; overflow: hidden;
} }
/* 历史卡片内的缩略图:最多 2 张,其余进详情弹层 */
.history-thumbs { .history-thumbs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -180,8 +186,8 @@
margin-top: 14rpx; margin-top: 14rpx;
} }
.history-thumb { .history-thumb {
width: 120rpx; width: 148rpx;
height: 120rpx; height: 148rpx;
border-radius: 12rpx; border-radius: 12rpx;
background: #F2EDE7; background: #F2EDE7;
} }
@@ -205,6 +211,7 @@
max-height: 70vh; max-height: 70vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
transform: translateY(100%); transform: translateY(100%);
transition: transform .28s ease; transition: transform .28s ease;
} }
@@ -220,9 +227,11 @@
text-align: center; text-align: center;
border-bottom: 1rpx solid #F2EDE7; border-bottom: 1rpx solid #F2EDE7;
} }
/* flex:1 撑满剩余高度,max-height 兜底(同 .cd-body,防 flex 高度解析失败后滚不动) */
.picker-list { .picker-list {
flex: 1; flex: 1 1 auto;
min-height: 0; min-height: 0;
max-height: 64vh;
padding: 8rpx 0 32rpx; padding: 8rpx 0 32rpx;
} }
.picker-item { .picker-item {
@@ -246,4 +255,85 @@
} }
/* 底部留白 */ /* 底部留白 */
.bottom-space { height: 40rpx; } .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;
}

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