feat(database): 迁移用户关联字段并优化打卡系统
- 修改数据库配置将MYSQL_DB从mys_info_v3改为mys_info_test - 新增auth_users表wechat_openid字段并建立唯一索引 - 为plan_checkins表添加计划性能索引(idx_plan_period_student) - 将checkin_records_old表关联方式从student_id改为user_id - 将plan_checkins表关联方式从student_id改为user_id并更新附件路径 - 移除routes/auth.py中的uuid导入使用安全token生成函数 - 重构routes/checkin.py打卡逻辑去除course_type字段并优化返回结构 - 添加打卡时间限制验证功能防止过期打卡操作 - 实现小组统计功能支持老师查看各组打卡情况 - 更新主页index.html界面适配新的打卡和计划功能 - 调整计划提交时间规则为周五至周四周期 - 添加月度和年度计划提交状态验证机制
This commit is contained in:
@@ -43,7 +43,6 @@ function fmtTime(ts) {
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statuses: STATUSES,
|
||||
statusItems: STATUS_ITEMS,
|
||||
statusIndex: -1,
|
||||
loading: true,
|
||||
@@ -53,16 +52,34 @@ Page({
|
||||
checked: false,
|
||||
record: null,
|
||||
statusColor: '',
|
||||
statusBg: '',
|
||||
history: [],
|
||||
group: [],
|
||||
groupName: '',
|
||||
// 视图模式:overview 总览 / detail 小组详情(仅老师)
|
||||
view: 'overview',
|
||||
// 当前选中的小组(详情页用)
|
||||
selectedGroupName: '',
|
||||
// 小组统计列表(老师总览用)
|
||||
groups: [],
|
||||
// 小组成员列表(详情页 & 学员小组 tab 用)
|
||||
members: [],
|
||||
checkedCount: 0,
|
||||
total: 0,
|
||||
// 用户角色
|
||||
isTeacher: false,
|
||||
myGroup: '',
|
||||
unassignedCount: 0,
|
||||
// 学员视图 tab:group / personal
|
||||
tab: 'group',
|
||||
// 个人历史
|
||||
history: [],
|
||||
historyPage: 1,
|
||||
historyTotal: 0,
|
||||
historyFinished: false,
|
||||
historyLoading: false,
|
||||
submitting: false,
|
||||
refreshing: false,
|
||||
loadError: '',
|
||||
// 周选择相关
|
||||
weekList: [],
|
||||
showWeekPicker: false,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -70,7 +87,7 @@ Page({
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadPage().then(() => wx.stopPullDownRefresh());
|
||||
this.loadPage().finally(() => wx.stopPullDownRefresh());
|
||||
},
|
||||
|
||||
loadPage() {
|
||||
@@ -81,6 +98,10 @@ Page({
|
||||
wx.switchTab({ url: '/pages/profile/profile' });
|
||||
return;
|
||||
}
|
||||
// 先加载周列表,再加载当前周数据
|
||||
return this.loadWeeks();
|
||||
})
|
||||
.then(() => {
|
||||
return this.loadWeek();
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -94,36 +115,120 @@ Page({
|
||||
this.loadPage();
|
||||
},
|
||||
|
||||
/** 加载可用周列表 */
|
||||
loadWeeks() {
|
||||
return api.request({ url: '/api/v1/checkin/weeks' }).then((res) => {
|
||||
const weeks = res.weeks || [];
|
||||
const current = res.current_week || (weeks[0] || '');
|
||||
this.setData({
|
||||
weekList: weeks,
|
||||
classDate: this.data.classDate || current,
|
||||
});
|
||||
}).catch(() => {
|
||||
// 获取周列表失败不影响主流程
|
||||
console.warn('获取上课周列表失败');
|
||||
});
|
||||
},
|
||||
|
||||
/** 打开周选择器 */
|
||||
openWeekPicker() {
|
||||
if (!this.data.weekList.length) {
|
||||
return;
|
||||
}
|
||||
this.setData({ showWeekPicker: true });
|
||||
},
|
||||
|
||||
/** 关闭周选择器 */
|
||||
closeWeekPicker() {
|
||||
this.setData({ showWeekPicker: false });
|
||||
},
|
||||
|
||||
/** 选择某一周 */
|
||||
onWeekSelect(e) {
|
||||
const classDate = e.currentTarget.dataset.date;
|
||||
if (!classDate || classDate === this.data.classDate) {
|
||||
this.setData({ showWeekPicker: false });
|
||||
return;
|
||||
}
|
||||
this.setData({
|
||||
classDate,
|
||||
showWeekPicker: false,
|
||||
});
|
||||
this.loadWeek();
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载当前周数据:
|
||||
* - 老师:加载小组统计总览
|
||||
* - 学员:加载小组统计 + 本组成员列表 + 个人历史(个人 tab 延迟加载)
|
||||
*/
|
||||
loadWeek() {
|
||||
this.setData({ loading: true });
|
||||
return api.request({ url: '/api/v1/checkin/week' }).then((res) => {
|
||||
const classDate = res.class_date || '';
|
||||
const weekday = WEEKDAYS[new Date(classDate + 'T00:00:00').getDay()] || '';
|
||||
const dayNum = classDate.split('-')[2] || '';
|
||||
const classDate = this.data.classDate;
|
||||
const params = classDate ? { class_date: classDate } : {};
|
||||
return api.request({ url: '/api/v1/checkin/groups', data: params }).then((res) => {
|
||||
const date = res.class_date || '';
|
||||
const weekday = WEEKDAYS[new Date(date + 'T00:00:00').getDay()] || '';
|
||||
const dayNum = date.split('-')[2] || '';
|
||||
const statusIndex = res.record && res.record.status
|
||||
? Math.max(0, STATUSES.indexOf(res.record.status))
|
||||
: -1;
|
||||
const statusColor = res.record ? (STATUS_META[res.record.status] || '#C67B30') : '';
|
||||
this.setData({
|
||||
classDate,
|
||||
const isTeacher = !!res.is_teacher;
|
||||
|
||||
// 计算每个小组的百分比
|
||||
const groups = (res.groups || []).map((g) => ({
|
||||
...g,
|
||||
percent: g.total ? Math.round(((g.checked_count || 0) / g.total) * 100) : 0
|
||||
}));
|
||||
|
||||
const baseData = {
|
||||
classDate: date,
|
||||
weekday,
|
||||
dayNum,
|
||||
checked: !!res.checked,
|
||||
record: res.record || null,
|
||||
statusIndex,
|
||||
statusColor,
|
||||
statusBg: statusColor ? statusColor + '1A' : ''
|
||||
});
|
||||
return this.loadGroup();
|
||||
}).then(() => {
|
||||
isTeacher,
|
||||
myGroup: res.my_group || '',
|
||||
unassignedCount: res.unassigned_count || 0,
|
||||
groups,
|
||||
};
|
||||
|
||||
// 如果当前在详情页(老师),重新加载该组详情
|
||||
if (isTeacher && this.data.view === 'detail' && this.data.selectedGroupName) {
|
||||
this.setData({ ...baseData, members: [], loading: true });
|
||||
return this.loadGroup(this.data.selectedGroupName).then(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
}
|
||||
|
||||
this.setData(baseData);
|
||||
|
||||
if (!isTeacher) {
|
||||
// 学员:先设置兜底的小组名(避免/group失败时显示"暂无小组")
|
||||
if (res.my_group) {
|
||||
this.setData({ groupName: res.my_group });
|
||||
}
|
||||
// 学员:加载本组成员列表
|
||||
return this.loadGroup(res.my_group || '').then(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
}
|
||||
|
||||
this.setData({ loading: false });
|
||||
}).catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
loadGroup() {
|
||||
const data = this.data.classDate ? { class_date: this.data.classDate } : {};
|
||||
/** 加载指定小组的成员列表,不传则看自己小组 */
|
||||
loadGroup(groupName) {
|
||||
const data = { class_date: this.data.classDate };
|
||||
if (groupName) {
|
||||
data.group_name = groupName;
|
||||
}
|
||||
return api.request({ url: '/api/v1/checkin/group', data }).then((res) => {
|
||||
const members = (res.members || []).map((m) => ({
|
||||
...m,
|
||||
@@ -132,17 +237,57 @@ Page({
|
||||
}));
|
||||
const checkedCount = members.filter((m) => m.checked).length;
|
||||
this.setData({
|
||||
group: members,
|
||||
groupName: res.group_name || '',
|
||||
members,
|
||||
groupName: res.group_name || groupName || '',
|
||||
checkedCount,
|
||||
total: members.length
|
||||
});
|
||||
}).catch(() => {});
|
||||
}).catch((err) => {
|
||||
console.warn('加载小组成员失败:', err);
|
||||
wx.showToast({ title: '加载小组成员失败', icon: 'none' });
|
||||
});
|
||||
},
|
||||
|
||||
/** 老师:打开小组详情 */
|
||||
openGroup(e) {
|
||||
const groupName = e.currentTarget.dataset.group;
|
||||
if (!groupName) return;
|
||||
this.setData({ selectedGroupName: groupName, view: 'detail', members: [], loading: true });
|
||||
this.loadGroup(groupName).then(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
/** 老师:返回总览 */
|
||||
backToOverview() {
|
||||
this.setData({ view: 'overview', selectedGroupName: '', members: [] });
|
||||
},
|
||||
|
||||
/** 加载个人历史(第一页) */
|
||||
loadHistory() {
|
||||
return api.request({ url: '/api/v1/checkin/list', data: { page: 1, size: 20 } }).then((res) => {
|
||||
const history = (res.list || []).map((item) => {
|
||||
this.setData({ historyPage: 1, historyFinished: false, historyLoading: true });
|
||||
return this._loadHistoryPage(1, true).then(() => {
|
||||
this.setData({ historyLoading: false });
|
||||
}).catch(() => {
|
||||
this.setData({ historyLoading: false });
|
||||
});
|
||||
},
|
||||
|
||||
/** 加载更多历史(翻页) */
|
||||
loadMoreHistory() {
|
||||
if (this.data.historyLoading || this.data.historyFinished) return;
|
||||
const nextPage = this.data.historyPage + 1;
|
||||
this.setData({ historyLoading: true });
|
||||
return this._loadHistoryPage(nextPage, false).then(() => {
|
||||
this.setData({ historyLoading: false, historyPage: nextPage });
|
||||
}).catch(() => {
|
||||
this.setData({ historyLoading: false });
|
||||
});
|
||||
},
|
||||
|
||||
_loadHistoryPage(page, replace) {
|
||||
return api.request({ url: '/api/v1/checkin/list', data: { page, size: 20 } }).then((res) => {
|
||||
const list = (res.list || []).map((item) => {
|
||||
const color = STATUS_META[item.status] || '#C67B30';
|
||||
return {
|
||||
...item,
|
||||
@@ -151,10 +296,14 @@ Page({
|
||||
bg: color + '1A'
|
||||
};
|
||||
});
|
||||
this.setData({ history });
|
||||
}).catch(() => {});
|
||||
const total = res.total || 0;
|
||||
const history = replace ? list : this.data.history.concat(list);
|
||||
const finished = history.length >= total;
|
||||
this.setData({ history, historyTotal: total, historyFinished: finished });
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
onStatusTap(e) {
|
||||
if (this.data.submitting) {
|
||||
return;
|
||||
@@ -172,15 +321,56 @@ Page({
|
||||
data: {
|
||||
status,
|
||||
class_date: this.data.classDate,
|
||||
course_type: '会员课程'
|
||||
}
|
||||
}).then((res) => {
|
||||
const record = res.record || null;
|
||||
const statusColor = record ? (STATUS_META[record.status] || '#C67B30') : '';
|
||||
this.setData({ submitting: false, checked: true, record, statusColor, statusBg: statusColor ? statusColor + '1A' : '' });
|
||||
this.setData({ submitting: false, checked: true, record, statusColor });
|
||||
wx.showToast({ title: '打卡成功', icon: 'success' });
|
||||
this.loadGroup();
|
||||
if (this.data.tab === 'personal') {
|
||||
// 学员:本地更新 members 中自己的记录,避免全量 reload
|
||||
if (!this.data.isTeacher && this.data.members.length > 0) {
|
||||
const myUserId = record && record.user_id;
|
||||
if (myUserId) {
|
||||
const members = this.data.members.map((m) => {
|
||||
if (m.user_id === myUserId) {
|
||||
return {
|
||||
...m,
|
||||
checked: true,
|
||||
record: { status: record.status, checked_at: record.checked_at, note: record.note || '' },
|
||||
timeText: fmtTime(record.checked_at),
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
const checkedCount = members.filter((m) => m.checked).length;
|
||||
this.setData({ members, checkedCount });
|
||||
}
|
||||
}
|
||||
// 老师视图:刷新小组统计(需要更新各小组计数)
|
||||
if (this.data.isTeacher) {
|
||||
this.loadWeek();
|
||||
}
|
||||
// 个人历史 tab:本地插入最新记录,避免全量 reload
|
||||
if (!this.data.isTeacher && this.data.tab === 'personal' && record && this.data.history.length > 0) {
|
||||
const color = STATUS_META[record.status] || '#C67B30';
|
||||
const newItem = {
|
||||
...record,
|
||||
timeText: fmtTime(record.checked_at),
|
||||
color,
|
||||
bg: color + '1A',
|
||||
};
|
||||
// 检查是否已有同一天的记录(更新而非插入)
|
||||
const existingIdx = this.data.history.findIndex((h) => h.class_date === record.class_date);
|
||||
let history;
|
||||
if (existingIdx >= 0) {
|
||||
history = this.data.history.slice();
|
||||
history[existingIdx] = newItem;
|
||||
} else {
|
||||
history = [newItem].concat(this.data.history);
|
||||
}
|
||||
const historyTotal = Math.max(this.data.historyTotal, this.data.historyTotal + (existingIdx >= 0 ? 0 : 1));
|
||||
this.setData({ history, historyTotal });
|
||||
} else if (!this.data.isTeacher && this.data.tab === 'personal') {
|
||||
this.loadHistory();
|
||||
}
|
||||
}).catch((err) => {
|
||||
@@ -189,6 +379,7 @@ Page({
|
||||
});
|
||||
},
|
||||
|
||||
/** 学员 tab 切换 */
|
||||
switchTab(e) {
|
||||
const tab = e.currentTarget.dataset.tab;
|
||||
if (tab !== 'group' && tab !== 'personal') {
|
||||
@@ -196,16 +387,50 @@ Page({
|
||||
}
|
||||
this.setData({ tab });
|
||||
if (tab === 'personal') {
|
||||
// 每次切到个人 tab 都刷新历史数据
|
||||
this.loadHistory();
|
||||
} else if (tab === 'group' && this.data.myGroup) {
|
||||
// 每次切回小组 tab 都刷新小组成员数据
|
||||
this.loadGroup(this.data.myGroup);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
onRefresh() {
|
||||
this.setData({ refreshing: true });
|
||||
const task = this.data.tab === 'group' ? this.loadWeek() : this.loadHistory();
|
||||
Promise.resolve(task).then(() => {
|
||||
this.loadWeek().then(() => {
|
||||
// 学员个人 tab 也刷新一下
|
||||
if (!this.data.isTeacher && this.data.tab === 'personal') {
|
||||
return this.loadHistory();
|
||||
}
|
||||
}).then(() => {
|
||||
this.setData({ refreshing: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ---- 分享 ----
|
||||
onShareAppMessage() {
|
||||
const checked = this.data.checked;
|
||||
const status = this.data.record && this.data.record.status;
|
||||
const title = checked
|
||||
? '我今天' + status + '啦,一起在明缘云道场打卡吧'
|
||||
: '明缘云道场 · 每日打卡,共同成长';
|
||||
return {
|
||||
title,
|
||||
path: '/pages/checkin/checkin'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '明缘云道场 · 每周打卡,共同进步',
|
||||
query: 'from=timeline'
|
||||
};
|
||||
},
|
||||
|
||||
/** 上拉加载更多(个人历史 tab) */
|
||||
onReachBottom() {
|
||||
if (!this.data.isTeacher && this.data.tab === 'personal') {
|
||||
this.loadMoreHistory();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,50 @@
|
||||
<!-- 顶部打卡卡模板 -->
|
||||
<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-week">{{weekday}}</view>
|
||||
<view class="date-num">{{dayNum}}<text class="date-arrow" wx:if="{{weekList.length > 0}}"> ▾</text></view>
|
||||
</view>
|
||||
<view class="status-main">
|
||||
<view class="status-title {{checked ? 'done' : 'undone'}}">{{checked ? '已打卡' : '未打卡'}}</view>
|
||||
<view class="status-sub">{{checked ? '已完成今日打卡' : '请选择状态完成打卡'}}</view>
|
||||
<view class="hero-status" wx:if="{{checked}}">
|
||||
<view class="hero-status-dot" style="background-color:{{statusColor}}"></view>
|
||||
<text class="hero-status-label" style="color:{{statusColor}}">{{record.status}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pills-grid {{submitting ? 'disabled' : ''}}">
|
||||
<view
|
||||
wx:for="{{statusItems}}"
|
||||
wx:key="label"
|
||||
class="pill {{statusIndex === index ? 'active' : ''}}"
|
||||
style="background-color:{{statusIndex === index ? item.color : '#ffffff'}};border-color:{{statusIndex === index ? item.color : '#D8C7B4'}};color:{{statusIndex === index ? '#ffffff' : '#5A4A3A'}}"
|
||||
data-index="{{index}}"
|
||||
hover-class="pill-hover"
|
||||
bindtap="onStatusTap"
|
||||
>
|
||||
<view class="dot" style="background-color:{{statusIndex === index ? '#ffffff' : item.color}}"></view>
|
||||
<text>{{item.label}}</text>
|
||||
</view>
|
||||
<view class="submitting" wx:if="{{submitting}}">提交中…</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 成员列表项模板 -->
|
||||
<template name="member-item">
|
||||
<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-meta">{{item.checked ? ('打卡 ' + item.timeText) : ''}}</view>
|
||||
</view>
|
||||
<view class="member-badge {{item.checked ? 'ok' : 'idle'}}">{{item.checked ? item.record.status : '未打卡'}}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<block wx:if="{{loadError}}">
|
||||
<view class="page">
|
||||
@@ -10,7 +57,7 @@
|
||||
</block>
|
||||
|
||||
<!-- 首次加载骨架屏 -->
|
||||
<block wx:if="{{loading}}">
|
||||
<block wx:if="{{loading && view === 'overview'}}">
|
||||
<view class="page">
|
||||
<view class="skeleton-card">
|
||||
<view class="sk sk-title"></view>
|
||||
@@ -23,86 +70,141 @@
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<block wx:elif="{{!loading}}">
|
||||
<view class="page fade-in">
|
||||
<view class="card top-card {{checked ? 'done-card' : ''}}">
|
||||
<view class="status-row">
|
||||
<view class="date-chip">
|
||||
<view class="date-week">{{weekday}}</view>
|
||||
<view class="date-num">{{dayNum}}</view>
|
||||
</view>
|
||||
<view class="status-main">
|
||||
<view class="status-title {{checked ? 'done' : 'undone'}}">{{checked ? '已打卡' : '未打卡'}}</view>
|
||||
<view class="status-sub">{{checked ? '已完成今日打卡' : '请选择状态完成打卡'}}</view>
|
||||
<view class="hero-status" wx:if="{{checked}}">
|
||||
<view class="hero-status-dot" style="background-color:{{statusColor}}"></view>
|
||||
<text class="hero-status-label" style="color:{{statusColor}}">{{record.status}}</text>
|
||||
<!-- 老师总览视图 -->
|
||||
<block wx:if="{{isTeacher && view === 'overview'}}">
|
||||
<!-- 顶部打卡卡 -->
|
||||
<template is="top-card" data="{{checked,weekList,weekday,dayNum,statusColor,record,statusItems,statusIndex,submitting}}"></template>
|
||||
|
||||
<!-- 小组统计列表 -->
|
||||
<view class="record-panel">
|
||||
<view class="section-title">小组统计</view>
|
||||
<scroll-view class="record-scroll" scroll-y refresher-enabled refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
|
||||
<view class="groups-list">
|
||||
<view class="group-card" hover-class="group-card-hover" wx:for="{{groups}}" wx:key="group_name" wx:for-item="group">
|
||||
<view class="group-main" data-group="{{group.group_name}}" bindtap="openGroup">
|
||||
<view class="group-row">
|
||||
<view class="group-avatar">{{index + 1}}</view>
|
||||
<view class="group-name">{{group.group_name}}</view>
|
||||
<view class="group-counts">
|
||||
<text class="count done">已打卡 {{group.checked_count}}</text>
|
||||
<text class="count total">{{group.total}}人</text>
|
||||
</view>
|
||||
<view class="chevron">›</view>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-bar" style="width: {{group.percent}}%"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="unassigned" wx:if="{{unassignedCount > 0}}">另有未分组 {{unassignedCount}} 人</view>
|
||||
<view class="empty" wx:if="{{groups.length === 0}}">
|
||||
<view class="empty-icon">👥</view>
|
||||
<view class="empty-text">暂无小组</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="bottom-space"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 老师详情视图 -->
|
||||
<block wx:elif="{{isTeacher && view === 'detail'}}">
|
||||
<view class="detail-head">
|
||||
<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' : ''}}">
|
||||
{{classDate}} <text class="head-arrow" wx:if="{{weekList.length > 0}}"> ▾</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-progress">{{checkedCount}}/{{total}}</view>
|
||||
</view>
|
||||
|
||||
<view class="pills-grid {{submitting ? 'disabled' : ''}}">
|
||||
<scroll-view class="detail-scroll" scroll-y refresher-enabled refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
|
||||
<view class="state-block" wx:if="{{loading}}">
|
||||
<view class="state-icon">◌</view>
|
||||
<view>加载中...</view>
|
||||
</view>
|
||||
<view class="member-list" wx:else>
|
||||
<block wx:for="{{members}}" wx:key="user_id" wx:for-item="item">
|
||||
<template is="member-item" data="{{item: item}}"></template>
|
||||
</block>
|
||||
</view>
|
||||
<view class="empty" wx:if="{{!loading && members.length === 0}}">暂无成员</view>
|
||||
<view class="bottom-space"></view>
|
||||
</scroll-view>
|
||||
</block>
|
||||
|
||||
<!-- 学员视图 -->
|
||||
<block wx:else>
|
||||
<!-- 顶部打卡卡 -->
|
||||
<template is="top-card" data="{{checked,weekList,weekday,dayNum,statusColor,record,statusItems,statusIndex,submitting}}"></template>
|
||||
|
||||
<!-- 记录区:小组/个人 tab -->
|
||||
<view class="record-panel">
|
||||
<view class="tabs">
|
||||
<view class="tab {{tab === 'group' ? 'active' : ''}}" data-tab="group" hover-class="tab-hover" bindtap="switchTab">小组</view>
|
||||
<view class="tab {{tab === 'personal' ? 'active' : ''}}" data-tab="personal" hover-class="tab-hover" bindtap="switchTab">个人</view>
|
||||
</view>
|
||||
<scroll-view class="record-scroll" scroll-y refresher-enabled refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
|
||||
<view class="record-inner" wx:if="{{tab === 'group'}}">
|
||||
<view class="group-head" wx:if="{{groupName}}">
|
||||
<view class="group-title">{{groupName}} · {{classDate}}</view>
|
||||
<view class="group-progress">{{checkedCount}}/{{total}} 已打卡</view>
|
||||
</view>
|
||||
<view class="empty" wx:if="{{!groupName}}">
|
||||
<view class="empty-icon">👥</view>
|
||||
<view class="empty-text">暂无小组,请联系管理员分配</view>
|
||||
</view>
|
||||
<view class="empty" wx:elif="{{members.length === 0}}">
|
||||
<view class="empty-icon">📭</view>
|
||||
<view class="empty-text">本周暂无打卡记录</view>
|
||||
</view>
|
||||
<view class="member-list" wx:else>
|
||||
<block wx:for="{{members}}" wx:key="student_id" wx:for-item="item">
|
||||
<template is="member-item" data="{{item: item}}"></template>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="record-inner">
|
||||
<view class="empty" wx:if="{{history.length === 0}}">
|
||||
<view class="empty-icon">📒</view>
|
||||
<view class="empty-text">暂无个人打卡记录</view>
|
||||
</view>
|
||||
<view class="history-item" wx:for="{{history}}" wx:key="id">
|
||||
<view class="history-head">
|
||||
<view class="history-date">{{item.class_date}}</view>
|
||||
<view class="history-badge" style="color:{{item.color}};background:{{item.bg}}">{{item.status}}</view>
|
||||
</view>
|
||||
<view class="history-time">{{item.timeText}}</view>
|
||||
</view>
|
||||
<view class="history-footer" wx:if="{{history.length > 0}}">
|
||||
<text wx:if="{{historyLoading}}">加载中…</text>
|
||||
<text wx:elif="{{historyFinished}}">— 已加载全部 —</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 周选择弹层 -->
|
||||
<view class="picker-mask" wx:if="{{showWeekPicker}}" bindtap="closeWeekPicker"></view>
|
||||
<view class="picker-panel {{showWeekPicker ? 'show' : ''}}" wx:if="{{showWeekPicker}}">
|
||||
<view class="picker-title">选择上课周</view>
|
||||
<scroll-view class="picker-list" scroll-y>
|
||||
<view
|
||||
wx:for="{{statusItems}}"
|
||||
wx:key="label"
|
||||
class="pill {{statusIndex === index ? 'active' : ''}}"
|
||||
style="background-color:{{statusIndex === index ? item.color : '#ffffff'}};border-color:{{statusIndex === index ? item.color : '#D8C7B4'}};color:{{statusIndex === index ? '#ffffff' : '#5A4A3A'}}"
|
||||
data-index="{{index}}"
|
||||
hover-class="pill-hover"
|
||||
bindtap="onStatusTap"
|
||||
class="picker-item {{item === classDate ? 'active' : ''}}"
|
||||
wx:for="{{weekList}}"
|
||||
wx:key="*this"
|
||||
data-date="{{item}}"
|
||||
bindtap="onWeekSelect"
|
||||
>
|
||||
<view class="dot" style="background-color:{{statusIndex === index ? '#ffffff' : item.color}}"></view>
|
||||
<text>{{item.label}}</text>
|
||||
</view>
|
||||
<view class="submitting" wx:if="{{submitting}}">提交中…</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<view class="record-panel">
|
||||
<view class="tabs">
|
||||
<view class="tab {{tab === 'group' ? 'active' : ''}}" data-tab="group" hover-class="tab-hover" bindtap="switchTab">小组</view>
|
||||
<view class="tab {{tab === 'personal' ? 'active' : ''}}" data-tab="personal" hover-class="tab-hover" bindtap="switchTab">个人</view>
|
||||
</view>
|
||||
<scroll-view class="record-scroll" scroll-y refresher-enabled refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
|
||||
<view class="record-inner" wx:if="{{tab === 'group'}}">
|
||||
<view class="group-head" wx:if="{{groupName}}">
|
||||
<view class="group-title">{{groupName}} · {{classDate}}</view>
|
||||
<view class="group-progress">{{checkedCount}}/{{total}} 已打卡</view>
|
||||
</view>
|
||||
<view class="empty" wx:if="{{!groupName}}">
|
||||
<view class="empty-icon">👥</view>
|
||||
<view class="empty-text">暂无小组,请联系管理员分配</view>
|
||||
</view>
|
||||
<view class="empty" wx:elif="{{group.length === 0}}">
|
||||
<view class="empty-icon">📭</view>
|
||||
<view class="empty-text">本周暂无打卡记录</view>
|
||||
</view>
|
||||
<view wx:for="{{group}}" wx:key="student_id" 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-meta">{{item.checked ? ('打卡 ' + item.timeText) : ''}}</view>
|
||||
</view>
|
||||
<view class="member-badge {{item.checked ? 'ok' : 'idle'}}">{{item.checked ? item.record.status : '未打卡'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="record-inner">
|
||||
<view class="empty" wx:if="{{history.length === 0}}">
|
||||
<view class="empty-icon">📒</view>
|
||||
<view class="empty-text">暂无个人打卡记录</view>
|
||||
</view>
|
||||
<view class="history-item" wx:for="{{history}}" wx:key="id">
|
||||
<view class="history-head">
|
||||
<view class="history-date">{{item.class_date}}</view>
|
||||
<view class="history-badge" style="color:{{item.color}};background:{{item.bg}}">{{item.status}}</view>
|
||||
</view>
|
||||
<view class="history-time">{{item.timeText}}</view>
|
||||
</view>
|
||||
<text>{{item}}</text>
|
||||
<text class="picker-check" wx:if="{{item === classDate}}">✓</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
|
||||
</block>
|
||||
@@ -313,6 +313,16 @@
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
.history-note {
|
||||
margin-top: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #faf6f0;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #5A4A3A;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 空态 */
|
||||
.empty {
|
||||
@@ -390,5 +400,168 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 日期 chip 可点击 */
|
||||
.date-chip-hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.date-arrow {
|
||||
font-size: 24rpx;
|
||||
color: #C67B30;
|
||||
margin-left: 6rpx;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ---- 老师视图:小组统计卡片 ---- */
|
||||
.groups-list {
|
||||
padding: 4rpx 4rpx 24rpx;
|
||||
}
|
||||
.group-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
margin-bottom: 18rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(44, 24, 16, 0.05);
|
||||
}
|
||||
.group-card-hover { transform: scale(.99); box-shadow: 0 10rpx 24rpx rgba(44, 24, 16, 0.10); }
|
||||
.group-row { display: flex; align-items: center; gap: 16rpx; }
|
||||
.group-avatar {
|
||||
width: 52rpx; height: 52rpx; border-radius: 14rpx;
|
||||
background: linear-gradient(135deg, #F3E3CF, #FBE7D1);
|
||||
color: #C67B30; font-size: 26rpx; font-weight: 800;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.group-name { font-size: 30rpx; font-weight: 800; color: #2C1810; flex-shrink: 0; }
|
||||
.chevron { color: #CDBFAE; font-size: 32rpx; line-height: 1; flex-shrink: 0; transition: transform .15s ease; }
|
||||
.group-card-hover .chevron { transform: translateX(4rpx); }
|
||||
.group-counts { display: flex; align-items: center; justify-content: flex-end; gap: 10rpx; flex: 1; min-width: 0; }
|
||||
.count { font-size: 22rpx; white-space: nowrap; }
|
||||
.count.done { color: #2E7D32; background: #E8F4EB; padding: 6rpx 12rpx; border-radius: 999rpx; font-weight: 700; }
|
||||
.count.total { color: #C67B30; background: #FFF3E6; padding: 6rpx 12rpx; border-radius: 999rpx; font-weight: 700; }
|
||||
.progress-track {
|
||||
height: 10rpx; border-radius: 999rpx; background: #F2EDE7;
|
||||
margin-top: 18rpx; overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%; border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #C67B30, #EFA45D);
|
||||
transition: width .35s ease;
|
||||
box-shadow: 0 2rpx 6rpx rgba(198, 123, 48, 0.35);
|
||||
}
|
||||
.unassigned { margin: 4rpx 8rpx 20rpx; font-size: 24rpx; color: #8B7A6A; }
|
||||
|
||||
.section-title {
|
||||
padding: 16rpx 4rpx 12rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #6B5D50;
|
||||
}
|
||||
|
||||
/* ---- 老师详情视图 ---- */
|
||||
.detail-head {
|
||||
display: flex; align-items: center; gap: 16rpx;
|
||||
padding: 8rpx 4rpx 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-title-wrap { flex: 1; min-width: 0; }
|
||||
.back {
|
||||
color: #C67B30; font-size: 30rpx; flex-shrink: 0;
|
||||
padding: 10rpx 16rpx 10rpx 0; border-radius: 12rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.back-hover { opacity: .7; }
|
||||
.detail-title { font-size: 34rpx; font-weight: 800; color: #2C1810; }
|
||||
.detail-sub { margin-top: 6rpx; font-size: 24rpx; color: #A99A89; }
|
||||
.head-hover { opacity: .8; }
|
||||
.head-arrow { color: #C67B30; font-size: 22rpx; }
|
||||
.detail-progress {
|
||||
flex-shrink: 0;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 24rpx;
|
||||
background: #FFF3E6;
|
||||
color: #C67B30;
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-scroll {
|
||||
flex: 1; min-height: 0;
|
||||
padding: 0 4rpx;
|
||||
}
|
||||
.member-list {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(44, 24, 16, 0.05);
|
||||
}
|
||||
.bottom-space { height: 40rpx; }
|
||||
|
||||
.state-block { padding: 64rpx 0; text-align: center; color: #A99A89; font-size: 26rpx; }
|
||||
.state-icon { font-size: 40rpx; margin-bottom: 12rpx; color: #D8C7B4; animation: pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:.4} 50%{opacity:1} }
|
||||
|
||||
/* 周选择弹层 */
|
||||
.picker-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 100;
|
||||
}
|
||||
.picker-panel {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
background: #fff;
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
z-index: 101;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateY(100%);
|
||||
transition: transform .28s ease;
|
||||
}
|
||||
.picker-panel.show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.picker-title {
|
||||
flex-shrink: 0;
|
||||
padding: 28rpx 32rpx 20rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #2C1810;
|
||||
text-align: center;
|
||||
border-bottom: 1rpx solid #F2EDE7;
|
||||
}
|
||||
.picker-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8rpx 0 32rpx;
|
||||
}
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: #2C1810;
|
||||
}
|
||||
.picker-item:active {
|
||||
background: #FBF7F1;
|
||||
}
|
||||
.picker-item.active {
|
||||
color: #C67B30;
|
||||
font-weight: 700;
|
||||
}
|
||||
.picker-check {
|
||||
font-size: 28rpx;
|
||||
color: #C67B30;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 历史列表底部 */
|
||||
.history-footer {
|
||||
padding: 32rpx 0;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #B6A996;
|
||||
}
|
||||
Reference in New Issue
Block a user