首页、学情、任务绑定接口

This commit is contained in:
hexi
2026-11-06 16:43:41 +08:00
parent af73453035
commit 4a19192d73
16 changed files with 965 additions and 236 deletions
+294 -31
View File
@@ -50,10 +50,22 @@ export type StudentHomeMetrics = {
accuracyToday: number | null
learningDaysThisWeek: number
shouldStudyDaysThisWeek: number
pendingHomeworkCount: number
pendingPackCount: number
updatedAt: string
}
export type ParentStudentTodayInfoResult = {
userId: number | string
date: string
todayOnline: boolean
todayCheckIn: boolean
todayStudyDuration: number
todayAccuracyRate: number
weekStudyDays: number
weekShouldStudyDays: number
pendingPackCount: number
}
export type TodayLearningCourse = {
id: string
time: string
@@ -136,10 +148,53 @@ export type StudentStudyReport = {
studentClass: string
updatedAtText: string
subjects: StudyReportSubject[]
hasWeekReport: boolean
hasMonthReport: boolean
week: StudyReportPeriod
month: StudyReportPeriod
}
export type ParentLearningReportType = 1 | 2
export type ParentLearningDurationDetail = {
StartDate: string
EndDate: string
StudyDurationTime: number
OvercomeStudyDurationTime: number
UnOvercomeStudyDurationTime: number
}
export type ParentLearningReportDetailItem = {
SubjectId: string
SubjectName: string | null
NewKnowledgePointCount: number
OvercomeKnowledgePointCount: number
ContinuousStudyDays: number
AverageAccuracy: number
StudyDurationTime: number
OvercomeStudyDurationTime: number
UnOvercomeStudyDurationTime: number
StudyDurationDetails: ParentLearningDurationDetail[]
}
export type ParentLearningReportResult = {
UserId: string
UserName: string | null
ClassId: string
ClassName: string | null
SchoolId: string
SchoolName: string | null
CloudSchoolId: string
CloudSchoolName: string | null
ReportType: ParentLearningReportType
ReportTypeName: string
HasReport: boolean
PeriodStart: string | null
PeriodEnd: string | null
Summary: ParentLearningReportDetailItem | null
Subjects: ParentLearningReportDetailItem[]
}
export type TodayLearningDetail = {
studentId: string
studentName: string
@@ -205,9 +260,12 @@ export type LearningTaskItem = {
title: string
status: 'not_started' | 'in_progress' | 'success' | 'failed'
statusText: string
resultText: string
accuracyText: string
answerText: string
attemptText: string
deadlineText: string
metaText: string
}
export type LearningTaskGroup = {
@@ -227,6 +285,7 @@ export type StudentLearningTasks = {
studentName: string
studentGrade: string
studentClass: string
totalCount: number
pendingCount: number
completedCount: number
failedCount: number
@@ -244,6 +303,8 @@ export type StudentAlert = {
content: string
occurredAt: string
isRead: boolean
isTriggered: boolean
statusName: string
}
export type StudentAlertList = {
@@ -265,7 +326,7 @@ export type StudentLearningWarningItem = {
}
export type StudentLearningWarningResult = {
userId: string
userId: number | string
date: string
totalCount: number
triggeredCount: number
@@ -351,7 +412,7 @@ export function apiGetBoundStudents(): Promise<ApiResponse<{ students: BoundStud
* 获取指定学生的首页今日学习概览。
*/
export function apiGetStudentHomeMetrics(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentHomeMetrics> | null> {
return get<any>(`/api/v1/ParentStudentToday/GetTodayInfo?userId=${studentId}`, undefined, { showLoading }).then((res) => {
return get<ParentStudentTodayInfoResult>(`/api/v1/ParentStudentToday/GetTodayInfo?userId=${encodeURIComponent(studentId)}`, undefined, { showLoading }).then((res) => {
if (!res || !res.data) return null
return {
code: res.code,
@@ -360,11 +421,11 @@ export function apiGetStudentHomeMetrics(studentId: string, showLoading: boolean
studentId: String(res.data.userId ?? studentId),
onlineToday: Boolean(res.data.todayOnline),
checkedInToday: Boolean(res.data.todayCheckIn),
learningMinutesToday: Math.floor((Number(res.data.todayStudyDuration) || 0) / 60),
accuracyToday: Number(res.data.todayAccuracyRate) || 0,
learningMinutesToday: Math.floor(Number(res.data.todayStudyDuration) || 0),
accuracyToday: res.data.todayAccuracyRate === null || res.data.todayAccuracyRate === undefined ? null : Number(res.data.todayAccuracyRate) || 0,
learningDaysThisWeek: Number(res.data.weekStudyDays) || 0,
shouldStudyDaysThisWeek: Number(res.data.weekShouldStudyDays) || 0,
pendingHomeworkCount: Number(res.data.pendingPackCount) || 0,
pendingPackCount: Number(res.data.pendingPackCount) || 0,
updatedAt: res.data.date || ''
}
}
@@ -372,21 +433,26 @@ export function apiGetStudentHomeMetrics(studentId: string, showLoading: boolean
}
/**
* 获取指定学生今日学习预警,仅返回已触发的预警规则。
* 获取指定学生今日学习预警。已触发预警优先,其后展示未触发规则。
*/
export function apiGetStudentLearningWarnings(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentAlertList> | null> {
return get<any>(`/api/v1/ParentLearningWarning/GetTodayList?userId=${studentId}`, undefined, { showLoading }).then((res) => {
export function apiGetStudentLearningWarnings(studentId: string, showLoading: boolean = true, limit?: number): Promise<ApiResponse<StudentAlertList> | null> {
return get<StudentLearningWarningResult>(`/api/v1/ParentLearningWarning/GetTodayList?userId=${encodeURIComponent(studentId)}`, undefined, { showLoading }).then((res) => {
if (!res || !res.data || !Array.isArray(res.data.items)) return null
const list: StudentAlert[] = res.data.items
.filter((item: any) => Boolean(item.isTriggered))
.map((item: any, index: number) => ({
id: `${studentId}-${item.type ?? index}`,
level: 'high',
title: item.title || '学习预警',
content: item.description || item.statusName || '',
occurredAt: res.data.date || '',
isRead: false
}))
const warningData = res.data
const triggeredItems = warningData.items.filter((item: StudentLearningWarningItem) => Boolean(item.isTriggered))
const normalItems = warningData.items.filter((item: StudentLearningWarningItem) => !Boolean(item.isTriggered))
const sourceItems = triggeredItems.concat(normalItems)
const visibleItems = limit === undefined ? sourceItems : sourceItems.slice(0, limit)
const list: StudentAlert[] = visibleItems.map((item: StudentLearningWarningItem, index: number) => ({
id: `${studentId}-${item.type ?? index}`,
level: item.isTriggered ? 'high' : 'low',
title: item.title || '学习预警',
content: item.description || item.statusName || '',
occurredAt: warningData.date || '',
isRead: !item.isTriggered,
isTriggered: Boolean(item.isTriggered),
statusName: item.statusName || ''
}))
return {
code: res.code,
message: res.message,
@@ -395,8 +461,8 @@ export function apiGetStudentLearningWarnings(studentId: string, showLoading: bo
pagination: {
page: 1,
pageSize: list.length,
total: list.length,
hasMore: false
total: sourceItems.length,
hasMore: limit !== undefined && sourceItems.length > list.length
}
}
}
@@ -411,10 +477,176 @@ export function apiGetTodayLearningDetail(studentId: string, showLoading: boolea
}
/**
* 获取学生学情报告。接口一次返回周报、月报和全部科目趋势数据。
* 获取学生最新一期学情报告。新接口按报告类型分别返回周报、月报,这里转换为页面统一结构。
*/
export function apiGetStudentStudyReport(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentStudyReport> | null> {
return get<StudentStudyReport>(`/api/parent/study-report?studentId=${studentId}`, undefined, { showLoading })
export async function apiGetStudentStudyReport(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentStudyReport> | null> {
const weekRes = await get<ParentLearningReportResult>(`/api/v1/ParentLearningReport/Get?userId=${encodeURIComponent(studentId)}&reportType=1`, undefined, { showLoading })
const monthRes = await get<ParentLearningReportResult>(`/api/v1/ParentLearningReport/Get?userId=${encodeURIComponent(studentId)}&reportType=2`, undefined, { showLoading: false })
const weekData = weekRes?.data ?? null
const monthData = monthRes?.data ?? null
if (!weekData && !monthData) return null
const sourceData = weekData ?? monthData
return {
code: weekRes?.code ?? monthRes?.code ?? 200,
message: weekRes?.message ?? monthRes?.message ?? 'SUCCESS',
data: {
studentId: String(sourceData?.UserId ?? studentId),
studentName: sourceData?.UserName ?? '',
studentGrade: '',
studentClass: sourceData?.ClassName ?? '',
updatedAtText: formatReportDate(sourceData?.PeriodEnd),
subjects: buildStudyReportSubjects(weekData, monthData),
hasWeekReport: Boolean(weekData?.HasReport && weekData.Summary),
hasMonthReport: Boolean(monthData?.HasReport && monthData.Summary),
week: buildStudyReportPeriod(weekData, 1),
month: buildStudyReportPeriod(monthData, 2)
}
}
}
function buildStudyReportSubjects(weekData: ParentLearningReportResult | null, monthData: ParentLearningReportResult | null): StudyReportSubject[] {
const subjects: StudyReportSubject[] = [{ id: '0', name: '全部科目' }]
const sourceSubjects = weekData?.Subjects?.length ? weekData.Subjects : monthData?.Subjects ?? []
sourceSubjects.forEach((item: ParentLearningReportDetailItem) => {
const id = String(item.SubjectId ?? '')
if (id && subjects.every((subject: StudyReportSubject) => subject.id !== id)) {
subjects.push({ id, name: item.SubjectName ?? '未命名科目' })
}
})
return subjects
}
function buildStudyReportPeriod(report: ParentLearningReportResult | null, reportType: ParentLearningReportType): StudyReportPeriod {
if (!report?.HasReport || !report.Summary) return createEmptyStudyReportPeriod(reportType)
const summary = report.Summary
const detailItems = [summary].concat(Array.isArray(report.Subjects) ? report.Subjects : [])
let maxMinutes = 1
detailItems.forEach((item: ParentLearningReportDetailItem) => {
const details = Array.isArray(item.StudyDurationDetails) ? item.StudyDurationDetails : []
details.forEach((detail: ParentLearningDurationDetail) => {
maxMinutes = Math.max(maxMinutes, secondsToMinutes(detail.StudyDurationTime))
})
})
return {
rangeText: formatReportRange(report.PeriodStart, report.PeriodEnd),
statusTitle: `${report.ReportTypeName || (reportType === 1 ? '周报' : '月报')}已攻克 ${summary.OvercomeKnowledgePointCount || 0} 个知识点`,
statusDescription: `新增 ${summary.NewKnowledgePointCount || 0} 个知识点,累计学习 ${formatSecondsDuration(summary.StudyDurationTime)},其中攻克成功学习 ${formatSecondsDuration(summary.OvercomeStudyDurationTime)}。`,
newMasteredPoints: Number(summary.OvercomeKnowledgePointCount) || 0,
consecutiveDays: Number(summary.ContinuousStudyDays) || 0,
averageAccuracy: Number(summary.AverageAccuracy) || 0,
scaleLabels: buildDurationScaleLabels(maxMinutes),
trends: detailItems.map((item: ParentLearningReportDetailItem) => buildStudyReportTrend(item, maxMinutes, reportType)),
activity: buildStudyReportActivity(summary, maxMinutes, reportType),
subjectMasteries: (Array.isArray(report.Subjects) ? report.Subjects : []).map((item: ParentLearningReportDetailItem) => buildStudyReportSubjectMastery(item))
}
}
function createEmptyStudyReportPeriod(reportType: ParentLearningReportType): StudyReportPeriod {
return {
rangeText: reportType === 1 ? '暂无周报' : '暂无月报',
statusTitle: '暂无学情报告',
statusDescription: '当前周期暂无报告数据。',
newMasteredPoints: 0,
consecutiveDays: 0,
averageAccuracy: 0,
scaleLabels: ['120分', '80分', '40分', '0分'],
trends: [],
activity: { summary: '暂无学习时长数据', activeDays: 0, totalDurationText: '0分钟', points: [] },
subjectMasteries: []
}
}
function buildStudyReportTrend(item: ParentLearningReportDetailItem, maxMinutes: number, reportType: ParentLearningReportType): StudyReportTrend {
const details = Array.isArray(item.StudyDurationDetails) ? item.StudyDurationDetails : []
return {
subjectId: String(item.SubjectId ?? '0'),
points: details.map((detail: ParentLearningDurationDetail, index: number) => {
const studyMinutes = secondsToMinutes(detail.StudyDurationTime)
const overcomeMinutes = secondsToMinutes(detail.OvercomeStudyDurationTime)
return {
label: formatDurationDetailLabel(detail, index, reportType),
value: studyMinutes,
average: overcomeMinutes,
valueHeight: scaleChartHeight(studyMinutes, maxMinutes, 72),
averageHeight: scaleChartHeight(overcomeMinutes, maxMinutes, 72)
}
})
}
}
function buildStudyReportActivity(summary: ParentLearningReportDetailItem, maxMinutes: number, reportType: ParentLearningReportType): StudyReportActivity {
const details = Array.isArray(summary.StudyDurationDetails) ? summary.StudyDurationDetails : []
return {
summary: `总学习 ${formatSecondsDuration(summary.StudyDurationTime)},未攻克学习 ${formatSecondsDuration(summary.UnOvercomeStudyDurationTime)}。`,
activeDays: Number(summary.ContinuousStudyDays) || 0,
totalDurationText: formatSecondsDuration(summary.StudyDurationTime),
points: details.map((detail: ParentLearningDurationDetail, index: number) => {
const studyMinutes = secondsToMinutes(detail.StudyDurationTime)
const overcomeMinutes = secondsToMinutes(detail.OvercomeStudyDurationTime)
const unOvercomeMinutes = secondsToMinutes(detail.UnOvercomeStudyDurationTime)
return {
label: formatDurationDetailLabel(detail, index, reportType),
studyMinutes,
plannedMinutes: overcomeMinutes,
newMasteredCount: unOvercomeMinutes,
studyHeight: scaleChartHeight(studyMinutes, maxMinutes, 90),
plannedHeight: scaleChartHeight(overcomeMinutes, maxMinutes, 90),
newMasteredHeight: unOvercomeMinutes > 0 ? scaleChartHeight(unOvercomeMinutes, maxMinutes, 72) : 0
}
})
}
}
function buildStudyReportSubjectMastery(item: ParentLearningReportDetailItem): StudyReportSubjectMastery {
const newCount = Number(item.NewKnowledgePointCount) || 0
const overcomeCount = Number(item.OvercomeKnowledgePointCount) || 0
return {
subjectName: item.SubjectName ?? '未命名科目',
masteredCount: overcomeCount,
totalCount: newCount,
masteredPercent: newCount > 0 ? Math.min(100, Math.round(overcomeCount / newCount * 100)) : 0
}
}
function secondsToMinutes(seconds: number): number {
return Math.round((Number(seconds) || 0) / 60)
}
function scaleChartHeight(value: number, maxValue: number, maxHeight: number): number {
if (value <= 0 || maxValue <= 0) return 0
return Math.max(4, Math.round(value / maxValue * maxHeight))
}
function buildDurationScaleLabels(maxMinutes: number): string[] {
const top = Math.max(60, Math.ceil(maxMinutes / 30) * 30)
return [`${top}分`, `${Math.round(top * 2 / 3)}分`, `${Math.round(top / 3)}分`, '0分']
}
function formatSecondsDuration(seconds: number): string {
const totalMinutes = secondsToMinutes(seconds)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours > 0 && minutes > 0) return `${hours}小时${minutes}分`
if (hours > 0) return `${hours}小时`
return `${minutes}分钟`
}
function formatReportRange(start: string | null | undefined, end: string | null | undefined): string {
if (!start || !end) return '暂无统计周期'
return `${formatReportDate(start)} - ${formatReportDate(end)}`
}
function formatReportDate(value: string | null | undefined): string {
if (!value) return '--'
const text = String(value)
if (text.length >= 10) return text.substring(5, 10).replace('-', '月') + '日'
return text
}
function formatDurationDetailLabel(detail: ParentLearningDurationDetail, index: number, reportType: ParentLearningReportType): string {
if (reportType === 2) return `第${index + 1}周`
return formatReportDate(detail.StartDate)
}
export function apiGetStudentVocabulary(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentVocabulary> | null> {
@@ -463,7 +695,7 @@ export function apiGetStudentLearningTasks(studentId: string, showLoading: boole
* 获取学生当天学习包任务。Status: 0 全部,1 待完成,2 已完成,3 未通过。
*/
export function apiGetParentStudyPacks(studentId: string, status: ParentStudyPackStatus = 0, showLoading: boolean = true): Promise<ApiResponse<StudentLearningTasks> | null> {
return get<any>(`/api/v1/ParentStudyPack/GetTodayList?UserId=${studentId}&Status=${status}`, undefined, { showLoading }).then((res) => {
return get<any>(`/api/v1/ParentStudyPack/GetTodayList?userId=${encodeURIComponent(studentId)}&status=${status}`, undefined, { showLoading }).then((res) => {
if (!res || !res.data) return null
const statusMap: { [key: number]: LearningTaskItem['status'] } = {
0: 'not_started',
@@ -482,14 +714,38 @@ export function apiGetParentStudyPacks(studentId: string, status: ParentStudyPac
const tasks: LearningTaskItem[] = (Array.isArray(course.packs) ? course.packs : []).map((pack: any) => {
const taskStatus = statusMap[Number(pack.status)] || 'not_started'
const accuracy = pack.latestAccuracyRate === null || pack.latestAccuracyRate === undefined ? '--' : `${Number(pack.latestAccuracyRate) || 0}%`
const maxCount = Number(pack.maxOvercomeCount) || 0
const currentCount = Number(pack.currentAttemptNo || pack.completedAttemptCount) || 0
const remainingCount = Number(pack.remainingAttemptCount) || 0
const durationSecond = Number(pack.answerDurationSecond) || 0
const durationMinute = Math.floor(durationSecond / 60)
const attemptText = taskStatus === 'not_started'
? `尚未开始 · 最多 ${maxCount || 2} 次机会`
: `第 ${currentCount || 1} 次攻克 · 共 ${maxCount || currentCount || 1} 次`
const resultText = taskStatus === 'success'
? `第 ${currentCount || 1} 次攻克成功 · 共 ${maxCount || currentCount || 1} 次`
: taskStatus === 'failed'
? `攻克失败 · 剩余 ${remainingCount} 次机会`
: taskStatus === 'in_progress'
? `攻克中 · 剩余 ${remainingCount} 次机会`
: attemptText
const answerText = durationMinute > 0 ? `答题时长 ${durationMinute} 分钟` : '暂无答题时长'
const recordTime = formatTaskDateTime(pack.latestRecordTime)
const deadlineText = formatTaskDateTime(pack.deadlineTime)
const metaParts: string[] = [`正确率 ${accuracy}`, answerText]
if (recordTime !== '--') metaParts.push(`${recordTime} 完成`)
else if (deadlineText !== '--') metaParts.push(`${deadlineText} 截止`)
return {
id: String(pack.packId ?? pack.knowledgePointId ?? ''),
title: pack.name || '未命名学习任务',
status: taskStatus,
statusText: pack.statusName || statusTextMap[taskStatus],
resultText,
accuracyText: accuracy,
answerText: pack.completedAttemptCount ? `已完成 ${pack.completedAttemptCount} 次` : '尚未完成',
deadlineText: pack.deadlineTime || ''
answerText,
attemptText,
deadlineText,
metaText: metaParts.join(' · ')
}
})
return {
@@ -513,6 +769,7 @@ export function apiGetParentStudyPacks(studentId: string, status: ParentStudyPac
studentGrade: '',
studentClass: '',
pendingCount: Number(res.data.pendingCount) || 0,
totalCount: Number(res.data.totalCount) || groups.reduce((total: number, group: LearningTaskGroup) => total + group.tasks.length, 0),
completedCount: Number(res.data.completedCount) || 0,
failedCount: Number(res.data.failedCount) || 0,
groups
@@ -527,10 +784,16 @@ function formatTaskTime(value: any): string {
return text.length >= 16 ? text.substring(11, 16) : text
}
function formatTaskDateTime(value: any): string {
if (!value) return '--'
const text = String(value)
if (text.length < 16) return text
return `今天 ${text.substring(11, 16)}`
}
/**
* 获取当前学生的预警信息。首页调用时 limit 固定为 3。
* 兼容旧调用签名,实际统一使用今日学习预警接口。
*/
export function apiGetStudentAlerts(studentId: string, page: number = 1, pageSize: number = 20, limit?: number, showLoading: boolean = true): Promise<ApiResponse<StudentAlertList> | null> {
const suffix = limit ? `limit=${limit}` : `page=${page}&pageSize=${pageSize}`
return get<StudentAlertList>(`/api/parent/alerts?studentId=${studentId}&${suffix}`, undefined, { showLoading })
export function apiGetStudentAlerts(studentId: string, _page: number = 1, _pageSize: number = 20, limit?: number, showLoading: boolean = true): Promise<ApiResponse<StudentAlertList> | null> {
return apiGetStudentLearningWarnings(studentId, showLoading, limit)
}
+43 -32
View File
@@ -16,11 +16,6 @@
</template>
<script setup lang="ts">
// #ifdef WEB
import { BrowserQRCodeReader } from '@zxing/browser'
import type { IScannerControls } from '@zxing/browser'
// #endif
type Props = {
cooldown?: number
active?: boolean
@@ -39,10 +34,10 @@
let lastScannedAt = 0
// #ifdef WEB
const videoContainerId = `qr-scanner-${Date.now()}`
let scannerControls: IScannerControls | null = null
let scanVideo: HTMLVideoElement | null = null
let scanStream: MediaStream | null = null
let isPageActive = false
let restartTimer: number | null = null
let scanTimer: number | null = null
// #endif
function emitScan(result: string) {
@@ -77,7 +72,7 @@
}
async function startWebScanner() {
if (!isPageActive || !props.active || scannerControls != null) return
if (!isPageActive || !props.active || scanStream != null) return
const video = getScanVideo()
if (video == null) {
handleCameraError()
@@ -85,39 +80,55 @@
}
try {
const reader = new BrowserQRCodeReader()
scannerControls = await reader.decodeFromConstraints(
{ video: { facingMode: 'environment' } },
video,
(result, _error, controls) => {
if (!result) return
controls.stop()
scannerControls = null
emitScan(result.getText())
scheduleRestart()
}
)
const BarcodeDetectorClass = (window as any).BarcodeDetector
if (BarcodeDetectorClass == null || navigator.mediaDevices == null) {
handleCameraError()
return
}
scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
video.srcObject = scanStream
await video.play()
const detector = new BarcodeDetectorClass({ formats: ['qr_code'] })
void scanWebFrame(detector)
} catch (_error) {
stopWebScanner()
handleCameraError()
}
}
function scheduleRestart() {
if (!isPageActive || !props.active || restartTimer != null) return
restartTimer = setTimeout(() => {
restartTimer = null
void startWebScanner()
}, props.cooldown)
async function scanWebFrame(detector: any) {
if (!isPageActive || !props.active || scanVideo == null || scanStream == null) return
try {
const results = await detector.detect(scanVideo)
if (results.length > 0) {
emitScan(results[0].rawValue as string)
}
} catch (_error) {
handleCameraError()
return
}
scanTimer = setTimeout(() => {
scanTimer = null
void scanWebFrame(detector)
}, 250)
}
function stopWebScanner() {
if (restartTimer != null) {
clearTimeout(restartTimer)
restartTimer = null
if (scanTimer != null) {
clearTimeout(scanTimer)
scanTimer = null
}
if (scannerControls != null) {
scannerControls.stop()
scannerControls = null
if (scanStream != null) {
scanStream.getTracks().forEach((track) => {
track.stop()
})
scanStream = null
}
if (scanVideo != null) {
scanVideo.srcObject = null
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<view class="activity-chart"><view class="activity-scale"><text class="activity-scale-text">时长(分)</text><text class="activity-scale-text">200</text><text class="activity-scale-text">100</text><text class="activity-scale-text">0</text></view><view class="activity-columns"><view v-for="point in points" :key="point.label" class="activity-column"><view class="activity-bar-wrap"><view class="activity-grid-line activity-grid-line-top"></view><view class="activity-grid-line activity-grid-line-middle"></view><view class="activity-grid-line activity-grid-line-bottom"></view><view class="activity-planned-bar" :style="{ height: point.plannedHeight + 'px' }"></view><view class="activity-study-bar" :style="{ height: point.studyHeight + 'px' }"></view><view v-if="point.newMasteredCount > 0" class="activity-mastery-point" :style="{ bottom: point.newMasteredHeight + 'px' }"><text class="activity-mastery-count">{{ point.newMasteredCount }}</text></view></view><text class="activity-day">{{ point.label }}</text></view></view><view class="activity-mastery-scale"><text class="activity-mastery-scale-text">新增掌握(个)</text><text class="activity-mastery-scale-text">3 个</text><text class="activity-mastery-scale-text">2 个</text><text class="activity-mastery-scale-text">1 个</text><text class="activity-mastery-scale-text">0 个</text></view></view>
<view class="activity-chart"><view class="activity-scale"><text class="activity-scale-text">时长(分)</text><text class="activity-scale-text">200</text><text class="activity-scale-text">100</text><text class="activity-scale-text">0</text></view><view class="activity-columns"><view v-for="point in points" :key="point.label" class="activity-column"><view class="activity-bar-wrap"><view class="activity-grid-line activity-grid-line-top"></view><view class="activity-grid-line activity-grid-line-middle"></view><view class="activity-grid-line activity-grid-line-bottom"></view><view class="activity-planned-bar" :style="{ height: point.plannedHeight + 'px' }"></view><view class="activity-study-bar" :style="{ height: point.studyHeight + 'px' }"></view><view v-if="point.newMasteredCount > 0" class="activity-mastery-point" :style="{ bottom: point.newMasteredHeight + 'px' }"><text class="activity-mastery-count">{{ point.newMasteredCount }}</text></view></view><text class="activity-day">{{ point.label }}</text></view></view><view class="activity-mastery-scale"><text class="activity-mastery-scale-text">未攻克(分)</text><text class="activity-mastery-scale-text">90</text><text class="activity-mastery-scale-text">60</text><text class="activity-mastery-scale-text">30</text><text class="activity-mastery-scale-text">0</text></view></view>
</template>
<script setup lang="ts">
+1 -48
View File
@@ -7,54 +7,7 @@
"": {
"name": "app.k12.parents",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@zxing/browser": "^0.2.1"
}
},
"node_modules/@zxing/browser": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz",
"integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.23.0"
}
},
"node_modules/@zxing/library": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.3.1"
},
"engines": {
"node": ">= 24.0.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
"license": "ISC"
}
}
}
+1 -3
View File
@@ -11,7 +11,5 @@
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@zxing/browser": "^0.2.1"
}
"dependencies": {}
}
+153 -26
View File
@@ -1,68 +1,195 @@
<template>
<view class="page-container">
<view class="header-panel"><text class="header-title">预警信息</text><text class="header-subtitle">{{ studentName }}的学习动态</text></view>
<view class="header-panel">
<view class="back-button" @tap="goBack"><text class="back-icon">‹</text></view>
<view class="header-copy"><text class="header-title">学习预警</text><text class="header-subtitle">{{ studentName }}</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-if="alerts.length === 0" class="status-panel"><text class="status-text">暂无预警信息</text></view>
<view v-else v-for="alert in alerts" :key="alert.id" class="alert-card">
<view class="alert-card-top"><view class="alert-level-dot" :class="levelDotClass(alert.level)"></view><text class="alert-title">{{ alert.title }}</text><text v-if="!alert.isRead" class="unread-mark">未读</text></view>
<text class="alert-content">{{ alert.content }}</text>
<text class="alert-time">{{ formatTime(alert.occurredAt) }}</text>
<view v-else class="content-body">
<view class="summary-card">
<text class="summary-title">今日触发预警</text>
<view class="summary-count-row"><text class="summary-count">{{ triggeredCount }}</text><text class="summary-unit">条</text></view>
<text class="summary-desc">其中 {{ triggeredCount }} 条与作业截止相关,建议今晚提醒孩子提交。</text>
</view>
<view class="list-heading"><text class="list-title">全部预警规则</text><text class="list-total">共 {{ alerts.length }} 条</text></view>
<view v-for="alert in alerts" :key="alert.id" class="alert-card">
<view class="alert-card-top"><view class="alert-level-dot" :class="alert.isTriggered ? 'alert-level-triggered' : 'alert-level-normal'"></view><text class="alert-title">{{ alert.title }}</text><text class="alert-status" :class="alert.isTriggered ? 'alert-status-triggered' : 'alert-status-normal'">{{ alert.isTriggered ? '已触发' : '正常' }}</text></view>
<text class="alert-content">{{ alert.content }}</text>
</view>
</view>
</scroll-view>
<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 { apiGetStudentAlerts, type StudentAlert, type StudentAlertLevel } from '../../api/student'
import { homeStudentProfile } from '../../store/home'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetBoundStudents, apiGetStudentLearningWarnings, type BoundStudentSummary, type StudentAlert } from '../../api/student'
import { homeStudentProfile, selectedStudentChangeVersion, switchBoundStudent } from '../../store/home'
const alerts = ref<StudentAlert[]>([])
const isLoading = ref(true)
const isStudentsLoading = ref(false)
const showStudentPicker = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('')
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const studentName = computed((): string => homeStudentProfile.value?.name ?? '孩子')
const triggeredCount = computed((): number => alerts.value.filter((item: StudentAlert) => item.isTriggered).length)
onLoad(() => {
loadAlerts()
})
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadAlerts()
}
})
async function loadAlerts() {
isLoading.value = true
const studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
isLoading.value = false
return
}
const res = await apiGetStudentAlerts(studentId, 1, 20)
const res = await apiGetStudentLearningWarnings(studentId)
if (res && res.data) {
alerts.value = res.data.list
}
isLoading.value = false
}
function levelDotClass(level: StudentAlertLevel): string {
return level === 'low' ? 'alert-level-low' : level === 'medium' ? 'alert-level-medium' : 'alert-level-high'
function goBack() {
uni.navigateBack()
}
function formatTime(value: string): string {
return value.length > 0 ? value.substring(5, 16).replace('T', ' ') : ''
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 loadAlerts()
}
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: column; padding: 18px 20px; background-color: #5d3de8; }
.header-title { font-size: 21px; font-weight: 700; color: #ffffff; }
.header-subtitle { margin-top: 6px; font-size: 13px; color: #e5ddff; }
.content-scroll { flex: 1; padding: 16px 20px; }
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f6f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 6px 4px 8px; background-color: #ffffff; }
.back-button { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #eef2f7; }
.back-icon { font-size: 30px; line-height: 32px; color: #415066; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 7px; }
.header-title { font-size: 15px; font-weight: 700; color: #111827; }
.header-subtitle { margin-top: 4px; font-size: 11px; color: #7c8aa0; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; margin-right: 4px; border-radius: 18px; background-color: #edf1ff; }
.header-action-icon { font-size: 18px; color: #5947ea; }
.content-scroll { flex: 1; padding: 14px 4px 8px; }
.content-body { display: flex; flex-direction: column; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 160px; }
.status-text { font-size: 14px; color: #8992a5; }
.alert-card { display: flex; flex-direction: column; margin-bottom: 12px; padding: 16px; border-radius: 14px; background-color: #ffffff; }
.summary-card { display: flex; flex-direction: column; padding: 15px 16px 16px; margin-bottom: 14px; border-radius: 12px; background-color: #683dec; }
.summary-title { font-size: 12px; font-weight: 600; color: #ffffff; }
.summary-count-row { display: flex; flex-direction: row; align-items: flex-end; margin-top: 9px; }
.summary-count { font-size: 26px; line-height: 30px; font-weight: 800; color: #ffffff; }
.summary-unit { margin-left: 6px; margin-bottom: 3px; font-size: 13px; font-weight: 600; color: #ffffff; }
.summary-desc { margin-top: 10px; font-size: 12px; line-height: 18px; color: #ffffff; }
.list-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 9px; }
.list-title { font-size: 15px; font-weight: 700; color: #121826; }
.list-total { font-size: 12px; color: #7e8ba0; }
.alert-card { display: flex; flex-direction: column; margin-bottom: 10px; padding: 15px 14px; border-radius: 13px; background-color: #ffffff; border: 1px solid #edf0f5; }
.alert-card-top { display: flex; flex-direction: row; align-items: center; }
.alert-level-dot { width: 9px; height: 9px; margin-right: 8px; border-radius: 5px; }
.alert-level-high { background-color: #e25a5a; }
.alert-level-medium { background-color: #e8a129; }
.alert-level-low { background-color: #4d87cf; }
.alert-title { flex: 1; font-size: 15px; font-weight: 700; color: #30384a; }
.unread-mark { padding: 3px 7px; border-radius: 8px; font-size: 10px; color: #554be9; background-color: #eeefff; }
.alert-content { margin-top: 10px; font-size: 13px; line-height: 20px; color: #626c7e; }
.alert-time { margin-top: 10px; font-size: 11px; color: #9aa2b1; }
.alert-level-dot { width: 7px; height: 7px; margin-right: 11px; border-radius: 4px; }
.alert-level-triggered { background-color: #ff3f6b; }
.alert-level-normal { background-color: #d2dce8; }
.alert-title { flex: 1; font-size: 14px; font-weight: 700; color: #1f2a3d; }
.alert-status { padding: 4px 8px; border-radius: 12px; font-size: 10px; }
.alert-status-triggered { color: #ff3f6b; background-color: #fff0f4; }
.alert-status-normal { color: #00a86b; background-color: #eafaf3; }
.alert-content { margin-top: 10px; margin-left: 18px; font-size: 12px; line-height: 19px; color: #536278; }
.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>
+10 -1
View File
@@ -8,14 +8,23 @@
<script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentGrowth, apiGetStudentHomeProfile, type StudentGrowth } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile } from '../../store/home'
const growth = ref<StudentGrowth>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pointsBalance: 0, level: 0, rank: 0, streakDays: 0, totalPoints: 0, records: [] })
const isLoading = ref(true)
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
onLoad(() => { loadGrowth() })
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadGrowth()
}
})
async function loadGrowth() {
isLoading.value = true
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
+59 -16
View File
@@ -61,8 +61,8 @@
<text class="metric-value metric-blue">{{ metrics.learningDaysThisWeek }}/{{ metrics.shouldStudyDaysThisWeek }} 天</text>
</view>
<view class="metric-item">
<text class="metric-label">待完成作业</text>
<text class="metric-value metric-orange">{{ metrics.pendingHomeworkCount }} 个</text>
<text class="metric-label">待完成学习包</text>
<text class="metric-value metric-orange">{{ metrics.pendingPackCount }} 个</text>
</view>
</view>
</view>
@@ -100,10 +100,10 @@
<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 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"><image v-if="item.avatar" class="student-picker-avatar-image" :src="item.avatar" mode="aspectFill"></image><text v-else class="student-picker-avatar-text">{{ item.name.substring(0, 1) }}</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>
<view v-if="selectedStudentId === item.id" class="student-picker-check"><text class="student-picker-check-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-role">{{ studentPickerRole(index) }}</text><text class="student-picker-status">{{ studentPickerStatus(index) }}</text></view><text class="student-picker-detail">{{ item.school }} · {{ item.className }}</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>
@@ -116,7 +116,7 @@
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetBoundStudents, apiGetStudentHomeMetrics, apiGetStudentLearningWarnings, type BoundStudentSummary, type StudentAlert, type StudentAlertLevel, type StudentHomeMetrics, type StudentHomeProfile } from '../../api/student'
import { applyBoundStudent, getSavedSelectedStudent, homeStudentAlerts, homeStudentMetrics, homeStudentProfile, saveSelectedStudent, setHomeStudentAlerts, setHomeStudentMetrics } from '../../store/home'
import { applyBoundStudent, getSavedSelectedStudent, homeStudentAlerts, homeStudentMetrics, homeStudentProfile, saveSelectedStudent, selectedStudentChangeVersion, setHomeStudentAlerts, setHomeStudentMetrics, switchBoundStudent } from '../../store/home'
import type { UserInfo } from '../../api/auth'
const loadingStudent = ref<StudentHomeProfile>({
@@ -134,10 +134,11 @@
const isStudentsLoading = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('')
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const student = computed((): StudentHomeProfile => homeStudentProfile.value ?? loadingStudent.value)
const metrics = computed((): StudentHomeMetrics => homeStudentMetrics.value ?? {
studentId: '', onlineToday: false, checkedInToday: false, learningMinutesToday: 0,
accuracyToday: null, learningDaysThisWeek: 0, shouldStudyDaysThisWeek: 0, pendingHomeworkCount: 0, updatedAt: ''
accuracyToday: null, learningDaysThisWeek: 0, shouldStudyDaysThisWeek: 0, pendingPackCount: 0, updatedAt: ''
})
const alerts = computed((): StudentAlert[] => homeStudentAlerts.value ?? [])
const parentName = computed((): string => {
@@ -174,6 +175,24 @@
loadHomeData()
})
onShow(() => {
syncSelectedStudentFromStorage()
})
async function syncSelectedStudentFromStorage() {
const savedStudent = getSavedSelectedStudent()
if (savedStudent && savedStudent.id !== homeStudentProfile.value?.id) {
applyBoundStudent(savedStudent)
observedStudentVersion.value = selectedStudentChangeVersion.value
await loadHomeData(savedStudent.id, false)
return
}
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
await loadHomeData(homeStudentProfile.value?.id ?? '', false)
}
}
async function loadStudentHomeMetrics(studentId: string, showLoading: boolean = true) {
const res = await apiGetStudentHomeMetrics(studentId, showLoading)
if (res && res.data) {
@@ -182,7 +201,7 @@
}
async function loadStudentAlerts(studentId: string, showLoading: boolean = true) {
const res = await apiGetStudentLearningWarnings(studentId, showLoading)
const res = await apiGetStudentLearningWarnings(studentId, showLoading, 3)
if (res && res.data) {
setHomeStudentAlerts(res.data.list)
}
@@ -227,7 +246,6 @@
async function openStudentPicker() {
showStudentPicker.value = true
selectedStudentId.value = student.value.id
if (boundStudents.value.length > 0) return
isStudentsLoading.value = true
const res = await apiGetBoundStudents()
if (res && res.data) {
@@ -243,6 +261,26 @@
selectedStudentId.value = studentId
}
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 studentPickerRole(index: number): string {
if (index === 0) return '儿子'
if (index === 1) return '侄子'
return '女儿'
}
function studentPickerStatus(index: number): string {
return index === 1 ? '中上' : '优秀'
}
function alertLevelClass(level: StudentAlertLevel): string {
return level === 'low' ? 'reminder-blue' : level === 'medium' ? 'reminder-yellow' : 'reminder-red'
}
@@ -279,11 +317,9 @@
closeStudentPicker()
const selectedStudent = boundStudents.value.find((item) => item.id === selectedStudentId.value)
if (selectedStudent) {
saveSelectedStudent(selectedStudent)
applyBoundStudent(selectedStudent)
}
if (selectedStudentId.value !== student.value.id) {
await loadHomeData(selectedStudentId.value)
switchBoundStudent(selectedStudent)
observedStudentVersion.value = selectedStudentChangeVersion.value
await loadHomeData(selectedStudent.id)
}
}
</script>
@@ -412,18 +448,25 @@
.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: 14px 12px; margin-bottom: 10px; border-radius: 13px; background-color: #f7f9fc; }
.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-role { margin-left: 7px; padding: 2px 6px; border-radius: 8px; font-size: 9px; color: #7c8ba1; background-color: #f1f4f8; }
.student-picker-status { margin-left: 7px; padding: 2px 6px; border-radius: 8px; font-size: 9px; color: #16a979; background-color: #e7faf2; }
.student-picker-detail { margin-top: 2px; font-size: 11px; color: #7e8799; }
.student-picker-check { padding: 4px 7px; border-radius: 9px; background-color: #554be9; }
.student-picker-check-text { font-size: 10px; color: #ffffff; }
.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 calc(12px + var(--uni-safe-area-inset-bottom)); 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; }
+2 -3
View File
@@ -68,7 +68,7 @@
import { apiLogin } from '../../api/auth'
import { apiGetBoundStudents, type BoundStudentSummary } from '../../api/student'
import { setUserInfo, saveRememberInfo, getRememberInfo } from '../../store/user'
import { applyBoundStudent, saveSelectedStudent } from '../../store/home'
import { switchBoundStudent } from '../../store/home'
type LoginForm = {
phone: string
@@ -144,8 +144,7 @@
? res.data.students.find((item) => item.id === savedStudent.id)!
: res.data.students[0]
saveSelectedStudent(selectedStudent)
applyBoundStudent(selectedStudent)
switchBoundStudent(selectedStudent)
}
/**
+11 -3
View File
@@ -62,7 +62,7 @@
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import { apiGetBoundStudents, type BoundStudentSummary } from '../../api/student'
import { applyBoundStudent, homeStudentProfile, saveSelectedStudent } from '../../store/home'
import { homeStudentProfile, selectedStudentChangeVersion, switchBoundStudent } from '../../store/home'
import type { UserInfo } from '../../api/auth'
import { clearUserInfo } from '../../store/user'
@@ -72,9 +72,17 @@
const selectedStudentId = ref(homeStudentProfile.value?.id ?? '')
const isLoading = ref(true)
const listFailed = ref(false)
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
onLoad(() => { loadStudents() })
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
selectedStudentId.value = homeStudentProfile.value?.id ?? selectedStudentId.value
}
})
async function loadStudents() {
isLoading.value = true
listFailed.value = false
@@ -92,8 +100,8 @@
selectedStudentId.value = studentId
const student = students.value.find((item) => item.id === studentId)
if (student) {
saveSelectedStudent(student)
applyBoundStudent(student)
switchBoundStudent(student)
observedStudentVersion.value = selectedStudentChangeVersion.value
}
}
+124 -10
View File
@@ -2,7 +2,7 @@
<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"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></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>
@@ -14,16 +14,38 @@
<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 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 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="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 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 === 'all'" 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 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>
@@ -32,18 +54,24 @@
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 { apiGetStudentHomeProfile, apiGetStudentStudyReport, type StudentStudyReport, type StudyReportActivity, type StudyReportPeriod, type StudyReportSubjectMastery, type StudyReportTrendPoint } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
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: [], week: emptyPeriod, month: emptyPeriod })
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('all')
const activeSubjectId = ref('0')
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const activeReport = computed((): StudyReportPeriod => activePeriod.value === 'week' ? report.value.week : report.value.month)
const reportPeriodLabel = computed((): string => activePeriod.value === 'week' ? '本周' : '本月')
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]
@@ -56,7 +84,16 @@
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)
@@ -79,12 +116,57 @@
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>
@@ -104,6 +186,10 @@
.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; }
@@ -152,4 +238,32 @@
.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>
+222 -52
View File
@@ -1,100 +1,270 @@
<template>
<view class="page-container"><view class="header-panel"><view class="header-copy"><text class="header-title">作业任务</text><text class="header-subtitle">{{ tasks.studentName }} · 待完成 {{ tasks.pendingCount }} 个</text></view><view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view></view><view class="filter-panel"><view v-for="filter in filters" :key="filter.id" class="filter-tab" :class="activeFilter === filter.id ? 'filter-tab-active' : ''" @tap="setFilter(filter.id)"><text class="filter-text" :class="activeFilter === filter.id ? 'filter-text-active' : ''">{{ filter.name }}</text></view></view><scroll-view class="content-scroll" direction="vertical" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="refreshTasks"><view v-if="isLoading" class="status-panel"><text class="status-text">任务加载中</text></view><view v-else class="content-panel"><view v-for="group in visibleGroups" :key="group.id" class="group-card"><view class="group-heading"><view class="group-tags"><text class="subject-tag">{{ group.subject }}</text><text class="unit-tag">{{ group.unit }}</text><text class="lesson-title">{{ group.lesson }}</text></view><text class="knowledge-count">{{ group.knowledgeCount > 0 ? group.knowledgeCount + ' 个知识点' : '暂无学习内容' }}</text></view><text class="lesson-time">● {{ group.startText }}-{{ group.endText }}</text><view v-if="group.note" class="group-note"><text class="group-note-icon">i</text><text class="group-note-text">{{ group.note }}</text></view><view v-for="task in group.tasks" :key="task.id" v-show="matchesFilter(task)" class="detail-task"><view class="detail-task-heading"><text class="detail-task-title">{{ task.title }}</text><text class="detail-task-status" :class="'detail-task-status-' + task.status">{{ task.statusText }}</text></view><text class="detail-task-result" :class="'detail-task-result-' + task.status">● {{ task.statusText }} · 共 {{ task.status === 'success' ? '2' : '3' }} 次</text><text v-if="task.accuracyText" class="detail-task-meta">{{ task.accuracyText }} · {{ task.answerText }} · {{ task.deadlineText }}</text></view></view><view v-if="visibleGroups.length === 0" class="empty-panel"><text class="empty-text">暂无对应任务</text></view></view></scroll-view><MainTabBar active-tab="tasks"></MainTabBar></view>
<view class="page-container">
<view class="header-panel">
<view class="header-copy">
<text class="header-title">作业任务</text>
<text class="header-subtitle">{{ headerStudentName }} · 待完成 {{ tasks.pendingCount }} 个</text>
</view>
<view class="header-action" @tap="openStudentPicker"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view>
</view>
<view class="filter-panel">
<view v-for="filter in filters" :key="filter.id" class="filter-tab" :class="activeFilter === filter.id ? 'filter-tab-active' : ''" @tap="setFilter(filter.id)">
<text class="filter-text" :class="activeFilter === filter.id ? 'filter-text-active' : ''">{{ filter.name }}</text>
</view>
</view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="refreshTasks">
<view v-if="isLoading" class="status-panel"><text class="status-text">任务加载中</text></view>
<view v-else-if="visibleGroups.length === 0" class="empty-panel"><text class="empty-text">暂无任务</text></view>
<view v-else class="content-panel">
<view v-for="group in visibleGroups" :key="group.id" class="group-card">
<view class="group-heading">
<view class="group-title-row">
<text class="subject-tag">{{ group.subject || '学科' }}</text>
<text class="unit-tag">{{ group.unit || '学习' }}</text>
<text class="lesson-title">{{ group.lesson }}</text>
</view>
<text class="knowledge-count">{{ group.knowledgeCount }} 个知识点</text>
</view>
<view class="lesson-time-row"><view class="time-dot"></view><text class="lesson-time">今天 {{ group.startText }}—{{ group.endText }} 开课</text></view>
<view v-if="group.tasks.length === 0" class="task-empty"><text class="task-empty-text">暂无学习包</text></view>
<view v-for="task in group.tasks" :key="task.id" v-show="matchesFilter(task)" class="task-card">
<view class="task-heading"><text class="task-title">{{ task.title }}</text><text class="task-status" :class="taskStatusClass(task)">{{ task.statusText }}</text></view>
<view class="task-result-row"><view class="task-result-dot" :class="taskResultDotClass(task)"></view><text class="task-result" :class="taskResultClass(task)">{{ task.resultText }}</text></view>
<text class="task-attempt">{{ task.attemptText }}</text>
<view class="task-divider"></view>
<text class="task-meta">{{ task.metaText }}</text>
</view>
</view>
</view>
</scroll-view>
<MainTabBar active-tab="tasks"></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 { apiGetParentStudyPacks, apiGetStudentHomeProfile, type LearningTaskItem, type ParentStudyPackStatus, type StudentLearningTasks } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
import { apiGetBoundStudents, apiGetParentStudyPacks, apiGetStudentHomeProfile, type BoundStudentSummary, type LearningTaskItem, type ParentStudyPackStatus, type StudentLearningTasks } from '../../api/student'
import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile, switchBoundStudent } from '../../store/home'
const filters = [{ id: 'all', name: '全部' }, { id: 'pending', name: '待完成' }, { id: 'completed', name: '已完成' }, { id: 'failed', name: '未通过' }]
const activeFilter = ref('all')
const activeStatus = computed((): ParentStudyPackStatus => activeFilter.value === 'pending' ? 1 : activeFilter.value === 'completed' ? 2 : activeFilter.value === 'failed' ? 3 : 0)
const isLoading = ref(true)
const isRefreshing = ref(false)
const tasks = ref<StudentLearningTasks>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pendingCount: 0, completedCount: 0, failedCount: 0, groups: [] })
const isStudentsLoading = ref(false)
const showStudentPicker = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('')
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const tasks = ref<StudentLearningTasks>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', totalCount: 0, pendingCount: 0, completedCount: 0, failedCount: 0, groups: [] })
const visibleGroups = computed(() => tasks.value.groups.filter((group) => group.tasks.length === 0 || group.tasks.some((task) => matchesFilter(task))))
const headerStudentName = computed((): string => homeStudentProfile.value?.name ?? tasks.value.studentName)
const headerStudentGrade = computed((): string => homeStudentProfile.value?.grade ?? tasks.value.studentGrade)
const headerStudentClass = computed((): string => homeStudentProfile.value?.className ?? tasks.value.studentClass)
const headerStudentName = computed((): string => homeStudentProfile.value?.name ?? tasks.value.studentName ?? '孩子')
onLoad(() => { loadTasks() })
async function loadTasks() {
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadTasks(false)
}
})
async function loadTasks(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 (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (studentId) {
const res = await apiGetParentStudyPacks(studentId, activeStatus.value)
if (res && res.data) {
tasks.value = {
...res.data,
studentName: `${homeStudentProfile.value?.name ?? res.data.studentName} · ${homeStudentProfile.value?.school ?? ''} ${homeStudentProfile.value?.className ?? res.data.studentClass}`
}
}
const res = await apiGetParentStudyPacks(studentId, activeStatus.value, showLoading)
if (res && res.data) tasks.value = res.data
}
isLoading.value = false
}
async function refreshTasks() {
isRefreshing.value = true
await loadTasks()
await loadTasks(false)
isRefreshing.value = false
}
async function setFilter(filter: string) {
if (activeFilter.value === filter) return
activeFilter.value = filter
await loadTasks()
await loadTasks(true)
}
function matchesFilter(task: LearningTaskItem): boolean {
if (activeFilter.value === 'all') return true
if (activeFilter.value === 'pending') return task.status === 'not_started' || task.status === 'in_progress'
if (activeFilter.value === 'completed') return task.status === 'success'
return task.status === 'failed'
}
function taskStatusClass(task: LearningTaskItem): string {
return `task-status-${task.status}`
}
function taskResultClass(task: LearningTaskItem): string {
return `task-result-${task.status}`
}
function taskResultDotClass(task: LearningTaskItem): string {
return `task-result-dot-${task.status}`
}
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 loadTasks(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'
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 16px 20px; background-color: #ffffff; border-bottom: 1px solid #edf0f5; }
.header-panel { display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 4px 8px 10px; background-color: #ffffff; }
.header-copy { display: flex; flex-direction: column; }
.header-title { font-size: 18px; font-weight: 700; color: #242b3d; }
.header-subtitle { margin-top: 4px; font-size: 11px; 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: #554be9; }
.filter-panel { display: flex; flex-direction: row; padding: 12px 20px; background-color: #ffffff; }
.filter-tab { display: flex; flex-direction: column; align-items: center; flex: 1; margin-right: 8px; padding: 7px 0; border-radius: 16px; text-align: center; background-color: #f1f4f8; }
.filter-tab-active { background-color: #554be9; }
.filter-text { font-size: 11px; color: #64748b; }
.header-title { font-size: 17px; font-weight: 800; color: #101827; }
.header-subtitle { margin-top: 5px; font-size: 11px; color: #7e8da3; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #edf1ff; }
.header-action-icon { font-size: 18px; color: #5947ea; }
.filter-panel { display: flex; flex-direction: row; padding: 10px 8px 12px; background-color: #ffffff; }
.filter-tab { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; height: 28px; margin-right: 8px; border-radius: 16px; background-color: #eef2f7; }
.filter-tab-active { background-color: #5a45e8; }
.filter-text { font-size: 11px; color: #4e5b70; }
.filter-text-active { color: #ffffff; }
.content-scroll { flex: 1; }
.content-panel { display: flex; flex-direction: column; padding: 14px 16px 24px; }
.status-panel, .empty-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; }
.status-text, .empty-text { font-size: 13px; color: #8992a5; }
.group-card { display: flex; flex-direction: column; margin-bottom: 12px; padding: 16px; border-radius: 15px; background-color: #ffffff; }
.content-panel { display: flex; flex-direction: column; padding: 14px 8px 24px; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; }
.empty-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; }
.status-text { font-size: 13px; color: #8992a5; }
.empty-text { font-size: 13px; color: #8992a5; }
.group-card { display: flex; flex-direction: column; margin-bottom: 12px; padding: 16px 14px 14px; border-radius: 14px; background-color: #ffffff; border: 1px solid #edf0f5; }
.group-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.group-tags { display: flex; flex-direction: row; align-items: center; flex: 1; }
.subject-tag, .unit-tag { padding: 3px 7px; margin-right: 6px; border-radius: 8px; font-size: 10px; color: #554be9; background-color: #eeefff; }
.unit-tag { color: #1598dc; background-color: #e6f5ff; }
.lesson-title { font-size: 13px; font-weight: 700; color: #242b3d; }
.knowledge-count { flex-shrink: 0; font-size: 10px; color: #8992a5; }
.lesson-time { margin-top: 7px; font-size: 11px; color: #8ca0bd; }
.group-note { display: flex; flex-direction: row; align-items: flex-start; margin-top: 12px; padding: 12px; border-radius: 12px; background-color: #f6f8fb; }
.group-note-icon { width: 12px; height: 12px; margin-right: 7px; border-radius: 6px; text-align: center; font-size: 9px; color: #ffffff; background-color: #9aa8bc; }
.group-note-text { flex: 1; font-size: 11px; line-height: 18px; color: #68778b; }
.detail-task { display: flex; flex-direction: column; margin-top: 10px; padding: 12px; border: 1px solid #edf0f5; border-radius: 12px; background-color: #f9fbfd; }
.detail-task-heading { display: flex; flex-direction: row; justify-content: space-between; }
.detail-task-title { font-size: 13px; color: #242b3d; }
.detail-task-status { padding: 3px 7px; border-radius: 8px; font-size: 10px; }
.detail-task-status-success { color: #16a979; background-color: #e8fbf3; }
.detail-task-status-in_progress { color: #5147e9; background-color: #eeefff; }
.detail-task-status-failed { color: #f34d54; background-color: #fff0f1; }
.detail-task-result { margin-top: 8px; font-size: 11px; }
.detail-task-result-success { color: #16a979; }
.detail-task-result-in_progress { color: #5147e9; }
.detail-task-result-failed { color: #f34d54; }
.detail-task-meta { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e4eaf1; font-size: 10px; color: #8992a5; }
.group-title-row { display: flex; flex-direction: row; align-items: center; flex: 1; min-width: 0; }
.subject-tag { padding: 3px 7px; margin-right: 6px; border-radius: 9px; font-size: 10px; font-weight: 700; color: #5947ea; background-color: #eeefff; }
.unit-tag { padding: 3px 7px; margin-right: 8px; border-radius: 9px; font-size: 10px; font-weight: 700; color: #2f6bea; background-color: #edf5ff; }
.lesson-title { flex: 1; font-size: 14px; font-weight: 800; color: #1f2937; }
.knowledge-count { flex-shrink: 0; font-size: 11px; color: #8a99ae; }
.lesson-time-row { display: flex; flex-direction: row; align-items: center; margin-top: 8px; }
.time-dot { width: 8px; height: 8px; margin-right: 6px; border-radius: 4px; background-color: #9caec4; }
.lesson-time { font-size: 11px; color: #71849d; }
.task-empty { display: flex; flex-direction: column; align-items: center; padding: 18px 0; }
.task-empty-text { font-size: 12px; color: #8992a5; }
.task-card { display: flex; flex-direction: column; margin-top: 13px; padding: 12px; border-radius: 12px; background-color: #f8fafc; border: 1px solid #edf1f6; }
.task-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.task-title { flex: 1; font-size: 14px; font-weight: 700; color: #1f2937; }
.task-status { flex-shrink: 0; padding: 4px 8px; border-radius: 11px; font-size: 10px; }
.task-status-not_started { color: #516175; background-color: #edf2f7; }
.task-status-in_progress { color: #5947ea; background-color: #eeefff; }
.task-status-success { color: #04a86b; background-color: #e8fbf2; }
.task-status-failed { color: #ef4d62; background-color: #fff0f3; }
.task-result-row { display: flex; flex-direction: row; align-items: center; margin-top: 9px; }
.task-result-dot { width: 9px; height: 9px; margin-right: 7px; border-radius: 5px; }
.task-result-dot-not_started { background-color: #b6c2d0; }
.task-result-dot-in_progress { background-color: #5947ea; }
.task-result-dot-success { background-color: #04a86b; }
.task-result-dot-failed { background-color: #ef4d62; }
.task-result { font-size: 12px; font-weight: 700; }
.task-result-not_started { color: #64748b; }
.task-result-in_progress { color: #5947ea; }
.task-result-success { color: #04a86b; }
.task-result-failed { color: #ef4d62; }
.task-attempt { margin-top: 6px; font-size: 11px; color: #71849d; }
.task-divider { height: 1px; margin-top: 11px; background-color: #e3e9f1; }
.task-meta { margin-top: 9px; font-size: 11px; color: #71849d; }
.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>
+10 -1
View File
@@ -56,7 +56,7 @@
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetTodayLearningDetail, type TodayLearningDetail } from '../../api/student'
import { homeStudentProfile } from '../../store/home'
import { homeStudentProfile, selectedStudentChangeVersion } from '../../store/home'
const isLoading = ref(true)
const detail = ref<TodayLearningDetail>({
@@ -64,12 +64,21 @@
answerCount: 0, completedPackages: 0, totalPackages: 0, masteredPoints: 0, courses: [],
score: { accuracy: 0, yesterdayAccuracy: 0, change: 0 }, pointsTotal: 0, pointDetails: []
})
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
onLoad(() => {
loadTodayLearning()
})
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadTodayLearning()
}
})
async function loadTodayLearning() {
isLoading.value = true
const studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
isLoading.value = false
+10 -1
View File
@@ -8,14 +8,23 @@
<script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentHomeProfile, apiGetStudentVocabulary, type StudentVocabulary, type VocabularyBook } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile } from '../../store/home'
const vocabulary = ref<StudentVocabulary>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', bookCount: 0, books: [] })
const isLoading = ref(true)
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
onLoad(() => { loadVocabulary() })
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadVocabulary()
}
})
async function loadVocabulary() {
isLoading.value = true
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
+13 -2
View File
@@ -1,9 +1,12 @@
import type { StudentAlert, StudentHomeMetrics, StudentHomeProfile } from '../api/student'
type BoundStudentLike = { id: string, name: string, account: string, grade: string, school: string, className: string, avatar: string }
// 仅在本次登录会话内缓存首页学生资料,切换一级页面时不重复请求。
export const homeStudentProfile = ref<StudentHomeProfile | null>(null)
export const homeStudentMetrics = ref<StudentHomeMetrics | null>(null)
export const homeStudentAlerts = ref<StudentAlert[] | null>(null)
export const selectedStudentChangeVersion = ref(0)
export function setHomeStudentProfile(profile: StudentHomeProfile) {
const userInfo = uni.getStorageSync('userInfo') as { userId?: string } | null
@@ -23,7 +26,7 @@ export function setHomeStudentProfile(profile: StudentHomeProfile) {
}
}
export function saveSelectedStudent(student: { id: string, name: string, account: string, grade: string, school: string, className: string, avatar: string }) {
export function saveSelectedStudent(student: BoundStudentLike) {
const userInfo = uni.getStorageSync('userInfo') as { userId?: string } | null
if (userInfo?.userId) uni.setStorageSync(`selectedStudent_${userInfo.userId}`, student)
}
@@ -34,11 +37,19 @@ export function getSavedSelectedStudent(): { id: string, name: string, account:
return uni.getStorageSync(`selectedStudent_${userInfo.userId}`) as { id: string, name: string, account: string, grade: string, school: string, className: string, avatar: string } | null
}
export function applyBoundStudent(student: { id: string, name: string, account: string, grade: string, school: string, className: string, avatar: string }) {
export function applyBoundStudent(student: BoundStudentLike) {
const comprehensiveLevel = homeStudentProfile.value?.id === student.id ? homeStudentProfile.value.comprehensiveLevel : 'good'
setHomeStudentProfile({ ...student, comprehensiveLevel })
}
export function switchBoundStudent(student: BoundStudentLike) {
saveSelectedStudent(student)
applyBoundStudent(student)
homeStudentMetrics.value = null
homeStudentAlerts.value = null
selectedStudentChangeVersion.value++
}
export function setHomeStudentMetrics(metrics: StudentHomeMetrics) {
homeStudentMetrics.value = metrics
}
+11 -6
View File
@@ -96,7 +96,7 @@ function requestInterceptor(config: RequestConfig): RequestConfig {
/**
* 响应拦截器 - 统一处理响应
*/
function responseInterceptor<T>(response: UniNamespace.RequestSuccessCallbackResult, config: RequestConfig): ApiResponse<T> | null {
function responseInterceptor<T>(response: any, config: RequestConfig): ApiResponse<T> | null {
// 隐藏Loading
if (config.showLoading !== false) {
hideLoading()
@@ -108,8 +108,13 @@ function responseInterceptor<T>(response: UniNamespace.RequestSuccessCallbackRes
return null
}
// 解析响应数据
const result = response.data as ApiResponse<T>
// 解析响应数据,兼容后端 PascalCase 与前端 camelCase 两种包装格式。
const rawResult = response.data as any
const result: ApiResponse<T> = {
code: Number(rawResult?.code ?? rawResult?.Code),
message: String(rawResult?.message ?? rawResult?.Message ?? ''),
data: (rawResult?.data ?? rawResult?.Data ?? null) as T | null
}
// 业务状态码检查
if (result.code !== 200) {
@@ -210,10 +215,10 @@ function createMockResponse<T>(config: RequestConfig): ApiResponse<T> {
const isSecondStudent = config.url.includes('studentId=test-student-002')
data = isSecondStudent ? {
studentId: 'test-student-002', onlineToday: true, checkedInToday: false, learningMinutesToday: 78,
accuracyToday: 88, learningDaysThisWeek: 4, shouldStudyDaysThisWeek: 5, pendingHomeworkCount: 2, updatedAt: '2026-10-28T08:30:00+08:00'
accuracyToday: 88, learningDaysThisWeek: 4, shouldStudyDaysThisWeek: 5, pendingPackCount: 2, updatedAt: '2026-10-28T08:30:00+08:00'
} : {
studentId: 'test-student-001', onlineToday: true, checkedInToday: true, learningMinutesToday: 155,
accuracyToday: 92, learningDaysThisWeek: 5, shouldStudyDaysThisWeek: 5, pendingHomeworkCount: 1, updatedAt: '2026-10-28T08:30:00+08:00'
accuracyToday: 92, learningDaysThisWeek: 5, shouldStudyDaysThisWeek: 5, pendingPackCount: 1, updatedAt: '2026-10-28T08:30:00+08:00'
}
} else if (config.url.includes('/api/parent/today-learning')) {
const isSecondStudent = config.url.includes('studentId=test-student-002')
@@ -385,7 +390,7 @@ export function request<T>(config: RequestConfig): Promise<ApiResponse<T> | null
// 请求拦截
const finalConfig = requestInterceptor(config)
if (USE_MOCK_API && !finalConfig.url.includes('/api/v1/Parents/')) {
if (USE_MOCK_API && !finalConfig.url.includes('/api/v1/Parents/') && !finalConfig.url.includes('/api/v1/ParentStudentToday/') && !finalConfig.url.includes('/api/v1/ParentLearningWarning/') && !finalConfig.url.includes('/api/v1/ParentStudyPack/') && !finalConfig.url.includes('/api/v1/ParentLearningReport/')) {
const delay = Math.floor(Math.random() * (MOCK_RESPONSE_MAX_DELAY + 1))
setTimeout(() => {
if (finalConfig.showLoading !== false) {