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

@@ -2,7 +2,7 @@
<template name="top-card">
<view class="card top-card {{checked ? 'done-card' : ''}}">
<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-num">{{dayNum}}<text class="date-arrow" wx:if="{{weekList.length > 0}}"> ▾</text></view>
</view>
@@ -38,7 +38,10 @@
<view class="member {{item.checked ? 'ok' : 'idle'}}">
<view class="member-avatar {{item.checked ? 'ok' : ''}}">{{item.shortName}}</view>
<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>
<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="detail-title-wrap">
<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>
</view>
</view>
@@ -163,7 +166,7 @@
<view class="empty-text">本周暂无打卡记录</view>
</view>
<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>
</block>
</view>

View File

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

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

View File

@@ -6,7 +6,7 @@
<view
class="type-tab {{typeIndex === index ? 'active' : ''}}"
wx:for="{{types}}"
wx:key="index"
wx:key="*this"
data-index="{{index}}"
hover-class="type-tab-hover"
bindtap="onTypeChange"
@@ -24,7 +24,7 @@
<view class="overview-head">
<view class="head-title-wrap">
<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}}
<text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
</view>
@@ -93,8 +93,8 @@
<view
class="group-period-row"
wx:if="{{myGroup}}"
bindtap="{{periodList.length > 0 ? 'openPeriodPicker' : ''}}"
hover-class="{{periodList.length > 0 ? 'head-hover' : ''}}"
bindtap="openPeriodPicker"
hover-class="{{periodList.length > 0 ? 'head-hover' : 'none'}}"
>
<text class="group-period-text">{{myGroup}} · {{periodKey}}</text>
<text class="head-arrow" wx:if="{{periodList.length > 0}}"> ▾</text>
@@ -103,16 +103,22 @@
<view class="state-icon">◌</view>
<view>加载中...</view>
</view>
<!-- 学员小组 tab:只展示成员信息,不展示任何人的打卡内容,也不提供详情入口 -->
<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-left">
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{index + 1}}</view>
<view class="member-info">
<view class="member-name">{{member.name}}</view>
<view class="member-meta" wx:if="{{member.filled && member.record}}">{{member.record.checked_at}} 提交</view>
<view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member" wx:for-index="mi">
<view class="member-row">
<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 class="badge {{member.filled ? 'done' : 'unchecked'}}">{{member.filled ? '已完成' : '未完成'}}</view>
</view>
<view class="badge {{member.filled ? 'done' : 'unchecked'}}">{{member.filled ? '已完成' : '未完成'}}</view>
</view>
</view>
<view class="empty" wx:elif="{{!loading && !myGroup}}">
@@ -131,7 +137,13 @@
<view class="empty-icon">📒</view>
<view class="empty-text">暂无{{types[typeIndex]}}记录</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-period">{{item.period_key}}</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-content" wx:if="{{item.content}}">{{item.content}}</view>
<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 class="tap-hint" data-hi="{{hi}}" hover-class="tap-hint-hover" bindtap="openHistoryDetail">查看详情 ›</view>
</view>
</view>
</block>
@@ -176,7 +197,7 @@
<view class="back" hover-class="back-hover" bindtap="backToOverview">‹ 返回</view>
<view class="detail-title-wrap">
<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>
</view>
</view>
@@ -187,15 +208,36 @@
<view>加载中...</view>
</view>
<view class="member-list">
<view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member">
<view class="member-left">
<view class="member-avatar {{member.filled ? 'done' : 'unchecked'}}">{{index + 1}}</view>
<view class="member-info">
<view class="member-name">{{member.name}}</view>
<view class="member-meta" wx:if="{{member.filled && member.record}}">{{member.record.checked_at}} 提交</view>
<view class="member" wx:for="{{members}}" wx:key="user_id" wx:for-item="member" wx:for-index="mi">
<!-- 已提交者整行可点,全文与全部图片在详情弹层里看(未提交者 handler 内部会直接返回) -->
<view
class="member-row"
data-mi="{{mi}}"
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 class="badge {{member.filled ? 'done' : 'unchecked'}}">{{member.filled ? '已完成' : '未完成'}}</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 class="empty" wx:if="{{!loading && members.length === 0}}">暂无成员</view>
@@ -220,4 +262,34 @@
</view>
</view>
</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 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-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; }
.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;
}