kindergarten_java/reading-platform-frontend/src/views/teacher/classes/ClassStudentsView.vue
zhonghua 5cc99d232a fix: 班级学生搜索与平均得分一致性问题
- 前端: 搜索改为调用接口查询,移除 Math.random() 模拟数据
- 前端: 修复搜索图标点击、清空后重新请求
- 后端: StudentResponse 新增 readingCount/lessonCount/avgScore
- 后端: StudentRecordMapper 添加批量统计学生阅读与得分
- 后端: getClassStudents 返回真实统计数据

Made-with: Cursor
2026-03-24 11:22:11 +08:00

485 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="class-students-view">
<div class="page-header">
<a-button type="text" @click="goBack">
<ArrowLeftOutlined />
</a-button>
<div class="header-info">
<h2><TeamOutlined /> 班级学生</h2>
<p class="page-desc" v-if="classInfo">{{ classInfo.name }} - 共 {{ students.length }} 名学生</p>
</div>
</div>
<a-spin :spinning="loading">
<!-- 统计卡片 -->
<div class="stats-cards" v-if="classInfo">
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);">
<TeamOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ classInfo.studentCount }}</div>
<div class="stat-label">学生总数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<BookOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ classInfo.lessonCount }}</div>
<div class="stat-label">授课次数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);">
<ReadOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ totalReadingCount }}</div>
<div class="stat-label">阅读总次数</div>
</div>
</div>
</div>
<!-- 学生列表 -->
<div class="students-section">
<div class="section-header">
<h3>学生列表</h3>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索学生"
style="width: 200px;"
allow-clear
@search="handleSearch"
/>
</div>
<a-table
:columns="columns"
:data-source="students"
:pagination="{ pageSize: 10 }"
row-key="id"
@row="handleRowClick"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="student-name-cell">
<a-avatar :size="32" :style="{ backgroundColor: getAvatarColor(record.id) }">
{{ record.name.charAt(0) }}
</a-avatar>
<span class="name">{{ record.name }}</span>
<span class="gender">{{ record.gender === '男' ? '👦' : record.gender === '女' ? '👧' : '' }}</span>
</div>
</template>
<template v-else-if="column.key === 'readingCount'">
<a-tag color="blue">{{ record.readingCount || 0 }}</a-tag>
</template>
<template v-else-if="column.key === 'lessonCount'">
<a-tag color="orange">{{ record.lessonCount || 0 }}</a-tag>
</template>
<template v-else-if="column.key === 'avgScore'">
<a-progress
:percent="record.avgScore || 0"
:stroke-color="getScoreColor(record.avgScore)"
size="small"
style="width: 80px;"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click.stop="handleShowDetail(record)">
查看详情
</a-button>
</template>
</template>
</a-table>
</div>
</a-spin>
<!-- 学生详情抽屉 -->
<a-drawer
v-model:open="detailDrawerVisible"
title="学生详情"
placement="right"
:width="480"
>
<div class="student-detail" v-if="selectedStudent">
<div class="detail-header">
<a-avatar :size="64" :style="{ backgroundColor: getAvatarColor(selectedStudent.id) }">
{{ selectedStudent.name.charAt(0) }}
</a-avatar>
<div class="detail-info">
<h3>{{ selectedStudent.name }}</h3>
<div class="tags">
<a-tag v-if="selectedStudent.gender">{{ selectedStudent.gender }}</a-tag>
<a-tag color="blue">{{ selectedStudent.readingCount || 0 }} 次阅读</a-tag>
</div>
</div>
</div>
<a-descriptions :column="1" bordered class="detail-descriptions">
<a-descriptions-item label="班级">{{ classInfo?.name }}</a-descriptions-item>
<a-descriptions-item label="生日" v-if="selectedStudent.birthDate">
{{ formatDate(selectedStudent.birthDate) }}
</a-descriptions-item>
<a-descriptions-item label="家长姓名">{{ selectedStudent.parentName || '-' }}</a-descriptions-item>
<a-descriptions-item label="家长电话">{{ selectedStudent.parentPhone || '-' }}</a-descriptions-item>
<a-descriptions-item label="入学时间">{{ formatDate(selectedStudent.createdAt) }}</a-descriptions-item>
</a-descriptions>
<!-- 阅读统计 -->
<div class="detail-stats">
<h4>阅读统计</h4>
<div class="stats-grid">
<div class="stats-item">
<span class="value">{{ selectedStudent.lessonCount || 0 }}</span>
<span class="label">上课次数</span>
</div>
<div class="stats-item">
<span class="value">{{ selectedStudent.readingCount || 0 }}</span>
<span class="label">阅读次数</span>
</div>
<div class="stats-item">
<span class="value">{{ selectedStudent.avgScore || 0 }}%</span>
<span class="label">平均得分</span>
</div>
</div>
</div>
<!-- 最近阅读记录 -->
<div class="recent-records">
<h4>最近阅读记录</h4>
<div class="records-list" v-if="recentRecords.length > 0">
<div v-for="record in recentRecords" :key="record.id" class="record-item">
<div class="record-course">{{ record.lesson?.course?.name || '未知课程' }}</div>
<div class="record-date">{{ formatDateTime(record.lesson?.startDatetime) }}</div>
<div class="record-score">
<a-rate :value="(record.focus || 0 + record.participation || 0) / 2" disabled allow-half style="font-size: 12px;" />
</div>
</div>
</div>
<div class="empty-records" v-else>
<p>暂无阅读记录</p>
</div>
</div>
</div>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import {
ArrowLeftOutlined,
TeamOutlined,
BookOutlined,
ReadOutlined,
} from '@ant-design/icons-vue';
import { getTeacherClassStudents } from '@/api/teacher';
import type { Student, ClassInfo } from '@/api/school';
import dayjs from 'dayjs';
const router = useRouter();
const route = useRoute();
const classId = computed(() => Number(route.params.id));
const loading = ref(false);
const classInfo = ref<ClassInfo | null>(null);
const students = ref<Student[]>([]);
const searchKeyword = ref('');
const detailDrawerVisible = ref(false);
const selectedStudent = ref<Student | null>(null);
const recentRecords = ref<any[]>([]);
const avatarColors = ['#FF8C42', '#667eea', '#f093fb', '#4facfe', '#43e97b', '#faad14', '#eb2f96'];
const getAvatarColor = (id: number) => avatarColors[id % avatarColors.length];
const columns = [
{ title: '姓名', key: 'name', dataIndex: 'name' },
{ title: '阅读次数', key: 'readingCount', dataIndex: 'readingCount', width: 100 },
{ title: '上课次数', key: 'lessonCount', dataIndex: 'lessonCount', width: 100 },
{ title: '平均得分', key: 'avgScore', width: 120 },
{ title: '操作', key: 'action', width: 100 },
];
const totalReadingCount = computed(() => {
return students.value.reduce((sum, s) => sum + (s.readingCount || 0), 0);
});
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD');
const formatDateTime = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm');
const getScoreColor = (score: number) => {
if (score >= 80) return '#52c41a';
if (score >= 60) return '#faad14';
return '#ff4d4f';
};
const goBack = () => router.back();
const handleRowClick = (record: Record<string, unknown>, _index: number) => ({
onClick: () => showStudentDetail(record as unknown as Student),
});
const handleShowDetail = (record: unknown) => showStudentDetail(record as unknown as Student);
const showStudentDetail = async (student: Student) => {
selectedStudent.value = student;
detailDrawerVisible.value = true;
// 加载最近阅读记录
try {
// 这里应该调用获取学生阅读记录的API
recentRecords.value = [];
} catch (error) {
console.error('Failed to load student records:', error);
}
};
const loadStudents = async (params?: { keyword?: string }) => {
loading.value = true;
try {
const data = await getTeacherClassStudents(classId.value, {
keyword: params?.keyword,
});
classInfo.value = data.class ? {
id: data.class.id,
name: data.class.name,
grade: data.class.grade,
studentCount: data.class.studentCount,
lessonCount: data.class.lessonCount,
} as any : null;
students.value = data.items.map((s: any) => ({
...s,
// 使用接口返回的 avgScore若未提供则显示 0后端需在班级学生接口中计算并返回
avgScore: s.avgScore ?? 0,
}));
} catch (error: any) {
message.error(error.message || '加载失败');
} finally {
loading.value = false;
}
};
const handleSearch = (value?: string) => {
const keyword = (value ?? searchKeyword.value)?.trim() || undefined;
loadStudents({ keyword });
};
watch(searchKeyword, (newVal, oldVal) => {
if (newVal === '' && oldVal !== undefined && oldVal !== '') {
loadStudents();
}
});
onMounted(() => {
loadStudents();
});
</script>
<style scoped lang="scss">
$primary-color: #FF8C42;
$primary-light: #FFF4EC;
.class-students-view {
.page-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
.header-info {
h2 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.page-desc {
margin: 4px 0 0;
color: #999;
font-size: 14px;
}
}
}
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.stat-content {
.stat-value {
font-size: 28px;
font-weight: 600;
color: #333;
}
.stat-label {
font-size: 14px;
color: #999;
}
}
}
}
.students-section {
background: white;
border-radius: 12px;
padding: 20px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
}
}
.student-name-cell {
display: flex;
align-items: center;
gap: 8px;
.name {
font-weight: 500;
}
.gender {
font-size: 16px;
}
}
}
}
// 学生详情抽屉
.student-detail {
.detail-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
.detail-info {
h3 {
margin: 0 0 8px;
font-size: 20px;
}
.tags {
display: flex;
gap: 8px;
}
}
}
.detail-descriptions {
margin-bottom: 24px;
}
.detail-stats {
margin-bottom: 24px;
h4 {
margin: 0 0 16px;
font-size: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
.stats-item {
text-align: center;
padding: 16px;
background: #fafafa;
border-radius: 8px;
.value {
display: block;
font-size: 24px;
font-weight: 600;
color: $primary-color;
}
.label {
font-size: 12px;
color: #999;
}
}
}
}
.recent-records {
h4 {
margin: 0 0 16px;
font-size: 16px;
}
.records-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.record-item {
display: flex;
align-items: center;
padding: 12px;
background: #fafafa;
border-radius: 8px;
.record-course {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.record-date {
font-size: 12px;
color: #999;
margin-right: 12px;
}
}
.empty-records {
text-align: center;
padding: 24px;
color: #999;
}
}
}
</style>