Files
App.k12.Parents/pages/today-learning/index.uvue
T
2026-11-10 11:25:04 +08:00

304 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page-container">
<view class="header-panel">
<view class="header-back" @tap="goBack"><UniIcons type="back" class="header-back-icon"></UniIcons></view>
<view class="header-copy"><text class="header-title">今日学习</text><text class="header-subtitle">{{ headerSubtitle }}</text></view>
<view class="header-action" @tap="openStudentPicker"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view>
</view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true">
<view v-if="isLoading" class="status-panel"><text class="status-text">今日学习信息加载中</text></view>
<view v-else class="content-panel">
<view class="overview-card">
<view class="overview-header"><text class="section-title">今日学习概览</text><text class="checkin-status" :class="detail.checkedIn ? 'checkin-complete' : 'checkin-pending'">{{ detail.checkInStatusName }}</text></view>
<view class="metrics-grid">
<view class="metric-card"><text class="metric-label">学习时长</text><text class="metric-value">{{ detail.learningMinutes }} 分钟</text></view>
<view class="metric-card"><text class="metric-label">答题时长</text><text class="metric-value">{{ detail.answerMinutes }} 分钟</text></view>
<view class="metric-card"><text class="metric-label">答题数量</text><text class="metric-value metric-blue">{{ detail.answerCount }} 题</text></view>
<view class="metric-card"><text class="metric-label">完成学习包</text><text class="metric-value metric-purple">{{ detail.completedPackages }} / {{ detail.totalPackages }}</text></view>
<view class="metric-card"><text class="metric-label">攻克知识点</text><text class="metric-value metric-green">{{ detail.masteredPoints }} 个</text></view>
</view>
</view>
<view class="courses-card">
<text class="section-title">今日课表与每课打卡</text>
<view v-if="detail.courses.length === 0" class="course-empty"><text class="course-empty-text">今日暂无课程</text></view>
<view v-else class="course-list">
<view v-for="course in detail.courses" :key="course.id" class="course-item">
<text class="course-time">{{ course.time }}</text>
<view class="course-copy">
<view class="course-title-row">
<text class="course-subject">{{ course.subject }}</text>
<text v-if="course.stageName" class="course-stage">{{ course.stageName }}</text>
</view>
<text class="course-content">{{ course.content }}</text>
</view>
<text class="course-status" :class="course.checkedIn ? 'course-complete' : 'course-pending'">{{ course.statusText }}</text>
</view>
</view>
</view>
<view class="score-card">
<text class="section-title">正确率(对比昨日)</text>
<text class="score-description">整体正确率较昨日全部答题目统计,与昨日对比</text>
<view class="score-row">
<text class="score-label">整体正确率</text>
<view class="score-values">
<text class="score-value">{{ formatAccuracy(detail.score.accuracy) }}</text>
<text v-if="detail.score.hasYesterdayData && detail.score.change !== null" class="score-change" :class="accuracyDiffClass(detail.score.change)">{{ formatAccuracyDiff(detail.score.change) }}</text>
</view>
</view>
<text class="score-yesterday">昨日 {{ formatAccuracy(detail.score.yesterdayAccuracy) }}</text>
<view class="score-track"><view class="score-progress" :style="{ width: (detail.score.accuracy ?? 0) + '%' }"></view></view>
</view>
<view class="points-card">
<view class="points-header"><text class="section-title">今日获得积分</text><text class="points-link">积分明细 ›</text></view>
<view class="points-total"><text class="points-number">+{{ detail.pointsTotal }}</text><text class="points-unit">积分</text></view>
<view v-if="detail.pointDetails.length === 0" class="points-empty"><text class="points-empty-text">今日暂无积分</text></view>
<view v-else class="points-list">
<view v-for="item in detail.pointDetails" :key="item.id" class="point-item"><text class="point-label">{{ item.label }}</text><text class="point-value">+{{ item.points }}</text></view>
</view>
</view>
</view>
</scroll-view>
<MainTabBar active-tab="home"></MainTabBar>
<page-container :show="showStudentPicker" position="bottom" :round="true" :overlay="true" :close-on-slide-down="true" @beforeleave="closeStudentPicker">
<view class="student-picker-panel">
<view class="student-picker-header">
<text class="student-picker-title">切换孩子</text>
<text class="student-picker-subtitle">切换后将刷新当前孩子的今日学习</text>
</view>
<scroll-view class="student-picker-list" direction="vertical" scroll-y="true">
<view v-if="isStudentsLoading" class="student-picker-empty"><text class="student-picker-empty-text">学生信息加载中</text></view>
<view v-else-if="boundStudents.length === 0" class="student-picker-empty"><text class="student-picker-empty-text">暂无已绑定学生</text></view>
<view v-else v-for="(item, index) in boundStudents" :key="item.id" class="student-picker-item" :class="selectedStudentId === item.id ? 'student-picker-item-selected' : ''" @tap="selectStudent(item.id)">
<view class="student-picker-avatar" :class="studentPickerAvatarClass(index)"><image v-if="item.avatar" class="student-picker-avatar-image" :src="item.avatar" mode="aspectFill"></image><text v-else class="student-picker-avatar-text">{{ studentPickerInitial(item.name) }}</text></view>
<view class="student-picker-info"><view class="student-picker-name-row"><text class="student-picker-name">{{ item.name }}</text><text class="student-picker-grade">{{ item.school }} {{ item.className }}</text></view><text class="student-picker-detail">账号:{{ item.account }}</text></view>
<text class="student-picker-action" :class="selectedStudentId === item.id ? 'student-picker-action-selected' : ''">{{ selectedStudentId === item.id ? '当前查看' : '切换' }}</text>
</view>
</scroll-view>
<view class="student-picker-footer"><view class="student-picker-cancel" @tap="closeStudentPicker"><text class="student-picker-cancel-text">取消</text></view><view class="student-picker-confirm" @tap="confirmStudent"><text class="student-picker-confirm-text">确定</text></view></view>
</view>
</page-container>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetBoundStudents, apiGetStudentHomeProfile, apiGetTodayLearningDetail, type BoundStudentSummary, type TodayLearningDetail } from '../../api/student'
import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile, switchBoundStudent } from '../../store/home'
const isLoading = ref(true)
const isStudentsLoading = ref(false)
const showStudentPicker = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('')
const detail = ref<TodayLearningDetail>({
studentId: '', studentName: '', dateText: '', checkedIn: false, checkInStatusName: '未打卡', learningMinutes: 0, answerMinutes: 0,
answerCount: 0, completedPackages: 0, totalPackages: 0, masteredPoints: 0, courses: [],
score: { accuracy: null, yesterdayAccuracy: null, change: null, hasYesterdayData: false }, pointsTotal: 0, pointDetails: []
})
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const headerStudentName = computed((): string => homeStudentProfile.value?.name || detail.value.studentName || '孩子')
const headerSubtitle = computed((): string => `${detail.value.dateText || formatCurrentDate()} · ${headerStudentName.value}`)
onLoad(() => {
loadTodayLearning()
})
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadTodayLearning()
}
})
async function loadTodayLearning(showLoading: boolean = true) {
isLoading.value = showLoading
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (!studentId) {
isLoading.value = false
return
}
const res = await apiGetTodayLearningDetail(studentId, showLoading)
if (res && res.data) {
detail.value = res.data
}
isLoading.value = false
}
async function openStudentPicker() {
showStudentPicker.value = true
selectedStudentId.value = homeStudentProfile.value?.id ?? ''
isStudentsLoading.value = true
const res = await apiGetBoundStudents()
if (res && res.data) {
boundStudents.value = res.data.students
if (!selectedStudentId.value && res.data.currentStudentId) selectedStudentId.value = res.data.currentStudentId
}
isStudentsLoading.value = false
}
function closeStudentPicker() {
showStudentPicker.value = false
}
function selectStudent(studentId: string) {
selectedStudentId.value = studentId
}
async function confirmStudent() {
if (!selectedStudentId.value) {
uni.showToast({ title: '请选择学生', icon: 'none' })
return
}
const selectedStudent = boundStudents.value.find((item) => item.id === selectedStudentId.value)
if (selectedStudent) {
switchBoundStudent(selectedStudent)
observedStudentVersion.value = selectedStudentChangeVersion.value
}
closeStudentPicker()
await loadTodayLearning(true)
}
function studentPickerInitial(name: string): string {
return name.length > 1 ? name.substring(1, 2) : name.substring(0, 1)
}
function studentPickerAvatarClass(index: number): string {
if (index === 0) return 'student-picker-avatar-0'
if (index === 1) return 'student-picker-avatar-1'
return 'student-picker-avatar-2'
}
function formatCurrentDate(): string {
const now = new Date()
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${now.getMonth() + 1}月${now.getDate()}日 ${weekdays[now.getDay()]}`
}
function goBack() {
uni.navigateBack()
}
function formatAccuracy(rate: number | null): string {
if (rate === null) return '暂无数据'
return `${rate}%`
}
function formatAccuracyDiff(diff: number | null): string {
if (diff === null) return ''
return `${diff > 0 ? '+' : ''}${diff}%`
}
function accuracyDiffClass(diff: number | null): string {
if (diff === null || diff >= 0) return 'score-change-up'
return 'score-change-down'
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #6558e9; }
.content-scroll { flex: 1; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.content-panel { display: flex; flex-direction: column; padding: 16px 18px 24px; }
.overview-card { display: flex; flex-direction: column; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.overview-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.checkin-status { padding: 4px 10px; border-radius: 12px; font-size: 11px; }
.checkin-complete { color: #00a66a; background-color: #e9fbf3; }
.checkin-pending { color: #f08a1a; background-color: #fff4e5; }
.metrics-grid { display: flex; flex-direction: row; flex-wrap: wrap; justify-content: space-between; margin-top: 14px; }
.metric-card { display: flex; flex-direction: column; width: 48%; margin-bottom: 10px; padding: 12px; border-radius: 8px; background-color: #f6f8fb; }
.metric-label { font-size: 11px; color: #8992a5; }
.metric-value { margin-top: 6px; font-size: 15px; font-weight: 700; color: #20283a; }
.metric-blue { color: #008ddb; }
.metric-purple { color: #6558e9; }
.metric-green { color: #00a66a; }
.courses-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.course-list { display: flex; flex-direction: column; margin-top: 14px; }
.course-item { display: flex; flex-direction: row; align-items: center; min-height: 58px; margin-bottom: 10px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; }
.course-time { width: 56px; font-size: 12px; color: #596275; }
.course-copy { display: flex; flex-direction: column; flex: 1; }
.course-title-row { display: flex; flex-direction: row; align-items: center; }
.course-subject { font-size: 13px; font-weight: 600; color: #2a3042; }
.course-stage { margin-left: 6px; padding: 1px 6px; border-radius: 8px; font-size: 10px; color: #6558e9; background-color: #efedff; }
.course-content { margin-top: 4px; font-size: 12px; color: #596275; }
.course-status { padding: 4px 7px; border-radius: 10px; font-size: 10px; }
.course-complete { color: #00a66a; background-color: #e9fbf3; }
.course-pending { color: #f08a1a; background-color: #fff4e5; }
.course-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 90px; margin-top: 12px; border-radius: 8px; background-color: #f6f8fb; }
.course-empty-text { font-size: 12px; color: #8992a5; }
.score-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.score-description { margin-top: 6px; font-size: 11px; color: #8992a5; }
.score-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-top: 14px; }
.score-label { font-size: 12px; color: #596275; }
.score-values { display: flex; flex-direction: row; align-items: center; }
.score-value { font-size: 13px; font-weight: 700; color: #20283a; }
.score-change { margin-left: 8px; font-size: 12px; }
.score-change-up { color: #00a66a; }
.score-change-down { color: #e05b5b; }
.score-yesterday { margin-top: 6px; font-size: 12px; color: #8992a5; }
.score-track { height: 8px; margin-top: 8px; border-radius: 4px; background-color: #edf1f7; }
.score-progress { height: 8px; border-radius: 4px; background-color: #7659ee; }
.points-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.points-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.points-link { font-size: 12px; color: #514ce8; }
.points-total { display: flex; flex-direction: row; align-items: baseline; margin-top: 10px; }
.points-number { font-size: 24px; font-weight: 700; color: #ee9500; }
.points-unit { margin-left: 5px; font-size: 12px; color: #8992a5; }
.points-list { display: flex; flex-direction: column; margin-top: 12px; }
.point-item { display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 8px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; }
.point-label { font-size: 12px; color: #596275; }
.point-value { font-size: 12px; font-weight: 600; color: #ee9500; }
.points-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60px; margin-top: 12px; border-radius: 8px; background-color: #f6f8fb; }
.points-empty-text { font-size: 12px; color: #8992a5; }
.student-picker-panel { display: flex; flex-direction: column; background-color: #ffffff; }
.student-picker-header { display: flex; flex-direction: column; padding: 18px 20px 12px; }
.student-picker-title { font-size: 18px; font-weight: 700; color: #242b3d; }
.student-picker-subtitle { margin-top: 5px; font-size: 12px; color: #8992a5; }
.student-picker-list { max-height: 340px; padding: 0 20px; }
.student-picker-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; }
.student-picker-empty-text { font-size: 13px; color: #8992a5; }
.student-picker-item { display: flex; flex-direction: row; align-items: center; padding: 10px; margin-bottom: 10px; border: 1px solid #edf0f5; border-radius: 15px; background-color: #ffffff; }
.student-picker-item-selected { background-color: #f0efff; }
.student-picker-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 46px; height: 46px; border-radius: 14px; background-color: #7161ed; }
.student-picker-avatar-0 { background-color: #6558e9; }
.student-picker-avatar-1 { background-color: #12a9df; }
.student-picker-avatar-2 { background-color: #e63a9c; }
.student-picker-avatar-image { width: 46px; height: 46px; border-radius: 14px; }
.student-picker-avatar-text { font-size: 18px; font-weight: 700; color: #ffffff; }
.student-picker-info { display: flex; flex-direction: column; flex: 1; margin-left: 11px; }
.student-picker-name-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 5px; }
.student-picker-name { margin-right: 8px; font-size: 15px; font-weight: 700; color: #2a3042; }
.student-picker-grade { padding: 2px 6px; border-radius: 7px; font-size: 10px; color: #554be9; background-color: #e8e7ff; }
.student-picker-detail { margin-top: 2px; font-size: 11px; color: #7e8799; }
.student-picker-action { flex-shrink: 0; padding: 5px 8px; border-radius: 10px; font-size: 9px; color: #6e7c91; background-color: #f1f4f8; }
.student-picker-action-selected { color: #ffffff; background-color: #554be9; }
.student-picker-footer { display: flex; flex-direction: row; padding: 12px 20px 24px; border-top: 1px solid #edf0f5; }
.student-picker-cancel, .student-picker-confirm { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; height: 44px; border-radius: 11px; }
.student-picker-cancel { margin-right: 10px; background-color: #f0f2f7; }
.student-picker-confirm { background-color: #554be9; }
.student-picker-cancel-text { font-size: 15px; color: #596275; }
.student-picker-confirm-text { font-size: 15px; color: #ffffff; }
</style>