270 lines
20 KiB
Plaintext
270 lines
20 KiB
Plaintext
<template>
|
|
<view class="page-container">
|
|
<view class="header-panel">
|
|
<view class="header-copy"><text class="header-title">学情报告</text><text class="header-subtitle">{{ homeStudentProfile?.name ?? report.studentName }} · {{ homeStudentProfile?.school ?? '' }} {{ homeStudentProfile?.className ?? report.studentClass }}</text></view>
|
|
<view class="header-action" @tap="openStudentPicker"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view>
|
|
</view>
|
|
<view class="report-meta"><text class="report-meta-text">{{ activeReport.rangeText }} · 数据更新到 {{ report.updatedAtText }}</text></view>
|
|
|
|
<scroll-view class="content-scroll" direction="vertical" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="refreshReport">
|
|
<view v-if="isLoading" class="status-panel"><text class="status-text">学情报告加载中</text></view>
|
|
<view v-else class="content-panel">
|
|
<view class="filters-card">
|
|
<view class="period-tabs"><view class="period-tab" :class="activePeriod === 'week' ? 'period-tab-active' : ''" @tap="setPeriod('week')"><text class="period-tab-text" :class="activePeriod === 'week' ? 'period-tab-text-active' : ''">周报</text></view><view class="period-tab" :class="activePeriod === 'month' ? 'period-tab-active' : ''" @tap="setPeriod('month')"><text class="period-tab-text" :class="activePeriod === 'month' ? 'period-tab-text-active' : ''">月报</text></view></view>
|
|
<scroll-view class="subject-scroll" direction="horizontal" scroll-x="true"><view class="subject-tabs"><view v-for="subject in report.subjects" :key="subject.id" class="subject-tab" :class="activeSubjectId === subject.id ? 'subject-tab-active' : ''" @tap="setSubject(subject.id)"><text class="subject-tab-text" :class="activeSubjectId === subject.id ? 'subject-tab-text-active' : ''">{{ subject.name }}</text></view></view></scroll-view>
|
|
</view>
|
|
|
|
<view v-if="!activeHasReport" class="empty-report-card"><text class="empty-report-title">暂无{{ reportPeriodLabel }}报告</text><text class="empty-report-text">当前周期还没有生成学情报告,生成后会在这里展示学习时长、知识点攻克和正确率数据。</text></view>
|
|
<view v-else class="report-content">
|
|
<view class="summary-card"><text class="summary-title">{{ activeReport.statusTitle }}</text><text class="summary-description">{{ activeReport.statusDescription }}</text><view class="summary-stats"><view class="summary-stat"><text class="summary-value">{{ activeReport.newMasteredPoints }} 个</text><text class="summary-label">攻克知识点</text></view><view class="summary-stat summary-stat-middle"><text class="summary-value">{{ activeReport.consecutiveDays }} 天</text><text class="summary-label">连续学习</text></view><view class="summary-stat"><text class="summary-value">{{ activeReport.averageAccuracy }}%</text><text class="summary-label">平均正确率</text></view></view></view>
|
|
|
|
<view class="trend-card"><text class="section-title">学习时长趋势</text><view class="trend-legend"><view class="legend-item"><view class="legend-mark legend-mark-student"></view><text class="legend-text">总学习时长</text></view><view class="legend-item"><view class="legend-mark legend-mark-average"></view><text class="legend-text">攻克成功时长</text></view></view><StudyTrendChart :scale-labels="activeReport.scaleLabels" :points="activeTrend"></StudyTrendChart></view>
|
|
|
|
<view class="activity-card"><text class="section-title">{{ reportPeriodLabel }}学习时长明细</text><text class="activity-summary">{{ activeReport.activity.summary }}</text><text class="activity-total">连续学习 {{ activeReport.activity.activeDays }} 天,累计 {{ activeReport.activity.totalDurationText }}</text><StudyActivityChart :points="activeReport.activity.points"></StudyActivityChart><view class="activity-legend"><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-student"></view><text class="legend-text">总学习时长</text></view><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-plan"></view><text class="legend-text">攻克成功时长</text></view><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-mastery"></view><text class="legend-text">未攻克时长</text></view></view></view>
|
|
|
|
<view v-if="activeSubjectId === '0'" class="mastery-card"><text class="section-title">各科知识点攻克情况</text><view v-for="mastery in activeReport.subjectMasteries" :key="mastery.subjectName" class="mastery-item"><view class="mastery-heading"><text class="mastery-subject">{{ mastery.subjectName }}</text><text class="mastery-value">{{ mastery.masteredCount }} / {{ mastery.totalCount }}</text></view><view class="mastery-track"><view class="mastery-progress" :style="{ width: mastery.masteredPercent + '%' }"></view><view class="mastery-remaining"></view></view></view><view class="mastery-legend"><view class="mastery-legend-item"><view class="mastery-legend-color mastery-legend-mastered"></view><text class="mastery-legend-text">已攻克</text></view><view class="mastery-legend-item"><view class="mastery-legend-color mastery-legend-unmastered"></view><text class="mastery-legend-text">未攻克</text></view></view></view>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
<MainTabBar active-tab="study"></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 StudyActivityChart from '../../components/study-activity-chart.uvue'
|
|
import StudyTrendChart from '../../components/study-trend-chart.uvue'
|
|
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
|
|
import { apiGetBoundStudents, apiGetStudentHomeProfile, apiGetStudentStudyReport, type BoundStudentSummary, type StudentStudyReport, type StudyReportActivity, type StudyReportPeriod, type StudyReportSubjectMastery, type StudyReportTrendPoint } from '../../api/student'
|
|
import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile, switchBoundStudent } from '../../store/home'
|
|
|
|
const emptyActivity: StudyReportActivity = { summary: '', activeDays: 0, totalDurationText: '', points: [] }
|
|
const emptyPeriod: StudyReportPeriod = { rangeText: '', statusTitle: '', statusDescription: '', newMasteredPoints: 0, consecutiveDays: 0, averageAccuracy: 0, scaleLabels: [], trends: [], activity: emptyActivity, subjectMasteries: [] as StudyReportSubjectMastery[] }
|
|
const report = ref<StudentStudyReport>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', updatedAtText: '', subjects: [{ id: '0', name: '全部科目' }], hasWeekReport: false, hasMonthReport: false, week: emptyPeriod, month: emptyPeriod })
|
|
const isLoading = ref(true)
|
|
const isRefreshing = ref(false)
|
|
const isStudentsLoading = ref(false)
|
|
const showStudentPicker = ref(false)
|
|
const boundStudents = ref<BoundStudentSummary[]>([])
|
|
const selectedStudentId = ref('')
|
|
const activePeriod = ref('week')
|
|
const activeSubjectId = ref('0')
|
|
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
|
|
const activeReport = computed((): StudyReportPeriod => activePeriod.value === 'week' ? report.value.week : report.value.month)
|
|
const activeHasReport = computed((): boolean => activePeriod.value === 'week' ? report.value.hasWeekReport : report.value.hasMonthReport)
|
|
const reportPeriodLabel = computed((): string => activePeriod.value === 'week' ? '周报' : '月报')
|
|
const activeTrend = computed((): StudyReportTrendPoint[] => {
|
|
for (let index = 0; index < activeReport.value.trends.length; index++) {
|
|
const trend = activeReport.value.trends[index]
|
|
if (trend.subjectId === activeSubjectId.value) return trend.points
|
|
}
|
|
return activeReport.value.trends.length > 0 ? activeReport.value.trends[0].points : []
|
|
})
|
|
|
|
onLoad(() => {
|
|
loadReport()
|
|
})
|
|
|
|
onShow(() => {
|
|
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
|
|
observedStudentVersion.value = selectedStudentChangeVersion.value
|
|
loadReport()
|
|
}
|
|
})
|
|
|
|
async function loadReport() {
|
|
isLoading.value = true
|
|
activeSubjectId.value = '0'
|
|
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 apiGetStudentStudyReport(studentId)
|
|
if (res && res.data) report.value = res.data
|
|
isLoading.value = false
|
|
}
|
|
|
|
async function refreshReport() {
|
|
isRefreshing.value = true
|
|
await loadReport()
|
|
isRefreshing.value = false
|
|
}
|
|
|
|
function setPeriod(period: string) {
|
|
activePeriod.value = period
|
|
activeSubjectId.value = '0'
|
|
}
|
|
|
|
function setSubject(subjectId: string) {
|
|
activeSubjectId.value = subjectId
|
|
}
|
|
|
|
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 loadReport()
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
</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; }
|
|
.report-meta { padding: 8px 18px; background-color: #ffffff; border-top: 1px solid #edf0f5; }
|
|
.report-meta-text { font-size: 11px; color: #8992a5; }
|
|
.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: 12px 18px 24px; }
|
|
.filters-card { display: flex; flex-direction: column; padding: 12px; border-radius: 8px; background-color: #ffffff; }
|
|
.report-content { display: flex; flex-direction: column; }
|
|
.empty-report-card { display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 16px; padding: 38px 18px; border-radius: 14px; background-color: #ffffff; }
|
|
.empty-report-title { font-size: 16px; font-weight: 700; color: #242b3d; }
|
|
.empty-report-text { margin-top: 8px; font-size: 12px; line-height: 19px; color: #8992a5; text-align: center; }
|
|
.period-tabs { display: flex; flex-direction: row; }
|
|
.period-tab { padding: 7px 14px; margin-right: 8px; border-radius: 16px; background-color: #f1f4f8; }
|
|
.period-tab-active { background-color: #554be9; }
|
|
.period-tab-text { font-size: 12px; color: #64748b; }
|
|
.period-tab-text-active { color: #ffffff; }
|
|
.subject-scroll { margin-top: 10px; }
|
|
.subject-tabs { display: flex; flex-direction: row; }
|
|
.subject-tab { flex-shrink: 0; padding: 7px 14px; margin-right: 8px; border-radius: 16px; background-color: #f1f4f8; }
|
|
.subject-tab-active { background-color: #554be9; }
|
|
.subject-tab-text { font-size: 12px; color: #64748b; }
|
|
.subject-tab-text-active { color: #ffffff; }
|
|
.summary-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 14px; background-color: #6545e8; }
|
|
.summary-title { font-size: 15px; font-weight: 700; color: #ffffff; }
|
|
.summary-description { margin-top: 8px; font-size: 12px; line-height: 19px; color: #efeaff; }
|
|
.summary-stats { display: flex; flex-direction: row; margin-top: 14px; padding: 10px 0; border-radius: 8px; background-color: #7659ee; }
|
|
.summary-stat { display: flex; flex-direction: column; align-items: center; flex: 1; }
|
|
.summary-stat-middle { border-left: 1px solid #927cf3; border-right: 1px solid #927cf3; }
|
|
.summary-value { font-size: 15px; font-weight: 700; color: #ffffff; }
|
|
.summary-label { margin-top: 4px; font-size: 10px; color: #e9e2ff; }
|
|
.trend-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
|
|
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
|
|
.trend-legend { display: flex; flex-direction: row; justify-content: flex-end; margin-top: 12px; }
|
|
.legend-item { display: flex; flex-direction: row; align-items: center; margin-left: 12px; }
|
|
.legend-mark { width: 11px; height: 5px; margin-right: 5px; border-radius: 2px; }
|
|
.legend-mark-student { background-color: #6558e9; }
|
|
.legend-mark-average { background-color: #9aa8bc; }
|
|
.legend-mark-plan { background-color: #d3dce8; }
|
|
.legend-mark-mastery { width: 7px; height: 7px; border-radius: 4px; background-color: #ffab26; }
|
|
.legend-text { font-size: 10px; color: #596275; }
|
|
.activity-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
|
|
.activity-summary { margin-top: 8px; font-size: 12px; color: #596275; }
|
|
.activity-total { margin-top: 4px; font-size: 12px; color: #8992a5; }
|
|
.activity-legend { display: flex; flex-direction: row; flex-wrap: wrap; margin-top: 6px; }
|
|
.activity-legend-item { margin-left: 0; margin-right: 12px; margin-bottom: 4px; }
|
|
.mastery-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
|
|
.mastery-legend { display: flex; flex-direction: row; margin-top: 16px; }
|
|
.mastery-legend-item { display: flex; flex-direction: row; align-items: center; margin-right: 16px; }
|
|
.mastery-legend-color { width: 8px; height: 8px; margin-right: 5px; border-radius: 2px; }
|
|
.mastery-legend-mastered { background-color: #16bd88; }
|
|
.mastery-legend-unmastered { background-color: #f34d54; }
|
|
.mastery-legend-text { font-size: 11px; color: #8992a5; }
|
|
.mastery-item { display: flex; flex-direction: column; margin-top: 14px; }
|
|
.mastery-heading { display: flex; flex-direction: row; justify-content: space-between; }
|
|
.mastery-subject { font-size: 13px; color: #4a5568; }
|
|
.mastery-value { font-size: 13px; font-weight: 700; color: #242b3d; }
|
|
.mastery-track { display: flex; flex-direction: row; height: 10px; margin-top: 7px; overflow: hidden; border-radius: 5px; background-color: #f1f4f8; }
|
|
.mastery-progress { height: 10px; border-radius: 5px 0 0 5px; background-color: #16bd88; }
|
|
.mastery-remaining { flex: 1; border-radius: 0 5px 5px 0; background-color: #f34d54; }
|
|
.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>
|