This commit is contained in:
hexi
2026-11-06 16:56:49 +08:00
4 changed files with 166 additions and 142 deletions
+97 -14
View File
@@ -255,6 +255,14 @@ export type StudentGrowth = {
records: GrowthPointRecord[]
}
export type StudentPointRecordsPage = {
records: GrowthPointRecord[]
total: number
pageIndex: number
pageSize: number
lastId: number | null
}
export type LearningTaskItem = {
id: string
title: string
@@ -333,6 +341,15 @@ export type StudentLearningWarningResult = {
items: StudentLearningWarningItem[]
}
const learningWarningTypeMap: { [key: number]: { title: string, level: StudentAlertLevel } } = {
1: { title: '今日未打卡', level: 'high' },
2: { title: '仅学无交', level: 'medium' },
3: { title: '作业未完成', level: 'high' },
4: { title: '攻克失败', level: 'critical' },
5: { title: '连续 3 天未学习', level: 'high' },
6: { title: '正确率明显下滑', level: 'medium' }
}
// 验证学生二维码请求参数
export type VerifyQRCodeParams = {
qrCode: string
@@ -433,26 +450,30 @@ export function apiGetStudentHomeMetrics(studentId: string, showLoading: boolean
}
/**
* 获取指定学生今日学习预警。已触发预警优先,其后展示未触发规则。
* 获取指定学生今日学习预警。已触发预警优先,其后展示未触发规则,可按数量裁剪。
*/
export function apiGetStudentLearningWarnings(studentId: string, showLoading: boolean = true, limit?: number): Promise<ApiResponse<StudentAlertList> | null> {
export function apiGetStudentLearningWarnings(studentId: string, showLoading: boolean = true, limit?: number, onlyTriggered: boolean = false): Promise<ApiResponse<StudentAlertList> | null> {
return get<StudentLearningWarningResult>(`/api/v1/ParentLearningWarning/GetTodayList?userId=${encodeURIComponent(studentId)}`, undefined, { showLoading }).then((res) => {
if (!res || !res.data || !Array.isArray(res.data.items)) return null
const warningData = res.data
const triggeredItems = warningData.items.filter((item: StudentLearningWarningItem) => Boolean(item.isTriggered))
const normalItems = warningData.items.filter((item: StudentLearningWarningItem) => !Boolean(item.isTriggered))
const sourceItems = triggeredItems.concat(normalItems)
const orderedItems = triggeredItems.concat(normalItems)
const sourceItems = onlyTriggered ? triggeredItems : orderedItems
const visibleItems = limit === undefined ? sourceItems : sourceItems.slice(0, limit)
const list: StudentAlert[] = visibleItems.map((item: StudentLearningWarningItem, index: number) => ({
id: `${studentId}-${item.type ?? index}`,
level: item.isTriggered ? 'high' : 'low',
title: item.title || '学习预警',
content: item.description || item.statusName || '',
occurredAt: warningData.date || '',
isRead: !item.isTriggered,
isTriggered: Boolean(item.isTriggered),
statusName: item.statusName || ''
}))
const list: StudentAlert[] = visibleItems.map((item: StudentLearningWarningItem, index: number) => {
const warningType = learningWarningTypeMap[Number(item.type)]
return {
id: `${studentId}-${item.type ?? index}`,
level: item.isTriggered ? warningType?.level ?? 'high' : 'low',
title: item.title || warningType?.title || '学习预警',
content: item.description || item.statusName || '',
occurredAt: warningData.date || '',
isRead: !item.isTriggered,
isTriggered: Boolean(item.isTriggered),
statusName: item.statusName || (item.isTriggered ? '已触发' : '正常')
}
})
return {
code: res.code,
message: res.message,
@@ -684,7 +705,69 @@ export function apiGetStudentVocabulary(studentId: string, showLoading: boolean
}
export function apiGetStudentGrowth(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentGrowth> | null> {
return get<StudentGrowth>(`/api/parent/growth?studentId=${studentId}`, undefined, { showLoading })
return get<any>(`/api/v1/StudentPoints/GetStudentPointSummary/${studentId}`, undefined, { showLoading }).then((res) => {
if (!res || !res.data) return null
return {
code: res.code,
message: res.message,
data: {
studentId: String(res.data.userId ?? studentId),
studentName: '',
studentGrade: '',
studentClass: '',
pointsBalance: Number(res.data.totalPoints) || 0,
level: Number(res.data.level) || 0,
rank: Number(res.data.classRank) || 0,
streakDays: Number(res.data.noPointDays) || 0,
totalPoints: Number(res.data.todayPoints) || 0,
records: []
}
}
})
}
/**
* 分页获取学生积分流水。
*/
export function apiGetStudentPointRecords(studentId: string, pageIndex: number = 1, pageSize: number = 20, prevPageId?: number, showLoading: boolean = true): Promise<ApiResponse<StudentPointRecordsPage> | null> {
const prevPageQuery = prevPageId === undefined || prevPageId === null ? '' : `&PrevPageId=${prevPageId}`
return get<any>(`/api/v1/StudentPoints/GetStudentPointRecords?UserId=${studentId}&PageIndex=${pageIndex}&PageSize=${pageSize}${prevPageQuery}`, undefined, { showLoading }).then((res) => {
if (!res || !res.data) return null
const page = res.data
const records: GrowthPointRecord[] = (Array.isArray(page.data) ? page.data : []).map((item: any) => ({
id: String(item.id),
title: item.pointMessage || '积分变动',
timeText: formatPointRecordTime(item.bizTime),
points: Number(item.points) || 0,
icon: pointRecordIcon(Number(item.type))
}))
return {
code: res.code,
message: res.message,
data: {
records,
total: Number(page.total) || 0,
pageIndex: Number(page.pageIndex) || pageIndex,
pageSize: Number(page.pageSize) || pageSize,
lastId: page.id === null || page.id === undefined ? null : Number(page.id)
}
}
})
}
function pointRecordIcon(type: number): GrowthPointRecord['icon'] {
if (type === 1) return 'calendar'
if (type === 2 || type === 3) return 'assignment'
if (type === 4) return 'streak'
if (type === 5 || type === 6) return 'accuracy'
if (type === 7 || type === 8) return 'mastery'
return 'note'
}
function formatPointRecordTime(value: any): string {
if (!value) return '--'
const text = String(value)
return text.length >= 16 ? text.substring(5, 16).replace('T', ' ') : text
}
export function apiGetStudentLearningTasks(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentLearningTasks> | null> {
+42 -2
View File
@@ -1,18 +1,24 @@
<template>
<view class="page-container">
<view class="header-panel"><view class="header-back" @tap="goHome"><UniIcons type="back" class="header-back-icon"></UniIcons></view><view class="header-copy"><text class="header-title">成长激励</text><text class="header-subtitle">{{ homeStudentProfile?.name ?? growth.studentName }} · {{ homeStudentProfile?.school ?? '' }} {{ homeStudentProfile?.className ?? growth.studentClass }}</text></view><view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true"><view v-if="isLoading" class="status-panel"><text class="status-text">成长数据加载中</text></view><view v-else class="content-panel"><view class="points-card"><view class="points-heading"><view class="points-copy"><text class="points-label">积分余额</text><text class="points-value">{{ growth.pointsBalance }}</text></view><view class="level-badge"><text class="level-label">等级</text><text class="level-value">Lv.{{ growth.level }}</text></view></view><view class="points-stats"><view class="points-stat"><text class="points-stat-value">{{ growth.rank }}</text><text class="points-stat-label">班级排名</text></view><view class="points-stat points-stat-middle"><text class="points-stat-value">{{ growth.streakDays }}</text><text class="points-stat-label">未能连续</text></view><view class="points-stat"><text class="points-stat-value">{{ growth.totalPoints }}</text><text class="points-stat-label">获得总量</text></view></view></view><view class="flow-card"><text class="section-title">积分流水</text><text class="section-description">记录孩子每天做了哪些正向行为</text><view class="flow-list"><view v-for="record in growth.records" :key="record.id" class="flow-item"><view class="flow-icon" :class="'flow-icon-' + record.icon"><UniIcons :type="record.icon === 'calendar' ? 'calendar' : record.icon === 'assignment' ? 'checkbox' : record.icon === 'streak' ? 'fire' : record.icon === 'accuracy' ? 'circle-filled' : record.icon === 'mastery' ? 'star-filled' : 'compose'" class="flow-icon-glyph"></UniIcons></view><view class="flow-copy"><text class="flow-title">{{ record.title }}</text><text class="flow-time">{{ record.timeText }}</text></view><text class="flow-points">+{{ record.points }}</text></view></view></view></view></scroll-view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="refreshGrowth" @scrolltolower="loadMoreRecords"><view v-if="isLoading" class="status-panel"><text class="status-text">成长数据加载中</text></view><view v-else class="content-panel"><view class="points-card"><view class="points-heading"><view class="points-copy"><text class="points-label">总积分</text><text class="points-value">{{ growth.pointsBalance }}</text></view><view class="level-badge"><text class="level-label">等级</text><text class="level-value">Lv.{{ growth.level }}</text></view></view><view class="points-stats"><view class="points-stat"><text class="points-stat-value">{{ growth.rank }}</text><text class="points-stat-label">班级排名</text></view><view class="points-stat points-stat-middle"><text class="points-stat-value">{{ growth.streakDays }}</text><text class="points-stat-label">未获积分天数</text></view><view class="points-stat"><text class="points-stat-value">{{ growth.totalPoints }}</text><text class="points-stat-label">今日获得</text></view></view></view><view class="flow-card"><text class="section-title">积分流水</text><text class="section-description">记录孩子每天做了哪些正向行为</text><view v-if="growth.records.length === 0" class="flow-empty"><text class="flow-empty-text">暂无积分流水</text></view><view v-else class="flow-list"><view v-for="record in growth.records" :key="record.id" class="flow-item"><view class="flow-icon" :class="'flow-icon-' + record.icon"><UniIcons :type="record.icon === 'calendar' ? 'calendar' : record.icon === 'assignment' ? 'checkbox' : record.icon === 'streak' ? 'fire' : record.icon === 'accuracy' ? 'circle-filled' : record.icon === 'mastery' ? 'star-filled' : 'compose'" class="flow-icon-glyph"></UniIcons></view><view class="flow-copy"><text class="flow-title">{{ record.title }}</text><text class="flow-time">{{ record.timeText }}</text></view><text class="flow-points">{{ record.points >= 0 ? '+' : '' }}{{ record.points }}</text></view></view><view v-if="isLoadingMore" class="records-footer"><text class="records-footer-text">加载更多...</text></view><view v-else-if="!hasMoreRecords && growth.records.length > 0" class="records-footer"><text class="records-footer-text">没有更多记录</text></view></view></view></scroll-view>
</view>
</template>
<script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentGrowth, apiGetStudentHomeProfile, type StudentGrowth } from '../../api/student'
import { apiGetStudentGrowth, apiGetStudentHomeProfile, apiGetStudentPointRecords, type StudentGrowth } from '../../api/student'
import { homeStudentProfile, selectedStudentChangeVersion, setHomeStudentProfile } from '../../store/home'
const growth = ref<StudentGrowth>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pointsBalance: 0, level: 0, rank: 0, streakDays: 0, totalPoints: 0, records: [] })
const isLoading = ref(true)
const observedStudentVersion = ref(selectedStudentChangeVersion.value)
const isRefreshing = ref(false)
const isLoadingMore = ref(false)
const hasMoreRecords = ref(true)
const recordsPageIndex = ref(1)
const recordsLastId = ref<number | null>(null)
const currentStudentId = ref('')
onLoad(() => { loadGrowth() })
@@ -34,12 +40,44 @@
}
}
if (studentId) {
currentStudentId.value = studentId
const res = await apiGetStudentGrowth(studentId)
if (res && res.data) growth.value = res.data
await loadPointRecords(false)
}
isLoading.value = false
}
async function loadPointRecords(append: boolean) {
if (!currentStudentId.value || isLoadingMore.value) return
if (append && !hasMoreRecords.value) return
if (append) isLoadingMore.value = true
const pageIndex = append ? recordsPageIndex.value + 1 : 1
const res = await apiGetStudentPointRecords(currentStudentId.value, pageIndex, 20, append ? recordsLastId.value ?? undefined : undefined, !append)
if (res && res.data) {
const records = append ? growth.value.records.concat(res.data.records) : res.data.records
growth.value = { ...growth.value, records }
recordsPageIndex.value = res.data.pageIndex
recordsLastId.value = res.data.lastId
hasMoreRecords.value = records.length < res.data.total && res.data.records.length > 0
}
isLoadingMore.value = false
}
async function loadMoreRecords() {
await loadPointRecords(true)
}
async function refreshGrowth() {
if (isRefreshing.value) return
isRefreshing.value = true
hasMoreRecords.value = true
recordsPageIndex.value = 1
recordsLastId.value = null
await loadGrowth()
isRefreshing.value = false
}
function goHome() { uni.reLaunch({ url: '/pages/index/index' }) }
</script>
@@ -81,4 +119,6 @@
.flow-title { font-size: 13px; color: #242b3d; }
.flow-time { margin-top: 3px; font-size: 10px; color: #94a0b1; }
.flow-points { font-size: 13px; font-weight: 700; color: #f29900; }
.flow-empty, .records-footer { display: flex; flex-direction: column; align-items: center; padding: 14px 0 4px; }
.flow-empty-text, .records-footer-text { font-size: 11px; color: #94a0b1; }
</style>
+3 -3
View File
@@ -228,10 +228,10 @@
}
}
}
if (student.value.id) {
if (selectedId) {
await Promise.all([
loadStudentHomeMetrics(selectedId || student.value.id, showLoading),
loadStudentAlerts(selectedId || student.value.id, showLoading)
loadStudentHomeMetrics(selectedId, showLoading),
loadStudentAlerts(selectedId, showLoading)
])
}
}
+24 -123
View File
@@ -3,14 +3,12 @@
* 支持请求拦截、响应拦截、Loading显示、错误处理
*/
// 响应数据类型
export type ApiResponse<T> = {
code: number
message: string
data: T | null
}
// 请求配置类型
export type RequestConfig = {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
@@ -21,37 +19,19 @@ export type RequestConfig = {
showError?: boolean
}
// 基础URL配置 - 虚拟接口
const BASE_URL = 'https://localhost:7145'
// 请求超时时间
const TIMEOUT = 10000
// 联调前统一使用测试响应,保证所有页面流程可完整验证。
const USE_MOCK_API = true
// Mock 接口模拟正常网络环境,响应延迟范围为 0 到 2 秒。
const MOCK_RESPONSE_MAX_DELAY = 2000
// Loading引用计数
let loadingCount = 0
/**
* 显示Loading
*/
function showLoading(text: string = '加载中...') {
loadingCount++
if (loadingCount === 1) {
uni.showLoading({
title: text,
mask: true
})
uni.showLoading({ title: text, mask: true })
}
}
/**
* 隐藏Loading
*/
function hideLoading() {
loadingCount--
if (loadingCount <= 0) {
@@ -60,36 +40,19 @@ function hideLoading() {
}
}
/**
* 获取Token
*/
function getToken(): string {
return uni.getStorageSync('token') as string ?? ''
}
/**
* 请求拦截器 - 添加token等
*/
function requestInterceptor(config: RequestConfig): RequestConfig {
// 添加完整URL
if (!config.url.startsWith('http')) {
config.url = BASE_URL + config.url
}
// 添加请求头
if (!config.url.startsWith('http')) config.url = BASE_URL + config.url
const token = getToken()
config.header = config.header ?? {}
config.header['Content-Type'] = 'application/json'
if (token) {
config.header['Authorization'] = `Bearer ${token}`
}
// 显示Loading
if (config.showLoading !== false) {
showLoading(config.loadingText)
}
if (token) config.header['Authorization'] = `Bearer ${token}`
if (config.showLoading !== false) showLoading(config.loadingText)
return config
}
@@ -97,17 +60,13 @@ function requestInterceptor(config: RequestConfig): RequestConfig {
* 响应拦截器 - 统一处理响应
*/
function responseInterceptor<T>(response: any, config: RequestConfig): ApiResponse<T> | null {
// 隐藏Loading
if (config.showLoading !== false) {
hideLoading()
}
// HTTP状态码检查
if (config.showLoading !== false) hideLoading()
if (response.statusCode !== 200) {
handleError(response.statusCode, '网络请求失败', config.showError)
return null
}
// 解析响应数据,兼容后端 PascalCase 与前端 camelCase 两种包装格式。
const rawResult = response.data as any
const result: ApiResponse<T> = {
@@ -115,50 +74,35 @@ function responseInterceptor<T>(response: any, config: RequestConfig): ApiRespon
message: String(rawResult?.message ?? rawResult?.Message ?? ''),
data: (rawResult?.data ?? rawResult?.Data ?? null) as T | null
}
// 业务状态码检查
if (result.code !== 200) {
handleError(result.code, result.message, config.showError)
return null
}
return result
}
/**
* 错误处理
*/
function handleError(code: number, message: string, showError: boolean = true) {
console.error(`[Request Error] Code: ${code}, Message: ${message}`)
if (!showError) {
return
}
// 根据错误码显示不同提示
if (!showError) return
let errorMsg = message
if (code === 401) {
errorMsg = '登录已过期,请重新登录'
// 清除token并跳转登录页
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
uni.reLaunch({
url: '/pages/login/index'
})
uni.reLaunch({ url: '/pages/login/index' })
} else if (code === 403) {
errorMsg = '没有权限访问'
} else if (code === 404) {
errorMsg = '请求的资源不存在'
} else if (code === 500) {
errorMsg = '服务器错误,请稍后重试'
} else if (code === -1) {
errorMsg = '网络异常'
}
uni.showToast({
title: errorMsg,
icon: 'none',
duration: 2000
})
uni.showToast({ title: errorMsg, icon: 'none', duration: 2000 })
}
/**
@@ -386,8 +330,7 @@ function createMockResponse<T>(config: RequestConfig): ApiResponse<T> {
* 通用请求方法
*/
export function request<T>(config: RequestConfig): Promise<ApiResponse<T> | null> {
return new Promise((resolve, reject) => {
// 请求拦截
return new Promise((resolve) => {
const finalConfig = requestInterceptor(config)
if (USE_MOCK_API && !finalConfig.url.includes('/api/v1/Parents/') && !finalConfig.url.includes('/api/v1/ParentStudentToday/') && !finalConfig.url.includes('/api/v1/ParentLearningWarning/') && !finalConfig.url.includes('/api/v1/ParentStudyPack/') && !finalConfig.url.includes('/api/v1/ParentLearningReport/')) {
@@ -408,70 +351,28 @@ export function request<T>(config: RequestConfig): Promise<ApiResponse<T> | null
data: finalConfig.data,
header: finalConfig.header,
timeout: TIMEOUT,
success: (res) => {
// 响应拦截
const result = responseInterceptor<T>(res, finalConfig)
resolve(result)
},
success: (res) => resolve(responseInterceptor<T>(res, finalConfig)),
fail: (err) => {
// 隐藏Loading
if (finalConfig.showLoading !== false) {
hideLoading()
}
// 网络错误处理
if (finalConfig.showLoading !== false) hideLoading()
handleError(-1, err.errMsg ?? '网络连接失败', finalConfig.showError)
// 将网络失败转换为统一的空响应,避免页面事件中出现未处理 Promise 异常。
resolve(null)
}
})
})
}
/**
* GET请求
*/
export function get<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T> | null> {
return request<T>({
url,
method: 'GET',
data,
...config
})
return request<T>({ url, method: 'GET', data, ...config })
}
/**
* POST请求
*/
export function post<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T> | null> {
return request<T>({
url,
method: 'POST',
data,
...config
})
return request<T>({ url, method: 'POST', data, ...config })
}
/**
* PUT请求
*/
export function put<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T> | null> {
return request<T>({
url,
method: 'PUT',
data,
...config
})
return request<T>({ url, method: 'PUT', data, ...config })
}
/**
* DELETE请求
*/
export function del<T>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T> | null> {
return request<T>({
url,
method: 'DELETE',
data,
...config
})
return request<T>({ url, method: 'DELETE', data, ...config })
}