feat:学情报告数据显示

This commit is contained in:
MJ 2025-08-08 19:19:08 +08:00
parent e0fc132885
commit defc2ab5f1
7 changed files with 347 additions and 275 deletions

View File

@ -8,21 +8,23 @@ import { storeToRefs } from 'pinia'
import { useBadgeStore } from '@/store/badge'
import { useChildStore } from '@/store/child'
import hud from '@/utils/hud'
import { getStudentStudyDataApi } from '@/api'
import { useStudyDataStore } from '@/store/study-data'
import { secondsToHours } from '@/utils'
const childStore = useChildStore()
const { getChildren } = childStore
const { children } = storeToRefs(childStore)
const { refreshBadge } = useBadgeStore()
const { getStudentStudyData } = useStudyDataStore()
const showChild = ref<ChildrenType>() //
const showChildData = ref({} as any)
const OSS_URL = import.meta.env.VITE_OSS_HOST
const arrow = `${OSS_URL}/iconfont/down_arrow.png`
const defaultAvatar = `${OSS_URL}/urm/default_avatar.png`
const showAllChildren = ref(false)
const checkedId = ref<string>()
const checkedId = ref()
function handleShowChild(i: ChildrenType) {
checkedId.value = i.child?.id
showChild.value = i
@ -90,7 +92,13 @@ const subjectOptions = [
const subject = ref(subjectOptions[0].value)
watch(
[() => dataType.value, () => timeType.value, () => timeDay.value, () => subject.value],
[
() => dataType.value,
() => timeType.value,
() => timeDay.value,
() => subject.value,
() => checkedId.value,
],
() => {
const params = {
queryType: dataType.value,
@ -104,7 +112,9 @@ watch(
if (dataType.value === dataTypeSubject) {
params.subjectId = subject.value
}
getStudentStudyDataApi(params)
getStudentStudyData(params).then(map => {
showChildData.value = map[checkedId.value] || {}
})
},
{ immediate: true },
)
@ -214,94 +224,94 @@ onMounted(() => {
</view>
</view>
</view>
<view class="study-data">
<mj-segment v-model="dataType" :options="dataTypeOptions" />
<view v-if="checkedId" class="study-data">
<view class="time-type-box">
<mj-segment class="time-type" v-model="timeType" :options="timeTypeOptions" />
<view class="time-type-text" v-if="timeType === timeTypeDay" @click="timeDayRef.show()">{{
timeDay
}}</view>
</view>
<mj-segment v-model="dataType" :options="dataTypeOptions" />
<mj-segment v-if="dataType === dataTypeSubject" v-model="subject" :options="subjectOptions" />
<tui-grid v-if="dataType === dataTypeSum" class="data-grid">
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ secondsToHours(showChildData.learnTime) }}</view>
<text class="label">有效学习时长</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.knowledgeCount || 0 }}</view>
<text class="label">知识点</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.errorCount || 0 }}</view>
<text class="label">错题</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.correctCount || 0 }}</view>
<text class="label">订正</text>
</tui-grid-item>
</tui-grid>
<tui-grid v-else-if="dataType === dataTypeWord" class="data-grid">
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.wordStrangerCount || 0 }}</view>
<text class="label">单词陌生</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.wordKnowCount || 0 }}</view>
<text class="label">单词认识</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.wordKnowWellCount || 0 }}</view>
<text class="label">单词熟悉</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.wordGraspCount || 0 }}</view>
<text class="label">单词掌握</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.wordCount || 0 }}</view>
<text class="label">单词总数</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.starCount || 0 }}</view>
<text class="label">单词闯关星星</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.sentenceCount || 0 }}</view>
<text class="label">语感训练句子</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.learnTime || 0 }}</view>
<text class="label">有效学习时长</text>
</tui-grid-item>
</tui-grid>
<tui-grid v-else-if="dataType === dataTypeSubject" class="data-grid">
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.successTestCount || 0 }}</view>
<text class="label">答题正确</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.errorTestCount || 0 }}</view>
<text class="label">答题错误</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.allKnowledgeCount || 0 }}</view>
<text class="label">知识点总数</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.allKnowledgeGraspCount || 0 }}</view>
<text class="label">知识点掌握</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.allReviewCount || 0 }}</view>
<text class="label">订正总数</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.successReviewCount || 0 }}</view>
<text class="label">订正正确</text>
</tui-grid-item>
<tui-grid-item :cell="2">
<view class="value">{{ 0 }}</view>
<view class="value">{{ showChildData.learnTime || 0 }}</view>
<text class="label">有效学习时长</text>
</tui-grid-item>
</tui-grid>
@ -366,251 +376,81 @@ onMounted(() => {
background-color: #fff;
padding: 0 30rpx 30rpx;
border-radius: 20rpx;
}
.title {
margin-bottom: 30rpx;
font-size: 34rpx;
line-height: 48rpx;
color: $font-color;
font-weight: 500;
}
.title2 {
margin: 30rpx 0 20rpx 0;
font-size: 28rpx;
font-weight: 500;
line-height: 40rpx;
text-align: center;
}
.trend_icon {
margin-left: 10rpx;
width: 26rpx;
height: 26rpx;
}
.study_situation {
display: flex;
.item {
flex: 1;
padding: 42rpx 30rpx;
.children {
display: flex;
justify-content: space-between;
height: 119rpx;
border-radius: 20rpx;
background-color: #f7faff;
font-size: 26rpx;
font-weight: 500;
box-sizing: border-box;
padding: 30rpx 0 30rpx 0;
border-bottom: 1rpx solid $border-color;
&:first-child {
margin-right: 30rpx;
}
.date {
color: #3fd15f;
font-size: 28rpx;
}
}
}
.study_situation2 {
display: flex;
justify-content: space-between;
.item {
width: calc(50% - 15rpx);
text-align: center;
font-weight: 500;
&_label {
display: block;
height: 76rpx;
line-height: 76rpx;
background-color: #ebf2ff;
border-bottom: 1rpx solid #d9e1f2;
border-radius: 20rpx 20rpx 0 0;
font-size: 26rpx;
}
&_date {
display: block;
.left {
display: flex;
justify-content: center;
align-items: center;
height: 79rpx;
line-height: 79rpx;
background-color: #f7faff;
border-radius: 0 0 20rpx 20rpx;
font-size: 28rpx;
// rgba(255, 40, 40, 1)jiang rgba(255, 141, 95, 1)ping
.children_avatar {
margin-right: 20rpx;
width: 100rpx;
height: 100rpx;
border-radius: 50%;
}
.name_vip {
display: flex;
align-items: center;
margin-bottom: 6rpx;
.name {
display: inline-block;
max-width: 210rpx;
font-weight: 500;
font-size: 32rpx;
line-height: 48rpx;
color: $font-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vip {
margin-left: 10rpx;
width: 85rpx;
height: 32rpx;
}
}
.time {
font-size: 24rpx;
line-height: 34rpx;
color: $font-aux-color;
}
}
.keep {
color: #ff8d5f;
.icon {
width: 30rpx;
height: 30rpx;
}
.down {
color: #ff2828;
.checked_icon {
margin: auto 0;
}
.up {
color: #21d17a;
.rotate {
transform: rotate(-180deg);
}
}
}
.date_filter_box {
text-align: center;
}
.children {
display: flex;
justify-content: space-between;
padding: 30rpx 0 30rpx 0;
border-bottom: 1rpx solid $border-color;
.left {
.children_info {
display: flex;
align-items: center;
.children_avatar {
margin-right: 20rpx;
width: 100rpx;
height: 100rpx;
border-radius: 50%;
}
.name_vip {
display: flex;
align-items: center;
margin-bottom: 6rpx;
.name {
display: inline-block;
max-width: 210rpx;
font-weight: 500;
font-size: 32rpx;
line-height: 48rpx;
color: $font-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vip {
margin-left: 10rpx;
width: 85rpx;
height: 32rpx;
}
}
.time {
font-size: 24rpx;
line-height: 34rpx;
color: $font-aux-color;
}
}
.icon {
width: 30rpx;
height: 30rpx;
}
.checked_icon {
margin: auto 0;
}
.rotate {
transform: rotate(-180deg);
}
}
.children_info {
display: flex;
padding: 30rpx 0 0 0;
.item {
width: 50%;
font-size: 28rpx;
line-height: 40rpx;
color: $font-aux-color;
.level {
color: $font-color;
}
}
}
.filter_box {
padding: 30rpx 30rpx 13rpx 30rpx;
.title {
margin-bottom: 0;
}
}
.learning_time {
.date_filter_box {
margin: 50rpx 0 30rpx 0;
}
}
.video_learning {
.date_filter_box {
margin: 30rpx 0;
}
}
.exercise_statistics {
.study_situation2 {
margin: 30rpx 0 0 0;
padding: 30rpx 0 0 0;
.item {
width: 210rpx;
}
}
}
width: 50%;
font-size: 28rpx;
line-height: 40rpx;
color: $font-aux-color;
.language_statistics {
.study_situation2 {
margin: 30rpx 0 50rpx 0;
}
}
.knowledge_mapping {
.chart_label {
display: flex;
justify-content: center;
font-size: 26rpx;
color: rgba(102, 102, 102, 1);
.iden_color {
display: inline-block;
width: 30rpx;
height: 8rpx;
margin-right: 4rpx;
}
.increase {
display: flex;
align-items: center;
margin-right: 16rpx;
.iden_color {
background-color: rgba(69, 203, 70, 1);
}
}
.decrease {
display: flex;
align-items: center;
.iden_color {
background-color: rgba(248, 104, 96, 1);
.level {
color: $font-color;
}
}
}

View File

@ -39,7 +39,7 @@
</view>
<view class="code_btn">
<template v-if="codeTime > 0">
<text class="time_tip">{{ codeTimeContet }} 秒后重新发送</text>
<text class="time_tip">{{ codeTimeContent }} 秒后重新发送</text>
</template>
<template v-else>
<text class="time_tip" @click="getCode">获取验证码</text>
@ -67,7 +67,7 @@ const { getUserInfo, setToken } = useUserStore()
const formatPhone = ref('')
const verifyCode = ref('')
const codeTime = ref(0)
const codeTimeContet = computed(() => {
const codeTimeContent = computed(() => {
return codeTime.value
})
@ -119,7 +119,6 @@ async function handleLogin() {
await smsLogin()
}
let wxUser: any
async function smsLogin() {
hud.load({
task: async () => {
@ -128,7 +127,7 @@ async function smsLogin() {
clientType: 'PARENT',
phone: formatPhone.value.replace(/\s+/g, ''),
verifyCode: verifyCode.value,
wxUser,
wxUser: db.get('wxUser') || undefined,
})
await loginSuccess(token)
},
@ -150,16 +149,13 @@ async function wxLogin() {
const data = await wxLoginApi({
code,
})
// openid
if (data.wxUser.openid) {
db.set('openid', data.wxUser.openid)
// wxUser
if (data.wxUser) {
db.set('wxUser', data.wxUser)
}
if (data.accessToken) {
//
await loginSuccess(data.accessToken)
} else {
//
wxUser = data.wxUser
}
},
option: '登录',

View File

@ -93,6 +93,7 @@ onMounted(() => {})
<text class="identity">家长</text>
</view>
<view class="right-bottom">
<text class="account">{{ userInfo.account }}</text>
<text
>乐贝<text class="currency">{{ userInfo.currency }}</text></text
>
@ -168,6 +169,11 @@ onMounted(() => {})
font-size: 28rpx;
line-height: 40rpx;
color: $font-aux-color;
display: flex;
align-items: center;
gap: 30rpx;
.account {
}
.currency {
color: #333;

12
src/store/study-data.ts Normal file
View File

@ -0,0 +1,12 @@
import { getStudentStudyDataApi } from '@/api'
import obj from '@/utils/obj'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
export const useStudyDataStore = defineStore('study-data', () => {
async function getStudentStudyData(params: any) {
const res = await getStudentStudyDataApi(params)
return obj.list2map(res, 'userId')
}
return { getStudentStudyData }
})

View File

@ -25,7 +25,7 @@ export const useUserStore = defineStore('user', () => {
const getUserInfo = async () => {
const mode = import.meta.env.MODE
// 如果是正式环境 && 且缺少openid直接跳回登录页面
if (mode !== 'dev' && mode !== 'mp' && !db.get('openid')) {
if (mode !== 'dev' && mode !== 'mp' && !db.get('wxUser')) {
router.toLogin()
return
}

View File

@ -1,25 +1,229 @@
// 对象相关处理
function clearNullProps(obj: any) {
for (const key in obj) {
if (obj[key] === null || obj[key] === undefined) {
delete obj[key]
}
for (const key in obj) {
if (obj[key] === null || obj[key] === undefined) {
delete obj[key]
}
}
return obj
}
// 计算父节点和子节点的总数
function getNodesSize(nodes: any[], childrenKey = 'children') {
let size = 0
for (const node of nodes) {
size += 1
if (node[childrenKey]?.length > 0) {
size += getNodesSize(node.children, childrenKey)
}
let size = 0
for (const node of nodes) {
size += 1
if (node[childrenKey]?.length > 0) {
size += getNodesSize(node.children, childrenKey)
}
return size
}
return size
}
// 只赋值dst中存在的属性
function assignExits(dst: any, src: any) {
for (const key in dst) {
dst[key] = src[key]
}
}
// 只提取指定的属性
function filterKeys(obj: any, keys: string[]) {
const newObj: any = {}
for (const key of keys) {
newObj[key] = obj[key]
}
return newObj
}
// 是否没有值
function isNullOrUndefined(obj: any) {
return obj === null || obj === undefined
}
function isEqualIgnoreNumberString(obj1: any, obj2: any) {
return isEqual(obj1, obj2, { ignoreNumberString: true })
}
function isEqual(
obj1: any,
obj2: any,
options?: {
ignoreNumberString?: boolean
},
) {
if (obj1 === obj2) {
return true
}
// 忽略数字、字符串的比较
if (
options?.ignoreNumberString &&
(typeof obj1 === 'number' || typeof obj1 === 'string') &&
(typeof obj2 === 'number' || typeof obj2 === 'string')
) {
return String(obj1) === String(obj2)
}
if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) {
return false
}
const keys1 = Object.keys(obj1)
const keys2 = Object.keys(obj2)
if (keys1.length !== keys2.length) return false
for (const key of keys1) {
if (!keys2.includes(key)) return false
if (!isEqual(obj1[key], obj2[key], options)) {
return false
}
}
return true
}
function isKeyValueObject(obj: any) {
return (
obj !== null &&
typeof obj === 'object' &&
!Array.isArray(obj) &&
!(obj instanceof Date) &&
!(obj instanceof RegExp) &&
!(obj instanceof Map) &&
!(obj instanceof Set)
)
}
function coverNullProps(dst: any, src: any) {
return cover(dst, src, {
onlyNullProps: true,
})
}
function moveElement<T>(arr: T[], fromIndex: number, toIndex: number): T[] {
// 处理边界情况
if (fromIndex < 0 || fromIndex >= arr.length || toIndex < 0 || toIndex >= arr.length) {
return arr
}
// 移除fromIndex位置的元素
const [element] = arr.splice(fromIndex, 1)
// 插入到toIndex位置
arr.splice(toIndex, 0, element)
return arr
}
// 覆盖属性
function cover(
dst: any,
src: any,
options?: {
onlyNullProps?: boolean
},
) {
if (!isKeyValueObject(dst) || !isKeyValueObject(src)) {
return dst
}
const onlyNull = options?.onlyNullProps
// 合并所有的key
const keys = Array.from(new Set([...Object.keys(dst), ...Object.keys(src)]))
for (const key of keys) {
// 跳过
if (!(key in src)) continue
// 来源值
const srcVal = src[key]
// 目标值
const dstVal = dst[key]
if (onlyNull && dstVal !== undefined && dstVal !== null) {
continue
}
if (Array.isArray(srcVal)) {
// 如果是数组
if (Array.isArray(dstVal)) {
dst[key] = [...dstVal, ...srcVal]
} else {
dst[key] = [...srcVal]
}
} else if (isKeyValueObject(srcVal) && isKeyValueObject(dstVal)) {
// 如果是对象类型
dst[key] = cover(dstVal, srcVal)
} else {
// 如果是非对象类型
dst[key] = srcVal
}
}
return dst
}
function getAllMethods(obj: any): Record<string, any> {
const methods: Record<string, any> = {}
let current = obj
while (current && current !== Object.prototype) {
const proto = Object.getPrototypeOf(current)
Object.getOwnPropertyNames(proto)
.filter(name => name !== 'constructor' && typeof proto[name] === 'function')
.forEach(name => {
// 这个判断很重要,优先返回自己的属性,不希望被父类的属性覆盖
if (methods[name]) return
methods[name] = proto[name]
})
current = proto
}
return methods
}
function snowflakeId(workerId = 1): string {
// 自定义起始时间
const timestamp = BigInt(Date.now())
const workerBits = BigInt(workerId) << 12n
const sequenceBits = BigInt(Math.floor(Math.random() * 4096))
return String((timestamp << 22n) | workerBits | sequenceBits)
}
function list2map(list: any[], key: string, value?: string): any {
const map: Record<string, any> = {}
if (!list?.length) return map
for (const item of list) {
map[item[key]] = value ? item[value] : item
}
return map
}
function options2map(list: any[]): any {
return list2map(list, 'value', 'label')
}
function map2options(map: Record<number, string>) {
// 将map转换成options
const arr = []
for (const value in map) {
arr.push({
value,
label: map[value],
})
}
return arr
}
export default {
clearNullProps,
getNodesSize,
clearNullProps,
getNodesSize,
assignExits,
filterKeys,
isEqual,
isEqualIgnoreNumberString,
cover,
map2options,
list2map,
coverNullProps,
isKeyValueObject,
isNullOrUndefined,
getAllMethods,
snowflakeId,
options2map,
moveElement,
}

View File

@ -1,6 +1,7 @@
import dayjs from 'dayjs'
import isToday from 'dayjs/plugin/isToday'
import isYesterday from 'dayjs/plugin/isYesterday'
import duration from 'dayjs/plugin/duration'
import db from './db'
const OSS_URL = import.meta.env.VITE_OSS_HOST
@ -8,6 +9,7 @@ const defaultAvatar = `${OSS_URL}/urm/default_avatar.png`
dayjs.extend(isToday)
dayjs.extend(isYesterday)
dayjs.extend(duration)
// store统一设置
export const setCache = (key: string, value: any) => {
if (!key) throw new Error('key is required')
@ -36,6 +38,18 @@ export const ArrToObj = (arr: any[], key = 'code', value = 'value'): { [key: str
return obj
}
export function secondsToHours(s: any) {
let t = Number(s)
if (isNaN(t) || t <= 0) {
return '0'
}
const day = dayjs.duration(t, 's')
return `${String(Math.floor(day.asHours())).padStart(2, '0')}:${String(day.minutes()).padStart(
2,
'0',
)}:${String(day.seconds()).padStart(2, '0')}`
}
// 延迟运行
export const $sleep = (time: number) => {
return new Promise(res => {