Compare commits

..
4 Commits
Author SHA1 Message Date
tanxin ef53fc7c40 修复安卓显示和成长积分接口 2026-11-10 15:32:09 +08:00
hexi ed134f0e99 今日学习 2026-11-10 11:25:04 +08:00
hexi 08711b7628 Merge branch 'main' of https://gitea.23544.com/tanxin/App.k12.Parents 2026-11-06 16:56:49 +08:00
hexi 4a19192d73 首页、学情、任务绑定接口 2026-11-06 16:43:41 +08:00
17 changed files with 1324 additions and 283 deletions
+439 -38
View File
@@ -50,22 +50,37 @@ export type StudentHomeMetrics = {
accuracyToday: number | null accuracyToday: number | null
learningDaysThisWeek: number learningDaysThisWeek: number
shouldStudyDaysThisWeek: number shouldStudyDaysThisWeek: number
pendingHomeworkCount: number pendingPackCount: number
updatedAt: string 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 = { export type TodayLearningCourse = {
id: string id: string
time: string time: string
subject: string subject: string
content: string content: string
stageName: string
checkedIn: boolean checkedIn: boolean
statusText: string
} }
export type TodayLearningScore = { export type TodayLearningScore = {
accuracy: number accuracy: number | null
yesterdayAccuracy: number yesterdayAccuracy: number | null
change: number change: number | null
hasYesterdayData: boolean
} }
export type TodayLearningPoint = { export type TodayLearningPoint = {
@@ -74,6 +89,64 @@ export type TodayLearningPoint = {
points: number points: number
} }
/** 接口原始结构:今日学习概览 */
export type ParentTodayLearningOverview = {
StudyDurationMinute: number
AnswerDurationMinute: number
AnswerQuestionCount: number
CompletedPackCount: number
TotalPackCount: number
OvercomeKnowledgePointCount: number
IsCheckedIn: boolean
CheckInStatusName: string
}
/** 接口原始结构:今日课节与打卡 */
export type ParentTodayCourseItem = {
CourseId: string
BaseSubjectId: string
BaseSubjectName: string
CourseName: string
StudyStage: number
StudyStageName: string
PlanStartTime: string
PlanEndTime: string
TimeText: string
IsCheckedIn: boolean
CheckInStatusName: string
}
/** 接口原始结构:正确率对比 */
export type ParentTodayAccuracyComparison = {
TodayAccuracyRate: number | null
YesterdayAccuracyRate: number | null
AccuracyRateDiff: number | null
HasYesterdayData: boolean
}
/** 接口原始结构:积分明细 */
export type ParentTodayPointItem = {
Type: number
Name: string
Points: number
}
/** 接口原始结构:今日获得积分 */
export type ParentTodayPointResult = {
TotalPoints: number
Items: ParentTodayPointItem[]
}
/** 接口原始结构:今日学习情况 */
export type ParentTodayLearningSituationResult = {
UserId: string
Date: string
Overview: ParentTodayLearningOverview
Courses: ParentTodayCourseItem[]
Accuracy: ParentTodayAccuracyComparison
Points: ParentTodayPointResult
}
export type StudyReportSubject = { export type StudyReportSubject = {
id: string id: string
name: string name: string
@@ -136,15 +209,59 @@ export type StudentStudyReport = {
studentClass: string studentClass: string
updatedAtText: string updatedAtText: string
subjects: StudyReportSubject[] subjects: StudyReportSubject[]
hasWeekReport: boolean
hasMonthReport: boolean
week: StudyReportPeriod week: StudyReportPeriod
month: 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 = { export type TodayLearningDetail = {
studentId: string studentId: string
studentName: string studentName: string
dateText: string dateText: string
checkedIn: boolean checkedIn: boolean
checkInStatusName: string
learningMinutes: number learningMinutes: number
answerMinutes: number answerMinutes: number
answerCount: number answerCount: number
@@ -213,9 +330,12 @@ export type LearningTaskItem = {
title: string title: string
status: 'not_started' | 'in_progress' | 'success' | 'failed' status: 'not_started' | 'in_progress' | 'success' | 'failed'
statusText: string statusText: string
resultText: string
accuracyText: string accuracyText: string
answerText: string answerText: string
attemptText: string
deadlineText: string deadlineText: string
metaText: string
} }
export type LearningTaskGroup = { export type LearningTaskGroup = {
@@ -235,6 +355,7 @@ export type StudentLearningTasks = {
studentName: string studentName: string
studentGrade: string studentGrade: string
studentClass: string studentClass: string
totalCount: number
pendingCount: number pendingCount: number
completedCount: number completedCount: number
failedCount: number failedCount: number
@@ -252,7 +373,8 @@ export type StudentAlert = {
content: string content: string
occurredAt: string occurredAt: string
isRead: boolean isRead: boolean
statusName?: string isTriggered: boolean
statusName: string
} }
export type StudentAlertList = { export type StudentAlertList = {
@@ -274,7 +396,7 @@ export type StudentLearningWarningItem = {
} }
export type StudentLearningWarningResult = { export type StudentLearningWarningResult = {
userId: string userId: number | string
date: string date: string
totalCount: number totalCount: number
triggeredCount: number triggeredCount: number
@@ -369,7 +491,7 @@ export function apiGetBoundStudents(): Promise<ApiResponse<{ students: BoundStud
* 获取指定学生的首页今日学习概览。 * 获取指定学生的首页今日学习概览。
*/ */
export function apiGetStudentHomeMetrics(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentHomeMetrics> | null> { 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 if (!res || !res.data) return null
return { return {
code: res.code, code: res.code,
@@ -378,11 +500,11 @@ export function apiGetStudentHomeMetrics(studentId: string, showLoading: boolean
studentId: String(res.data.userId ?? studentId), studentId: String(res.data.userId ?? studentId),
onlineToday: Boolean(res.data.todayOnline), onlineToday: Boolean(res.data.todayOnline),
checkedInToday: Boolean(res.data.todayCheckIn), checkedInToday: Boolean(res.data.todayCheckIn),
learningMinutesToday: Math.floor((Number(res.data.todayStudyDuration) || 0) / 60), learningMinutesToday: Math.floor(Number(res.data.todayStudyDuration) || 0),
accuracyToday: Number(res.data.todayAccuracyRate) || 0, accuracyToday: res.data.todayAccuracyRate === null || res.data.todayAccuracyRate === undefined ? null : Number(res.data.todayAccuracyRate) || 0,
learningDaysThisWeek: Number(res.data.weekStudyDays) || 0, learningDaysThisWeek: Number(res.data.weekStudyDays) || 0,
shouldStudyDaysThisWeek: Number(res.data.weekShouldStudyDays) || 0, shouldStudyDaysThisWeek: Number(res.data.weekShouldStudyDays) || 0,
pendingHomeworkCount: Number(res.data.pendingPackCount) || 0, pendingPackCount: Number(res.data.pendingPackCount) || 0,
updatedAt: res.data.date || '' updatedAt: res.data.date || ''
} }
} }
@@ -390,26 +512,30 @@ export function apiGetStudentHomeMetrics(studentId: string, showLoading: boolean
} }
/** /**
* 获取指定学生今日学习预警,可选择仅返回已触发的预警规则。 * 获取指定学生今日学习预警。已触发预警优先,其后展示未触发规则,可按数量裁剪。
*/ */
export function apiGetStudentLearningWarnings(studentId: string, showLoading: boolean = true, onlyTriggered: boolean = true): Promise<ApiResponse<StudentAlertList> | null> { export function apiGetStudentLearningWarnings(studentId: string, showLoading: boolean = true, limit?: number, onlyTriggered: boolean = false): Promise<ApiResponse<StudentAlertList> | null> {
return get<any>(`/api/v1/ParentLearningWarning/GetTodayList?userId=${studentId}`, undefined, { showLoading }).then((res) => { return get<StudentLearningWarningResult>(`/api/v1/ParentLearningWarning/GetTodayList?userId=${encodeURIComponent(studentId)}`, undefined, { showLoading }).then((res) => {
if (!res || !res.data) return null if (!res || !res.data || !Array.isArray(res.data.items)) return null
const items = Array.isArray(res.data.items) ? res.data.items : [] const warningData = res.data
const list: StudentAlert[] = items const triggeredItems = warningData.items.filter((item: StudentLearningWarningItem) => Boolean(item.isTriggered))
.filter((item: any) => !onlyTriggered || Boolean(item.isTriggered)) const normalItems = warningData.items.filter((item: StudentLearningWarningItem) => !Boolean(item.isTriggered))
.map((item: any, index: number) => { const orderedItems = triggeredItems.concat(normalItems)
const warningType = learningWarningTypeMap[Number(item.type)] const sourceItems = onlyTriggered ? triggeredItems : orderedItems
return { const visibleItems = limit === undefined ? sourceItems : sourceItems.slice(0, limit)
const list: StudentAlert[] = visibleItems.map((item: StudentLearningWarningItem, index: number) => {
const warningType = learningWarningTypeMap[Number(item.type)]
return {
id: `${studentId}-${item.type ?? index}`, id: `${studentId}-${item.type ?? index}`,
level: warningType?.level ?? 'medium', level: item.isTriggered ? warningType?.level ?? 'high' : 'low',
title: item.title || warningType?.title || '学习预警', title: item.title || warningType?.title || '学习预警',
content: item.description || item.statusName || '', content: item.description || item.statusName || '',
occurredAt: res.data.date || '', occurredAt: warningData.date || '',
isRead: !item.isTriggered, isRead: !item.isTriggered,
isTriggered: Boolean(item.isTriggered),
statusName: item.statusName || (item.isTriggered ? '已触发' : '正常') statusName: item.statusName || (item.isTriggered ? '已触发' : '正常')
} }
}) })
return { return {
code: res.code, code: res.code,
message: res.message, message: res.message,
@@ -418,8 +544,8 @@ export function apiGetStudentLearningWarnings(studentId: string, showLoading: bo
pagination: { pagination: {
page: 1, page: 1,
pageSize: list.length, pageSize: list.length,
total: list.length, total: sourceItems.length,
hasMore: false hasMore: limit !== undefined && sourceItems.length > list.length
} }
} }
} }
@@ -427,17 +553,261 @@ export function apiGetStudentLearningWarnings(studentId: string, showLoading: bo
} }
/** /**
* 获取学生今日学习详情及课程打卡记录。 * 获取学生今日学习情况。
* 接口字段为 PascalCase,这里统一转换为页面使用的驼峰结构。
*/ */
export function apiGetTodayLearningDetail(studentId: string, showLoading: boolean = true): Promise<ApiResponse<TodayLearningDetail> | null> { export function apiGetTodayLearningDetail(studentId: string, showLoading: boolean = true): Promise<ApiResponse<TodayLearningDetail> | null> {
return get<TodayLearningDetail>(`/api/parent/today-learning?studentId=${studentId}`, undefined, { showLoading }) return get<ParentTodayLearningSituationResult>(`/api/v1/ParentLearningSituation/GetToday?userId=${encodeURIComponent(studentId)}`, undefined, { showLoading }).then((res) => {
if (!res || !res.data) return null
const data = res.data
const overview = data.Overview
const accuracy = data.Accuracy
const points = data.Points
const courses: TodayLearningCourse[] = (Array.isArray(data.Courses) ? data.Courses : []).map((course: ParentTodayCourseItem) => {
const checkedIn = Boolean(course.IsCheckedIn)
return {
id: String(course.CourseId ?? ''),
time: course.TimeText || formatTodayLearningTime(course.PlanStartTime),
subject: course.BaseSubjectName || '',
content: course.CourseName || '',
stageName: course.StudyStageName || '',
checkedIn,
statusText: course.CheckInStatusName || (checkedIn ? '已打卡' : '未打卡')
}
})
const pointDetails: TodayLearningPoint[] = (Array.isArray(points?.Items) ? points.Items : []).map((item: ParentTodayPointItem) => ({
id: String(item.Type ?? ''),
label: item.Name || '其他积分',
points: Number(item.Points) || 0
}))
return {
code: res.code,
message: res.message,
data: {
studentId: String(data.UserId ?? studentId),
studentName: '',
dateText: formatTodayLearningDateText(data.Date),
checkedIn: Boolean(overview?.IsCheckedIn),
checkInStatusName: overview?.CheckInStatusName || (overview?.IsCheckedIn ? '已打卡' : '未打卡'),
learningMinutes: Number(overview?.StudyDurationMinute) || 0,
answerMinutes: Number(overview?.AnswerDurationMinute) || 0,
answerCount: Number(overview?.AnswerQuestionCount) || 0,
completedPackages: Number(overview?.CompletedPackCount) || 0,
totalPackages: Number(overview?.TotalPackCount) || 0,
masteredPoints: Number(overview?.OvercomeKnowledgePointCount) || 0,
courses,
score: {
accuracy: toNullableNumber(accuracy?.TodayAccuracyRate),
yesterdayAccuracy: toNullableNumber(accuracy?.YesterdayAccuracyRate),
change: toNullableNumber(accuracy?.AccuracyRateDiff),
hasYesterdayData: Boolean(accuracy?.HasYesterdayData)
},
pointsTotal: Number(points?.TotalPoints) || 0,
pointDetails
}
}
})
}
function toNullableNumber(value: number | null | undefined): number | null {
if (value === null || value === undefined) return null
return Number(value)
}
function formatTodayLearningTime(value: string | null | undefined): string {
if (!value) return '--'
const text = String(value)
return text.length >= 16 ? text.substring(11, 16) : text
}
function formatTodayLearningDateText(value: string | null | undefined): string {
if (!value) return ''
const text = String(value)
if (text.length < 10) return text
const year = Number(text.substring(0, 4))
const month = Number(text.substring(5, 7))
const day = Number(text.substring(8, 10))
return `${month}月${day}日 ${weekdayName(year, month, day)}`
}
function weekdayName(year: number, month: number, day: number): string {
const offsets = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
const adjustedYear = month < 3 ? year - 1 : year
const index = (adjustedYear + Math.floor(adjustedYear / 4) - Math.floor(adjustedYear / 100) + Math.floor(adjustedYear / 400) + offsets[month - 1] + day) % 7
return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][index]
} }
/** /**
* 获取学生学情报告。接口一次返回周报、月报和全部科目趋势数据。 * 获取学生最新一期学情报告。新接口按报告类型分别返回周报、月报,这里转换为页面统一结构。
*/ */
export function apiGetStudentStudyReport(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentStudyReport> | null> { export async function apiGetStudentStudyReport(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentStudyReport> | null> {
return get<StudentStudyReport>(`/api/parent/study-report?studentId=${studentId}`, undefined, { showLoading }) 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> { export function apiGetStudentVocabulary(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentVocabulary> | null> {
@@ -548,7 +918,7 @@ export function apiGetStudentLearningTasks(studentId: string, showLoading: boole
* 获取学生当天学习包任务。Status: 0 全部,1 待完成,2 已完成,3 未通过。 * 获取学生当天学习包任务。Status: 0 全部,1 待完成,2 已完成,3 未通过。
*/ */
export function apiGetParentStudyPacks(studentId: string, status: ParentStudyPackStatus = 0, showLoading: boolean = true): Promise<ApiResponse<StudentLearningTasks> | null> { 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 if (!res || !res.data) return null
const statusMap: { [key: number]: LearningTaskItem['status'] } = { const statusMap: { [key: number]: LearningTaskItem['status'] } = {
0: 'not_started', 0: 'not_started',
@@ -567,14 +937,38 @@ export function apiGetParentStudyPacks(studentId: string, status: ParentStudyPac
const tasks: LearningTaskItem[] = (Array.isArray(course.packs) ? course.packs : []).map((pack: any) => { const tasks: LearningTaskItem[] = (Array.isArray(course.packs) ? course.packs : []).map((pack: any) => {
const taskStatus = statusMap[Number(pack.status)] || 'not_started' const taskStatus = statusMap[Number(pack.status)] || 'not_started'
const accuracy = pack.latestAccuracyRate === null || pack.latestAccuracyRate === undefined ? '--' : `${Number(pack.latestAccuracyRate) || 0}%` 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 { return {
id: String(pack.packId ?? pack.knowledgePointId ?? ''), id: String(pack.packId ?? pack.knowledgePointId ?? ''),
title: pack.name || '未命名学习任务', title: pack.name || '未命名学习任务',
status: taskStatus, status: taskStatus,
statusText: pack.statusName || statusTextMap[taskStatus], statusText: pack.statusName || statusTextMap[taskStatus],
resultText,
accuracyText: accuracy, accuracyText: accuracy,
answerText: pack.completedAttemptCount ? `已完成 ${pack.completedAttemptCount} 次` : '尚未完成', answerText,
deadlineText: pack.deadlineTime || '' attemptText,
deadlineText,
metaText: metaParts.join(' · ')
} }
}) })
return { return {
@@ -598,6 +992,7 @@ export function apiGetParentStudyPacks(studentId: string, status: ParentStudyPac
studentGrade: '', studentGrade: '',
studentClass: '', studentClass: '',
pendingCount: Number(res.data.pendingCount) || 0, 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, completedCount: Number(res.data.completedCount) || 0,
failedCount: Number(res.data.failedCount) || 0, failedCount: Number(res.data.failedCount) || 0,
groups groups
@@ -612,10 +1007,16 @@ function formatTaskTime(value: any): string {
return text.length >= 16 ? text.substring(11, 16) : text 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> { 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 apiGetStudentLearningWarnings(studentId, showLoading, limit)
return get<StudentAlertList>(`/api/parent/alerts?studentId=${studentId}&${suffix}`, undefined, { showLoading })
} }
+43 -32
View File
@@ -16,11 +16,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// #ifdef WEB
import { BrowserQRCodeReader } from '@zxing/browser'
import type { IScannerControls } from '@zxing/browser'
// #endif
type Props = { type Props = {
cooldown?: number cooldown?: number
active?: boolean active?: boolean
@@ -39,10 +34,10 @@
let lastScannedAt = 0 let lastScannedAt = 0
// #ifdef WEB // #ifdef WEB
const videoContainerId = `qr-scanner-${Date.now()}` const videoContainerId = `qr-scanner-${Date.now()}`
let scannerControls: IScannerControls | null = null
let scanVideo: HTMLVideoElement | null = null let scanVideo: HTMLVideoElement | null = null
let scanStream: MediaStream | null = null
let isPageActive = false let isPageActive = false
let restartTimer: number | null = null let scanTimer: number | null = null
// #endif // #endif
function emitScan(result: string) { function emitScan(result: string) {
@@ -77,7 +72,7 @@
} }
async function startWebScanner() { async function startWebScanner() {
if (!isPageActive || !props.active || scannerControls != null) return if (!isPageActive || !props.active || scanStream != null) return
const video = getScanVideo() const video = getScanVideo()
if (video == null) { if (video == null) {
handleCameraError() handleCameraError()
@@ -85,39 +80,55 @@
} }
try { try {
const reader = new BrowserQRCodeReader() const BarcodeDetectorClass = (window as any).BarcodeDetector
scannerControls = await reader.decodeFromConstraints( if (BarcodeDetectorClass == null || navigator.mediaDevices == null) {
{ video: { facingMode: 'environment' } }, handleCameraError()
video, return
(result, _error, controls) => { }
if (!result) return
controls.stop() scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
scannerControls = null video.srcObject = scanStream
emitScan(result.getText()) await video.play()
scheduleRestart() const detector = new BarcodeDetectorClass({ formats: ['qr_code'] })
} void scanWebFrame(detector)
)
} catch (_error) { } catch (_error) {
stopWebScanner()
handleCameraError() handleCameraError()
} }
} }
function scheduleRestart() { async function scanWebFrame(detector: any) {
if (!isPageActive || !props.active || restartTimer != null) return if (!isPageActive || !props.active || scanVideo == null || scanStream == null) return
restartTimer = setTimeout(() => {
restartTimer = null try {
void startWebScanner() const results = await detector.detect(scanVideo)
}, props.cooldown) if (results.length > 0) {
emitScan(results[0].rawValue as string)
}
} catch (_error) {
handleCameraError()
return
}
scanTimer = setTimeout(() => {
scanTimer = null
void scanWebFrame(detector)
}, 250)
} }
function stopWebScanner() { function stopWebScanner() {
if (restartTimer != null) { if (scanTimer != null) {
clearTimeout(restartTimer) clearTimeout(scanTimer)
restartTimer = null scanTimer = null
} }
if (scannerControls != null) { if (scanStream != null) {
scannerControls.stop() scanStream.getTracks().forEach((track) => {
scannerControls = null track.stop()
})
scanStream = null
}
if (scanVideo != null) {
scanVideo.srcObject = null
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
+1 -48
View File
@@ -7,54 +7,7 @@
"": { "": {
"name": "app.k12.parents", "name": "app.k12.parents",
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "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"
}
} }
} }
} }
+1 -3
View File
@@ -11,7 +11,5 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {}
"@zxing/browser": "^0.2.1"
}
} }
+37 -18
View File
@@ -8,59 +8,70 @@
"style": { "style": {
"navigationBarTitleText": "登录", "navigationBarTitleText": "登录",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"hideStatusBar": true
} }
}, },
{ {
"path": "pages/index/index", "path": "pages/index/index",
"style": { "style": {
"navigationBarTitleText": "首页" "navigationBarTitleText": "首页",
"navigationBarBackgroundColor": "#5d3de8",
"navigationBarTextStyle": "white",
"hideStatusBar": true
} }
}, },
{ {
"path": "pages/study/index", "path": "pages/study/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom",
"hideStatusBar": true
} }
}, },
{ {
"path": "pages/vocabulary/index", "path": "pages/vocabulary/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom",
"hideStatusBar": true
} }
}, },
{ {
"path": "pages/growth/index", "path": "pages/growth/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom",
"hideStatusBar": true
} }
}, },
{ {
"path": "pages/tasks/index", "path": "pages/tasks/index",
"style": { "style": {
"navigationBarTitleText": "任务" "navigationBarTitleText": "任务",
"hideStatusBar": true
} }
}, },
{ {
"path": "pages/profile/index", "path": "pages/profile/index",
"style": { "style": {
"navigationBarTitleText": "我的" "navigationBarTitleText": "我的",
"hideStatusBar": true
} }
}, },
{ {
"path": "pages/profile/verify-student", "path": "pages/profile/verify-student",
"style": { "navigationBarTitleText": "验证学生" } "style": { "navigationBarTitleText": "验证学生", "hideStatusBar": true }
}, },
{ {
"path": "pages/alerts/index", "path": "pages/alerts/index",
"style": { "style": {
"navigationBarTitleText": "预警信息" "navigationBarTitleText": "预警信息",
"hideStatusBar": true
} }
}, },
{ {
"path": "pages/today-learning/index", "path": "pages/today-learning/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom",
"hideStatusBar": true
} }
}, },
{ {
@@ -68,7 +79,8 @@
"style": { "style": {
"navigationBarTitleText": "验证码登录", "navigationBarTitleText": "验证码登录",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"hideStatusBar": true
} }
}, },
{ {
@@ -76,7 +88,8 @@
"style": { "style": {
"navigationBarTitleText": "忘记密码", "navigationBarTitleText": "忘记密码",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"hideStatusBar": true
} }
}, },
{ {
@@ -84,7 +97,8 @@
"style": { "style": {
"navigationBarTitleText": "家长注册", "navigationBarTitleText": "家长注册",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"hideStatusBar": true
} }
}, },
{ {
@@ -92,7 +106,8 @@
"style": { "style": {
"navigationBarTitleText": "家长注册", "navigationBarTitleText": "家长注册",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"hideStatusBar": true
} }
}, },
{ {
@@ -100,7 +115,8 @@
"style": { "style": {
"navigationBarTitleText": "家长注册", "navigationBarTitleText": "家长注册",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"hideStatusBar": true
} }
}, },
{ {
@@ -108,7 +124,8 @@
"style": { "style": {
"navigationBarTitleText": "家长注册", "navigationBarTitleText": "家长注册",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"hideStatusBar": true
} }
}, },
{ {
@@ -116,7 +133,8 @@
"style": { "style": {
"navigationBarTitleText": "注册完成", "navigationBarTitleText": "注册完成",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"hideStatusBar": true
} }
} }
], ],
@@ -124,7 +142,8 @@
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"navigationBarTitleText": "家长学情", "navigationBarTitleText": "家长学情",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F5F5F5" "backgroundColor": "#F5F5F5",
"hideStatusBar": true
}, },
"uniIdRouter": {} "uniIdRouter": {}
} }
+153 -28
View File
@@ -1,70 +1,195 @@
<template> <template>
<view class="page-container"> <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"> <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-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-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 v-else class="content-body">
<view class="alert-card-top"><view class="alert-level-dot" :class="levelDotClass(alert.level)"></view><text class="alert-title">{{ alert.title }}</text><text class="alert-status" :class="alert.isRead ? 'alert-status-normal' : 'alert-status-triggered'">{{ alert.statusName }}</text></view> <view class="summary-card">
<text class="alert-content">{{ alert.content }}</text> <text class="summary-title">今日触发预警</text>
<text class="alert-time">{{ formatTime(alert.occurredAt) }}</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> </view>
</scroll-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> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { apiGetStudentLearningWarnings, type StudentAlert, type StudentAlertLevel } from '../../api/student' import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { homeStudentProfile } from '../../store/home' import { apiGetBoundStudents, apiGetStudentLearningWarnings, type BoundStudentSummary, type StudentAlert } from '../../api/student'
import { homeStudentProfile, selectedStudentChangeVersion, switchBoundStudent } from '../../store/home'
const alerts = ref<StudentAlert[]>([]) const alerts = ref<StudentAlert[]>([])
const isLoading = ref(true) 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 studentName = computed((): string => homeStudentProfile.value?.name ?? '孩子')
const triggeredCount = computed((): number => alerts.value.filter((item: StudentAlert) => item.isTriggered).length)
onLoad(() => { onLoad(() => {
loadAlerts() loadAlerts()
}) })
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadAlerts()
}
})
async function loadAlerts() { async function loadAlerts() {
isLoading.value = true
const studentId = homeStudentProfile.value?.id ?? '' const studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) { if (!studentId) {
isLoading.value = false isLoading.value = false
return return
} }
const res = await apiGetStudentLearningWarnings(studentId, true, false) const res = await apiGetStudentLearningWarnings(studentId)
if (res && res.data) { if (res && res.data) {
alerts.value = res.data.list alerts.value = res.data.list
} }
isLoading.value = false isLoading.value = false
} }
function levelDotClass(level: StudentAlertLevel): string { function goBack() {
return level === 'low' ? 'alert-level-low' : level === 'medium' ? 'alert-level-medium' : 'alert-level-high' uni.navigateBack()
} }
function formatTime(value: string): string { async function openStudentPicker() {
return value.length > 0 ? value.substring(5, 16).replace('T', ' ') : '' 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> </script>
<style> <style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; } .page-container { display: flex; flex-direction: column; flex: 1; background-color: #f6f7fb; }
.header-panel { display: flex; flex-direction: column; padding: 18px 20px; background-color: #5d3de8; } .header-panel { display: flex; flex-direction: row; align-items: center; padding: 6px 4px 8px; background-color: #ffffff; }
.header-title { font-size: 21px; font-weight: 700; 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; }
.header-subtitle { margin-top: 6px; font-size: 13px; color: #e5ddff; } .back-icon { font-size: 30px; line-height: 32px; color: #415066; }
.content-scroll { flex: 1; padding: 16px 20px; } .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-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 160px; }
.status-text { font-size: 14px; color: #8992a5; } .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-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-dot { width: 7px; height: 7px; margin-right: 11px; border-radius: 4px; }
.alert-level-high { background-color: #e25a5a; } .alert-level-triggered { background-color: #ff3f6b; }
.alert-level-medium { background-color: #e8a129; } .alert-level-normal { background-color: #d2dce8; }
.alert-level-low { background-color: #4d87cf; } .alert-title { flex: 1; font-size: 14px; font-weight: 700; color: #1f2a3d; }
.alert-title { flex: 1; font-size: 15px; font-weight: 700; color: #30384a; } .alert-status { padding: 4px 8px; border-radius: 12px; font-size: 10px; }
.alert-status { padding: 3px 7px; border-radius: 8px; font-size: 10px; } .alert-status-triggered { color: #ff3f6b; background-color: #fff0f4; }
.alert-status-triggered { color: #e25a5a; background-color: #fff0f1; } .alert-status-normal { color: #00a86b; background-color: #eafaf3; }
.alert-status-normal { color: #16a979; background-color: #e8fbf3; } .alert-content { margin-top: 10px; margin-left: 18px; font-size: 12px; line-height: 19px; color: #536278; }
.alert-content { margin-top: 10px; font-size: 13px; line-height: 20px; color: #626c7e; } .student-picker-panel { display: flex; flex-direction: column; background-color: #ffffff; }
.alert-time { margin-top: 10px; font-size: 11px; color: #9aa2b1; } .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> </style>
+23 -6
View File
@@ -7,11 +7,12 @@
<script setup lang="ts"> <script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue' import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentGrowth, apiGetStudentHomeProfile, apiGetStudentPointRecords, type StudentGrowth } from '../../api/student' import { apiGetBoundStudents, apiGetStudentGrowth, apiGetStudentPointRecords, type StudentGrowth } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home' import { applyBoundStudent, getSavedSelectedStudent, homeStudentProfile, saveSelectedStudent, selectedStudentChangeVersion } from '../../store/home'
const growth = ref<StudentGrowth>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pointsBalance: 0, level: 0, rank: 0, streakDays: 0, totalPoints: 0, records: [] }) const growth = ref<StudentGrowth>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pointsBalance: 0, level: 0, rank: 0, streakDays: 0, totalPoints: 0, records: [] })
const isLoading = ref(true) const isLoading = ref(true)
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const isRefreshing = ref(false) const isRefreshing = ref(false)
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
const hasMoreRecords = ref(true) const hasMoreRecords = ref(true)
@@ -21,13 +22,29 @@
onLoad(() => { loadGrowth() }) onLoad(() => { loadGrowth() })
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadGrowth()
}
})
async function loadGrowth() { async function loadGrowth() {
isLoading.value = true
let studentId = homeStudentProfile.value?.id ?? '' let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) { if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false) const savedStudent = getSavedSelectedStudent()
if (profileRes && profileRes.data) { if (savedStudent) {
setHomeStudentProfile(profileRes.data) applyBoundStudent(savedStudent)
studentId = profileRes.data.id studentId = savedStudent.id
} else {
const res = await apiGetBoundStudents()
const firstStudent = res?.data?.students[0]
if (firstStudent) {
saveSelectedStudent(firstStudent)
applyBoundStudent(firstStudent)
studentId = firstStudent.id
}
} }
} }
if (studentId) { if (studentId) {
+61 -17
View File
@@ -61,8 +61,8 @@
<text class="metric-value metric-blue">{{ metrics.learningDaysThisWeek }}/{{ metrics.shouldStudyDaysThisWeek }} 天</text> <text class="metric-value metric-blue">{{ metrics.learningDaysThisWeek }}/{{ metrics.shouldStudyDaysThisWeek }} 天</text>
</view> </view>
<view class="metric-item"> <view class="metric-item">
<text class="metric-label">待完成作业</text> <text class="metric-label">待完成学习包</text>
<text class="metric-value metric-orange">{{ metrics.pendingHomeworkCount }} 个</text> <text class="metric-value metric-orange">{{ metrics.pendingPackCount }} 个</text>
</view> </view>
</view> </view>
</view> </view>
@@ -100,10 +100,10 @@
<scroll-view class="student-picker-list" direction="vertical" scroll-y="true"> <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-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-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 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"><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-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> <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>
<view v-if="selectedStudentId === item.id" class="student-picker-check"><text class="student-picker-check-text">已选</text></view> <text class="student-picker-action" :class="selectedStudentId === item.id ? 'student-picker-action-selected' : ''">{{ selectedStudentId === item.id ? '当前查看' : '切换' }}</text>
</view> </view>
</scroll-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 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 MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.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 { 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' import type { UserInfo } from '../../api/auth'
const loadingStudent = ref<StudentHomeProfile>({ const loadingStudent = ref<StudentHomeProfile>({
@@ -134,10 +134,11 @@
const isStudentsLoading = ref(false) const isStudentsLoading = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([]) const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('') const selectedStudentId = ref('')
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const student = computed((): StudentHomeProfile => homeStudentProfile.value ?? loadingStudent.value) const student = computed((): StudentHomeProfile => homeStudentProfile.value ?? loadingStudent.value)
const metrics = computed((): StudentHomeMetrics => homeStudentMetrics.value ?? { const metrics = computed((): StudentHomeMetrics => homeStudentMetrics.value ?? {
studentId: '', onlineToday: false, checkedInToday: false, learningMinutesToday: 0, 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 alerts = computed((): StudentAlert[] => homeStudentAlerts.value ?? [])
const parentName = computed((): string => { const parentName = computed((): string => {
@@ -174,6 +175,24 @@
loadHomeData() 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) { async function loadStudentHomeMetrics(studentId: string, showLoading: boolean = true) {
const res = await apiGetStudentHomeMetrics(studentId, showLoading) const res = await apiGetStudentHomeMetrics(studentId, showLoading)
if (res && res.data) { if (res && res.data) {
@@ -182,7 +201,7 @@
} }
async function loadStudentAlerts(studentId: string, showLoading: boolean = true) { 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) { if (res && res.data) {
setHomeStudentAlerts(res.data.list) setHomeStudentAlerts(res.data.list)
} }
@@ -227,7 +246,6 @@
async function openStudentPicker() { async function openStudentPicker() {
showStudentPicker.value = true showStudentPicker.value = true
selectedStudentId.value = student.value.id selectedStudentId.value = student.value.id
if (boundStudents.value.length > 0) return
isStudentsLoading.value = true isStudentsLoading.value = true
const res = await apiGetBoundStudents() const res = await apiGetBoundStudents()
if (res && res.data) { if (res && res.data) {
@@ -243,6 +261,26 @@
selectedStudentId.value = studentId 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 { function alertLevelClass(level: StudentAlertLevel): string {
return level === 'low' ? 'reminder-blue' : level === 'medium' ? 'reminder-yellow' : 'reminder-red' return level === 'low' ? 'reminder-blue' : level === 'medium' ? 'reminder-yellow' : 'reminder-red'
} }
@@ -279,11 +317,9 @@
closeStudentPicker() closeStudentPicker()
const selectedStudent = boundStudents.value.find((item) => item.id === selectedStudentId.value) const selectedStudent = boundStudents.value.find((item) => item.id === selectedStudentId.value)
if (selectedStudent) { if (selectedStudent) {
saveSelectedStudent(selectedStudent) switchBoundStudent(selectedStudent)
applyBoundStudent(selectedStudent) observedStudentVersion.value = selectedStudentChangeVersion.value
} await loadHomeData(selectedStudent.id)
if (selectedStudentId.value !== student.value.id) {
await loadHomeData(selectedStudentId.value)
} }
} }
</script> </script>
@@ -302,7 +338,8 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
padding: 15px 20px 17px; padding: 15px 20px 17px;
background: linear-gradient(135deg, #5d3de8 0%, #8236e9 100%); background-color: #5d3de8;
background-image: linear-gradient(to bottom right, #5d3de8, #8236e9);
border-radius: 0 0 0 0; border-radius: 0 0 0 0;
} }
@@ -412,18 +449,25 @@
.student-picker-list { max-height: 340px; padding: 0 20px; } .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 { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; }
.student-picker-empty-text { font-size: 13px; color: #8992a5; } .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-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 { 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-image { width: 46px; height: 46px; border-radius: 14px; }
.student-picker-avatar-text { font-size: 18px; font-weight: 700; color: #ffffff; } .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-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-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-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-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-detail { margin-top: 2px; font-size: 11px; color: #7e8799; }
.student-picker-check { padding: 4px 7px; border-radius: 9px; background-color: #554be9; } .student-picker-check { padding: 4px 7px; border-radius: 9px; background-color: #554be9; }
.student-picker-check-text { font-size: 10px; color: #ffffff; } .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-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, .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-cancel { margin-right: 10px; background-color: #f0f2f7; }
+2 -3
View File
@@ -68,7 +68,7 @@
import { apiLogin } from '../../api/auth' import { apiLogin } from '../../api/auth'
import { apiGetBoundStudents, type BoundStudentSummary } from '../../api/student' import { apiGetBoundStudents, type BoundStudentSummary } from '../../api/student'
import { setUserInfo, saveRememberInfo, getRememberInfo } from '../../store/user' import { setUserInfo, saveRememberInfo, getRememberInfo } from '../../store/user'
import { applyBoundStudent, saveSelectedStudent } from '../../store/home' import { switchBoundStudent } from '../../store/home'
type LoginForm = { type LoginForm = {
phone: string phone: string
@@ -144,8 +144,7 @@
? res.data.students.find((item) => item.id === savedStudent.id)! ? res.data.students.find((item) => item.id === savedStudent.id)!
: res.data.students[0] : res.data.students[0]
saveSelectedStudent(selectedStudent) switchBoundStudent(selectedStudent)
applyBoundStudent(selectedStudent)
} }
/** /**
+11 -3
View File
@@ -62,7 +62,7 @@
<script setup lang="ts"> <script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue' import MainTabBar from '../../components/main-tab-bar.uvue'
import { apiGetBoundStudents, type BoundStudentSummary } from '../../api/student' 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 type { UserInfo } from '../../api/auth'
import { clearUserInfo } from '../../store/user' import { clearUserInfo } from '../../store/user'
@@ -72,9 +72,17 @@
const selectedStudentId = ref(homeStudentProfile.value?.id ?? '') const selectedStudentId = ref(homeStudentProfile.value?.id ?? '')
const isLoading = ref(true) const isLoading = ref(true)
const listFailed = ref(false) const listFailed = ref(false)
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
onLoad(() => { loadStudents() }) onLoad(() => { loadStudents() })
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
selectedStudentId.value = homeStudentProfile.value?.id ?? selectedStudentId.value
}
})
async function loadStudents() { async function loadStudents() {
isLoading.value = true isLoading.value = true
listFailed.value = false listFailed.value = false
@@ -92,8 +100,8 @@
selectedStudentId.value = studentId selectedStudentId.value = studentId
const student = students.value.find((item) => item.id === studentId) const student = students.value.find((item) => item.id === studentId)
if (student) { if (student) {
saveSelectedStudent(student) switchBoundStudent(student)
applyBoundStudent(student) observedStudentVersion.value = selectedStudentChangeVersion.value
} }
} }
+124 -10
View File
@@ -2,7 +2,7 @@
<view class="page-container"> <view class="page-container">
<view class="header-panel"> <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-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>
<view class="report-meta"><text class="report-meta-text">{{ activeReport.rangeText }} · 数据更新到 {{ report.updatedAtText }}</text></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> <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>
<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> </view>
</scroll-view> </scroll-view>
<MainTabBar active-tab="study"></MainTabBar> <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> </view>
</template> </template>
@@ -32,18 +54,24 @@
import StudyActivityChart from '../../components/study-activity-chart.uvue' import StudyActivityChart from '../../components/study-activity-chart.uvue'
import StudyTrendChart from '../../components/study-trend-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 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 { apiGetBoundStudents, apiGetStudentHomeProfile, apiGetStudentStudyReport, type BoundStudentSummary, type StudentStudyReport, type StudyReportActivity, type StudyReportPeriod, type StudyReportSubjectMastery, type StudyReportTrendPoint } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home' import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile, switchBoundStudent } from '../../store/home'
const emptyActivity: StudyReportActivity = { summary: '', activeDays: 0, totalDurationText: '', points: [] } 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 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 isLoading = ref(true)
const isRefreshing = ref(false) const isRefreshing = ref(false)
const isStudentsLoading = ref(false)
const showStudentPicker = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('')
const activePeriod = ref('week') 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 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[] => { const activeTrend = computed((): StudyReportTrendPoint[] => {
for (let index = 0; index < activeReport.value.trends.length; index++) { for (let index = 0; index < activeReport.value.trends.length; index++) {
const trend = activeReport.value.trends[index] const trend = activeReport.value.trends[index]
@@ -56,7 +84,16 @@
loadReport() loadReport()
}) })
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadReport()
}
})
async function loadReport() { async function loadReport() {
isLoading.value = true
activeSubjectId.value = '0'
let studentId = homeStudentProfile.value?.id ?? '' let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) { if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false) const profileRes = await apiGetStudentHomeProfile(undefined, false)
@@ -79,12 +116,57 @@
function setPeriod(period: string) { function setPeriod(period: string) {
activePeriod.value = period activePeriod.value = period
activeSubjectId.value = '0'
} }
function setSubject(subjectId: string) { function setSubject(subjectId: string) {
activeSubjectId.value = subjectId 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> </script>
<style> <style>
@@ -104,6 +186,10 @@
.status-text { font-size: 13px; color: #8992a5; } .status-text { font-size: 13px; color: #8992a5; }
.content-panel { display: flex; flex-direction: column; padding: 12px 18px 24px; } .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; } .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-tabs { display: flex; flex-direction: row; }
.period-tab { padding: 7px 14px; margin-right: 8px; border-radius: 16px; background-color: #f1f4f8; } .period-tab { padding: 7px 14px; margin-right: 8px; border-radius: 16px; background-color: #f1f4f8; }
.period-tab-active { background-color: #554be9; } .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-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-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; } .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> </style>
+222 -52
View File
@@ -1,100 +1,270 @@
<template> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue' import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.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 { apiGetBoundStudents, apiGetParentStudyPacks, apiGetStudentHomeProfile, type BoundStudentSummary, type LearningTaskItem, type ParentStudyPackStatus, type StudentLearningTasks } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home' import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile, switchBoundStudent } from '../../store/home'
const filters = [{ id: 'all', name: '全部' }, { id: 'pending', name: '待完成' }, { id: 'completed', name: '已完成' }, { id: 'failed', name: '未通过' }] const filters = [{ id: 'all', name: '全部' }, { id: 'pending', name: '待完成' }, { id: 'completed', name: '已完成' }, { id: 'failed', name: '未通过' }]
const activeFilter = ref('all') const activeFilter = ref('all')
const activeStatus = computed((): ParentStudyPackStatus => activeFilter.value === 'pending' ? 1 : activeFilter.value === 'completed' ? 2 : activeFilter.value === 'failed' ? 3 : 0) const activeStatus = computed((): ParentStudyPackStatus => activeFilter.value === 'pending' ? 1 : activeFilter.value === 'completed' ? 2 : activeFilter.value === 'failed' ? 3 : 0)
const isLoading = ref(true) const isLoading = ref(true)
const isRefreshing = ref(false) 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 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 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)
onLoad(() => { loadTasks() }) 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 ?? '' let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) { if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false) 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) { if (studentId) {
const res = await apiGetParentStudyPacks(studentId, activeStatus.value) const res = await apiGetParentStudyPacks(studentId, activeStatus.value, showLoading)
if (res && res.data) { if (res && res.data) tasks.value = res.data
tasks.value = {
...res.data,
studentName: `${homeStudentProfile.value?.name ?? res.data.studentName} · ${homeStudentProfile.value?.school ?? ''} ${homeStudentProfile.value?.className ?? res.data.studentClass}`
}
}
} }
isLoading.value = false isLoading.value = false
} }
async function refreshTasks() { async function refreshTasks() {
isRefreshing.value = true isRefreshing.value = true
await loadTasks() await loadTasks(false)
isRefreshing.value = false isRefreshing.value = false
} }
async function setFilter(filter: string) { async function setFilter(filter: string) {
if (activeFilter.value === filter) return
activeFilter.value = filter activeFilter.value = filter
await loadTasks() await loadTasks(true)
} }
function matchesFilter(task: LearningTaskItem): boolean { function matchesFilter(task: LearningTaskItem): boolean {
if (activeFilter.value === 'all') return true if (activeFilter.value === 'all') return true
if (activeFilter.value === 'pending') return task.status === 'not_started' || task.status === 'in_progress' if (activeFilter.value === 'pending') return task.status === 'not_started' || task.status === 'in_progress'
if (activeFilter.value === 'completed') return task.status === 'success' if (activeFilter.value === 'completed') return task.status === 'success'
return task.status === 'failed' 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> </script>
<style> <style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; } .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-copy { display: flex; flex-direction: column; }
.header-title { font-size: 18px; font-weight: 700; color: #242b3d; } .header-title { font-size: 17px; font-weight: 800; color: #101827; }
.header-subtitle { margin-top: 4px; font-size: 11px; color: #8992a5; } .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: #f0f1ff; } .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: #554be9; } .header-action-icon { font-size: 18px; color: #5947ea; }
.filter-panel { display: flex; flex-direction: row; padding: 12px 20px; background-color: #ffffff; } .filter-panel { display: flex; flex-direction: row; padding: 10px 8px 12px; 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 { 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: #554be9; } .filter-tab-active { background-color: #5a45e8; }
.filter-text { font-size: 11px; color: #64748b; } .filter-text { font-size: 11px; color: #4e5b70; }
.filter-text-active { color: #ffffff; } .filter-text-active { color: #ffffff; }
.content-scroll { flex: 1; } .content-scroll { flex: 1; }
.content-panel { display: flex; flex-direction: column; padding: 14px 16px 24px; } .content-panel { display: flex; flex-direction: column; padding: 14px 8px 24px; }
.status-panel, .empty-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; } .status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; }
.status-text, .empty-text { font-size: 13px; color: #8992a5; } .empty-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; }
.group-card { display: flex; flex-direction: column; margin-bottom: 12px; padding: 16px; border-radius: 15px; background-color: #ffffff; } .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-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; } .group-title-row { display: flex; flex-direction: row; align-items: center; flex: 1; min-width: 0; }
.subject-tag, .unit-tag { padding: 3px 7px; margin-right: 6px; border-radius: 8px; font-size: 10px; color: #554be9; background-color: #eeefff; } .subject-tag { padding: 3px 7px; margin-right: 6px; border-radius: 9px; font-size: 10px; font-weight: 700; color: #5947ea; background-color: #eeefff; }
.unit-tag { color: #1598dc; background-color: #e6f5ff; } .unit-tag { padding: 3px 7px; margin-right: 8px; border-radius: 9px; font-size: 10px; font-weight: 700; color: #2f6bea; background-color: #edf5ff; }
.lesson-title { font-size: 13px; font-weight: 700; color: #242b3d; } .lesson-title { flex: 1; font-size: 14px; font-weight: 800; color: #1f2937; }
.knowledge-count { flex-shrink: 0; font-size: 10px; color: #8992a5; } .knowledge-count { flex-shrink: 0; font-size: 11px; color: #8a99ae; }
.lesson-time { margin-top: 7px; font-size: 11px; color: #8ca0bd; } .lesson-time-row { display: flex; flex-direction: row; align-items: center; margin-top: 8px; }
.group-note { display: flex; flex-direction: row; align-items: flex-start; margin-top: 12px; padding: 12px; border-radius: 12px; background-color: #f6f8fb; } .time-dot { width: 8px; height: 8px; margin-right: 6px; border-radius: 4px; background-color: #9caec4; }
.group-note-icon { width: 12px; height: 12px; margin-right: 7px; border-radius: 6px; text-align: center; font-size: 9px; color: #ffffff; background-color: #9aa8bc; } .lesson-time { font-size: 11px; color: #71849d; }
.group-note-text { flex: 1; font-size: 11px; line-height: 18px; color: #68778b; } .task-empty { display: flex; flex-direction: column; align-items: center; padding: 18px 0; }
.detail-task { display: flex; flex-direction: column; margin-top: 10px; padding: 12px; border: 1px solid #edf0f5; border-radius: 12px; background-color: #f9fbfd; } .task-empty-text { font-size: 12px; color: #8992a5; }
.detail-task-heading { display: flex; flex-direction: row; justify-content: space-between; } .task-card { display: flex; flex-direction: column; margin-top: 13px; padding: 12px; border-radius: 12px; background-color: #f8fafc; border: 1px solid #edf1f6; }
.detail-task-title { font-size: 13px; color: #242b3d; } .task-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.detail-task-status { padding: 3px 7px; border-radius: 8px; font-size: 10px; } .task-title { flex: 1; font-size: 14px; font-weight: 700; color: #1f2937; }
.detail-task-status-success { color: #16a979; background-color: #e8fbf3; } .task-status { flex-shrink: 0; padding: 4px 8px; border-radius: 11px; font-size: 10px; }
.detail-task-status-in_progress { color: #5147e9; background-color: #eeefff; } .task-status-not_started { color: #516175; background-color: #edf2f7; }
.detail-task-status-failed { color: #f34d54; background-color: #fff0f1; } .task-status-in_progress { color: #5947ea; background-color: #eeefff; }
.detail-task-result { margin-top: 8px; font-size: 11px; } .task-status-success { color: #04a86b; background-color: #e8fbf2; }
.detail-task-result-success { color: #16a979; } .task-status-failed { color: #ef4d62; background-color: #fff0f3; }
.detail-task-result-in_progress { color: #5147e9; } .task-result-row { display: flex; flex-direction: row; align-items: center; margin-top: 9px; }
.detail-task-result-failed { color: #f34d54; } .task-result-dot { width: 9px; height: 9px; margin-right: 7px; border-radius: 5px; }
.detail-task-meta { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e4eaf1; font-size: 10px; color: #8992a5; } .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> </style>
+174 -18
View File
@@ -2,15 +2,15 @@
<view class="page-container"> <view class="page-container">
<view class="header-panel"> <view class="header-panel">
<view class="header-back" @tap="goBack"><UniIcons type="back" class="header-back-icon"></UniIcons></view> <view class="header-back" @tap="goBack"><UniIcons type="back" class="header-back-icon"></UniIcons></view>
<view class="header-copy"><text class="header-title">今日学习</text><text class="header-subtitle">{{ detail.dateText }} {{ homeStudentProfile?.name ?? detail.studentName }} · {{ homeStudentProfile?.school ?? '' }} {{ homeStudentProfile?.className ?? '' }}</text></view> <view class="header-copy"><text class="header-title">今日学习</text><text class="header-subtitle">{{ headerSubtitle }}</text></view>
<view class="header-action" @tap="goBack"><UniIcons type="home-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>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true"> <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-if="isLoading" class="status-panel"><text class="status-text">今日学习信息加载中</text></view>
<view v-else class="content-panel"> <view v-else class="content-panel">
<view class="overview-card"> <view class="overview-card">
<view class="overview-header"><text class="section-title">今日学习概览</text><text class="checkin-status" :class="detail.checkedIn ? 'checkin-complete' : 'checkin-pending'">{{ detail.checkedIn ? '已打卡' : '未打卡' }}</text></view> <view class="overview-header"><text class="section-title">今日学习概览</text><text class="checkin-status" :class="detail.checkedIn ? 'checkin-complete' : 'checkin-pending'">{{ detail.checkInStatusName }}</text></view>
<view class="metrics-grid"> <view class="metrics-grid">
<view class="metric-card"><text class="metric-label">学习时长</text><text class="metric-value">{{ detail.learningMinutes }} 分钟</text></view> <view class="metric-card"><text class="metric-label">学习时长</text><text class="metric-value">{{ detail.learningMinutes }} 分钟</text></view>
<view class="metric-card"><text class="metric-label">答题时长</text><text class="metric-value">{{ detail.answerMinutes }} 分钟</text></view> <view class="metric-card"><text class="metric-label">答题时长</text><text class="metric-value">{{ detail.answerMinutes }} 分钟</text></view>
@@ -22,11 +22,18 @@
<view class="courses-card"> <view class="courses-card">
<text class="section-title">今日课表与每课打卡</text> <text class="section-title">今日课表与每课打卡</text>
<view class="course-list"> <view v-if="detail.courses.length === 0" class="course-empty"><text class="course-empty-text">今日暂无课程</text></view>
<view v-else class="course-list">
<view v-for="course in detail.courses" :key="course.id" class="course-item"> <view v-for="course in detail.courses" :key="course.id" class="course-item">
<text class="course-time">{{ course.time }}</text> <text class="course-time">{{ course.time }}</text>
<view class="course-copy"><text class="course-subject">{{ course.subject }}</text><text class="course-content">{{ course.content }}</text></view> <view class="course-copy">
<text class="course-status" :class="course.checkedIn ? 'course-complete' : 'course-pending'">{{ course.checkedIn ? '已打卡' : '未打卡' }}</text> <view class="course-title-row">
<text class="course-subject">{{ course.subject }}</text>
<text v-if="course.stageName" class="course-stage">{{ course.stageName }}</text>
</view>
<text class="course-content">{{ course.content }}</text>
</view>
<text class="course-status" :class="course.checkedIn ? 'course-complete' : 'course-pending'">{{ course.statusText }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -34,57 +41,170 @@
<view class="score-card"> <view class="score-card">
<text class="section-title">正确率(对比昨日)</text> <text class="section-title">正确率(对比昨日)</text>
<text class="score-description">整体正确率较昨日全部答题目统计,与昨日对比</text> <text class="score-description">整体正确率较昨日全部答题目统计,与昨日对比</text>
<view class="score-row"><text class="score-label">整体正确率</text><view class="score-values"><text class="score-value">{{ detail.score.accuracy }}%</text><text class="score-change">+{{ detail.score.change }}%</text></view></view> <view class="score-row">
<text class="score-yesterday">昨日 {{ detail.score.yesterdayAccuracy }}%</text> <text class="score-label">整体正确率</text>
<view class="score-track"><view class="score-progress" :style="{ width: detail.score.accuracy + '%' }"></view></view> <view class="score-values">
<text class="score-value">{{ formatAccuracy(detail.score.accuracy) }}</text>
<text v-if="detail.score.hasYesterdayData && detail.score.change !== null" class="score-change" :class="accuracyDiffClass(detail.score.change)">{{ formatAccuracyDiff(detail.score.change) }}</text>
</view>
</view>
<text class="score-yesterday">昨日 {{ formatAccuracy(detail.score.yesterdayAccuracy) }}</text>
<view class="score-track"><view class="score-progress" :style="{ width: (detail.score.accuracy ?? 0) + '%' }"></view></view>
</view> </view>
<view class="points-card"> <view class="points-card">
<view class="points-header"><text class="section-title">今日获得积分</text><text class="points-link">积分明细 ›</text></view> <view class="points-header"><text class="section-title">今日获得积分</text><text class="points-link">积分明细 ›</text></view>
<view class="points-total"><text class="points-number">+{{ detail.pointsTotal }}</text><text class="points-unit">积分</text></view> <view class="points-total"><text class="points-number">+{{ detail.pointsTotal }}</text><text class="points-unit">积分</text></view>
<view class="points-list"> <view v-if="detail.pointDetails.length === 0" class="points-empty"><text class="points-empty-text">今日暂无积分</text></view>
<view v-else class="points-list">
<view v-for="item in detail.pointDetails" :key="item.id" class="point-item"><text class="point-label">{{ item.label }}</text><text class="point-value">+{{ item.points }}</text></view> <view v-for="item in detail.pointDetails" :key="item.id" class="point-item"><text class="point-label">{{ item.label }}</text><text class="point-value">+{{ item.points }}</text></view>
</view> </view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<MainTabBar active-tab="home"></MainTabBar> <MainTabBar active-tab="home"></MainTabBar>
<page-container :show="showStudentPicker" position="bottom" :round="true" :overlay="true" :close-on-slide-down="true" @beforeleave="closeStudentPicker">
<view class="student-picker-panel">
<view class="student-picker-header">
<text class="student-picker-title">切换孩子</text>
<text class="student-picker-subtitle">切换后将刷新当前孩子的今日学习</text>
</view>
<scroll-view class="student-picker-list" direction="vertical" scroll-y="true">
<view v-if="isStudentsLoading" class="student-picker-empty"><text class="student-picker-empty-text">学生信息加载中</text></view>
<view v-else-if="boundStudents.length === 0" class="student-picker-empty"><text class="student-picker-empty-text">暂无已绑定学生</text></view>
<view v-else v-for="(item, index) in boundStudents" :key="item.id" class="student-picker-item" :class="selectedStudentId === item.id ? 'student-picker-item-selected' : ''" @tap="selectStudent(item.id)">
<view class="student-picker-avatar" :class="studentPickerAvatarClass(index)"><image v-if="item.avatar" class="student-picker-avatar-image" :src="item.avatar" mode="aspectFill"></image><text v-else class="student-picker-avatar-text">{{ studentPickerInitial(item.name) }}</text></view>
<view class="student-picker-info"><view class="student-picker-name-row"><text class="student-picker-name">{{ item.name }}</text><text class="student-picker-grade">{{ item.school }} {{ item.className }}</text></view><text class="student-picker-detail">账号:{{ item.account }}</text></view>
<text class="student-picker-action" :class="selectedStudentId === item.id ? 'student-picker-action-selected' : ''">{{ selectedStudentId === item.id ? '当前查看' : '切换' }}</text>
</view>
</scroll-view>
<view class="student-picker-footer"><view class="student-picker-cancel" @tap="closeStudentPicker"><text class="student-picker-cancel-text">取消</text></view><view class="student-picker-confirm" @tap="confirmStudent"><text class="student-picker-confirm-text">确定</text></view></view>
</view>
</page-container>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue' import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue' import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetTodayLearningDetail, type TodayLearningDetail } from '../../api/student' import { apiGetBoundStudents, apiGetStudentHomeProfile, apiGetTodayLearningDetail, type BoundStudentSummary, type TodayLearningDetail } from '../../api/student'
import { homeStudentProfile } from '../../store/home' import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile, switchBoundStudent } from '../../store/home'
const isLoading = ref(true) const isLoading = ref(true)
const isStudentsLoading = ref(false)
const showStudentPicker = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('')
const detail = ref<TodayLearningDetail>({ const detail = ref<TodayLearningDetail>({
studentId: '', studentName: '', dateText: '', checkedIn: false, learningMinutes: 0, answerMinutes: 0, studentId: '', studentName: '', dateText: '', checkedIn: false, checkInStatusName: '未打卡', learningMinutes: 0, answerMinutes: 0,
answerCount: 0, completedPackages: 0, totalPackages: 0, masteredPoints: 0, courses: [], answerCount: 0, completedPackages: 0, totalPackages: 0, masteredPoints: 0, courses: [],
score: { accuracy: 0, yesterdayAccuracy: 0, change: 0 }, pointsTotal: 0, pointDetails: [] score: { accuracy: null, yesterdayAccuracy: null, change: null, hasYesterdayData: false }, pointsTotal: 0, pointDetails: []
}) })
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const headerStudentName = computed((): string => homeStudentProfile.value?.name || detail.value.studentName || '孩子')
const headerSubtitle = computed((): string => `${detail.value.dateText || formatCurrentDate()} · ${headerStudentName.value}`)
onLoad(() => { onLoad(() => {
loadTodayLearning() loadTodayLearning()
}) })
async function loadTodayLearning() { onShow(() => {
const studentId = homeStudentProfile.value?.id ?? '' if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadTodayLearning()
}
})
async function loadTodayLearning(showLoading: boolean = true) {
isLoading.value = showLoading
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (!studentId) { if (!studentId) {
isLoading.value = false isLoading.value = false
return return
} }
const res = await apiGetTodayLearningDetail(studentId) const res = await apiGetTodayLearningDetail(studentId, showLoading)
if (res && res.data) { if (res && res.data) {
detail.value = res.data detail.value = res.data
} }
isLoading.value = false isLoading.value = false
} }
async function openStudentPicker() {
showStudentPicker.value = true
selectedStudentId.value = homeStudentProfile.value?.id ?? ''
isStudentsLoading.value = true
const res = await apiGetBoundStudents()
if (res && res.data) {
boundStudents.value = res.data.students
if (!selectedStudentId.value && res.data.currentStudentId) selectedStudentId.value = res.data.currentStudentId
}
isStudentsLoading.value = false
}
function closeStudentPicker() {
showStudentPicker.value = false
}
function selectStudent(studentId: string) {
selectedStudentId.value = studentId
}
async function confirmStudent() {
if (!selectedStudentId.value) {
uni.showToast({ title: '请选择学生', icon: 'none' })
return
}
const selectedStudent = boundStudents.value.find((item) => item.id === selectedStudentId.value)
if (selectedStudent) {
switchBoundStudent(selectedStudent)
observedStudentVersion.value = selectedStudentChangeVersion.value
}
closeStudentPicker()
await loadTodayLearning(true)
}
function studentPickerInitial(name: string): string {
return name.length > 1 ? name.substring(1, 2) : name.substring(0, 1)
}
function studentPickerAvatarClass(index: number): string {
if (index === 0) return 'student-picker-avatar-0'
if (index === 1) return 'student-picker-avatar-1'
return 'student-picker-avatar-2'
}
function formatCurrentDate(): string {
const now = new Date()
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${now.getMonth() + 1}月${now.getDate()}日 ${weekdays[now.getDay()]}`
}
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
} }
function formatAccuracy(rate: number | null): string {
if (rate === null) return '暂无数据'
return `${rate}%`
}
function formatAccuracyDiff(diff: number | null): string {
if (diff === null) return ''
return `${diff > 0 ? '+' : ''}${diff}%`
}
function accuracyDiffClass(diff: number | null): string {
if (diff === null || diff >= 0) return 'score-change-up'
return 'score-change-down'
}
</script> </script>
<style> <style>
@@ -119,18 +239,24 @@
.course-item { display: flex; flex-direction: row; align-items: center; min-height: 58px; margin-bottom: 10px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; } .course-item { display: flex; flex-direction: row; align-items: center; min-height: 58px; margin-bottom: 10px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; }
.course-time { width: 56px; font-size: 12px; color: #596275; } .course-time { width: 56px; font-size: 12px; color: #596275; }
.course-copy { display: flex; flex-direction: column; flex: 1; } .course-copy { display: flex; flex-direction: column; flex: 1; }
.course-title-row { display: flex; flex-direction: row; align-items: center; }
.course-subject { font-size: 13px; font-weight: 600; color: #2a3042; } .course-subject { font-size: 13px; font-weight: 600; color: #2a3042; }
.course-stage { margin-left: 6px; padding: 1px 6px; border-radius: 8px; font-size: 10px; color: #6558e9; background-color: #efedff; }
.course-content { margin-top: 4px; font-size: 12px; color: #596275; } .course-content { margin-top: 4px; font-size: 12px; color: #596275; }
.course-status { padding: 4px 7px; border-radius: 10px; font-size: 10px; } .course-status { padding: 4px 7px; border-radius: 10px; font-size: 10px; }
.course-complete { color: #00a66a; background-color: #e9fbf3; } .course-complete { color: #00a66a; background-color: #e9fbf3; }
.course-pending { color: #f08a1a; background-color: #fff4e5; } .course-pending { color: #f08a1a; background-color: #fff4e5; }
.course-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 90px; margin-top: 12px; border-radius: 8px; background-color: #f6f8fb; }
.course-empty-text { font-size: 12px; color: #8992a5; }
.score-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; } .score-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.score-description { margin-top: 6px; font-size: 11px; color: #8992a5; } .score-description { margin-top: 6px; font-size: 11px; color: #8992a5; }
.score-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-top: 14px; } .score-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-top: 14px; }
.score-label { font-size: 12px; color: #596275; } .score-label { font-size: 12px; color: #596275; }
.score-values { display: flex; flex-direction: row; align-items: center; } .score-values { display: flex; flex-direction: row; align-items: center; }
.score-value { font-size: 13px; font-weight: 700; color: #20283a; } .score-value { font-size: 13px; font-weight: 700; color: #20283a; }
.score-change { margin-left: 8px; font-size: 12px; color: #00a66a; } .score-change { margin-left: 8px; font-size: 12px; }
.score-change-up { color: #00a66a; }
.score-change-down { color: #e05b5b; }
.score-yesterday { margin-top: 6px; font-size: 12px; color: #8992a5; } .score-yesterday { margin-top: 6px; font-size: 12px; color: #8992a5; }
.score-track { height: 8px; margin-top: 8px; border-radius: 4px; background-color: #edf1f7; } .score-track { height: 8px; margin-top: 8px; border-radius: 4px; background-color: #edf1f7; }
.score-progress { height: 8px; border-radius: 4px; background-color: #7659ee; } .score-progress { height: 8px; border-radius: 4px; background-color: #7659ee; }
@@ -144,4 +270,34 @@
.point-item { display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 8px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; } .point-item { display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 8px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; }
.point-label { font-size: 12px; color: #596275; } .point-label { font-size: 12px; color: #596275; }
.point-value { font-size: 12px; font-weight: 600; color: #ee9500; } .point-value { font-size: 12px; font-weight: 600; color: #ee9500; }
.points-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60px; margin-top: 12px; border-radius: 8px; background-color: #f6f8fb; }
.points-empty-text { font-size: 12px; color: #8992a5; }
.student-picker-panel { display: flex; flex-direction: column; background-color: #ffffff; }
.student-picker-header { display: flex; flex-direction: column; padding: 18px 20px 12px; }
.student-picker-title { font-size: 18px; font-weight: 700; color: #242b3d; }
.student-picker-subtitle { margin-top: 5px; font-size: 12px; color: #8992a5; }
.student-picker-list { max-height: 340px; padding: 0 20px; }
.student-picker-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; }
.student-picker-empty-text { font-size: 13px; color: #8992a5; }
.student-picker-item { display: flex; flex-direction: row; align-items: center; padding: 10px; margin-bottom: 10px; border: 1px solid #edf0f5; border-radius: 15px; background-color: #ffffff; }
.student-picker-item-selected { background-color: #f0efff; }
.student-picker-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 46px; height: 46px; border-radius: 14px; background-color: #7161ed; }
.student-picker-avatar-0 { background-color: #6558e9; }
.student-picker-avatar-1 { background-color: #12a9df; }
.student-picker-avatar-2 { background-color: #e63a9c; }
.student-picker-avatar-image { width: 46px; height: 46px; border-radius: 14px; }
.student-picker-avatar-text { font-size: 18px; font-weight: 700; color: #ffffff; }
.student-picker-info { display: flex; flex-direction: column; flex: 1; margin-left: 11px; }
.student-picker-name-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 5px; }
.student-picker-name { margin-right: 8px; font-size: 15px; font-weight: 700; color: #2a3042; }
.student-picker-grade { padding: 2px 6px; border-radius: 7px; font-size: 10px; color: #554be9; background-color: #e8e7ff; }
.student-picker-detail { margin-top: 2px; font-size: 11px; color: #7e8799; }
.student-picker-action { flex-shrink: 0; padding: 5px 8px; border-radius: 10px; font-size: 9px; color: #6e7c91; background-color: #f1f4f8; }
.student-picker-action-selected { color: #ffffff; background-color: #554be9; }
.student-picker-footer { display: flex; flex-direction: row; padding: 12px 20px 24px; border-top: 1px solid #edf0f5; }
.student-picker-cancel, .student-picker-confirm { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; height: 44px; border-radius: 11px; }
.student-picker-cancel { margin-right: 10px; background-color: #f0f2f7; }
.student-picker-confirm { background-color: #554be9; }
.student-picker-cancel-text { font-size: 15px; color: #596275; }
.student-picker-confirm-text { font-size: 15px; color: #ffffff; }
</style> </style>
+10 -1
View File
@@ -8,14 +8,23 @@
<script setup lang="ts"> <script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue' 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 { 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 vocabulary = ref<StudentVocabulary>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', bookCount: 0, books: [] })
const isLoading = ref(true) const isLoading = ref(true)
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
onLoad(() => { loadVocabulary() }) onLoad(() => { loadVocabulary() })
onShow(() => {
if (observedStudentVersion.value !== selectedStudentChangeVersion.value) {
observedStudentVersion.value = selectedStudentChangeVersion.value
loadVocabulary()
}
})
async function loadVocabulary() { async function loadVocabulary() {
isLoading.value = true
let studentId = homeStudentProfile.value?.id ?? '' let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) { if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false) const profileRes = await apiGetStudentHomeProfile(undefined, false)
+13 -2
View File
@@ -1,9 +1,12 @@
import type { StudentAlert, StudentHomeMetrics, StudentHomeProfile } from '../api/student' 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 homeStudentProfile = ref<StudentHomeProfile | null>(null)
export const homeStudentMetrics = ref<StudentHomeMetrics | null>(null) export const homeStudentMetrics = ref<StudentHomeMetrics | null>(null)
export const homeStudentAlerts = ref<StudentAlert[] | null>(null) export const homeStudentAlerts = ref<StudentAlert[] | null>(null)
export const selectedStudentChangeVersion = ref(0)
export function setHomeStudentProfile(profile: StudentHomeProfile) { export function setHomeStudentProfile(profile: StudentHomeProfile) {
const userInfo = uni.getStorageSync('userInfo') as { userId?: string } | null 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 const userInfo = uni.getStorageSync('userInfo') as { userId?: string } | null
if (userInfo?.userId) uni.setStorageSync(`selectedStudent_${userInfo.userId}`, student) 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 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' const comprehensiveLevel = homeStudentProfile.value?.id === student.id ? homeStudentProfile.value.comprehensiveLevel : 'good'
setHomeStudentProfile({ ...student, comprehensiveLevel }) 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) { export function setHomeStudentMetrics(metrics: StudentHomeMetrics) {
homeStudentMetrics.value = metrics homeStudentMetrics.value = metrics
} }
+9 -3
View File
@@ -19,7 +19,7 @@ export type RequestConfig = {
showError?: boolean showError?: boolean
} }
const BASE_URL = 'https://localhost:7145' const BASE_URL = 'https://28j56530c4.oicp.vip'
const TIMEOUT = 10000 const TIMEOUT = 10000
let loadingCount = 0 let loadingCount = 0
@@ -54,7 +54,7 @@ function requestInterceptor(config: RequestConfig): RequestConfig {
return config return config
} }
function responseInterceptor<T>(response: UniNamespace.RequestSuccessCallbackResult, config: RequestConfig): ApiResponse<T> | null { function responseInterceptor<T>(response: any, config: RequestConfig): ApiResponse<T> | null {
if (config.showLoading !== false) hideLoading() if (config.showLoading !== false) hideLoading()
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
@@ -62,7 +62,13 @@ function responseInterceptor<T>(response: UniNamespace.RequestSuccessCallbackRes
return null return null
} }
const result = response.data as ApiResponse<T> 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) { if (result.code !== 200) {
handleError(result.code, result.message, config.showError) handleError(result.code, result.message, config.showError)
return null return null