kindergarten_java/reading-platform-frontend/src/views/teacher/classes/ClassStudentsView.vue
Claude Opus 4.6 4e13f186f3 fix: 统一修改错误处理逻辑
- 将所有 error.response?.data?.message 改为 error.message
- 影响所有教师端组件的错误处理
- 适配新的响应拦截器返回的错误对象结构

修改的文件:
- CourseListView.vue
- CourseDetailView.vue
- PrepareModeView.vue
- LessonListView.vue
- LessonView.vue
- LessonRecordsView.vue
- SchoolCourseEditView.vue
- ClassListView.vue
- ClassStudentsView.vue
- TaskListView.vue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:33:44 +08:00

474 lines
13 KiB
Vue

<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
/>
</div>
<a-table
:columns="columns"
:data-source="filteredStudents"
: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="showStudentDetail(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 } 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, getStudentRecords } from '@/api/teacher';
import type { Student } 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 filteredStudents = computed(() => {
if (!searchKeyword.value) return students.value;
const keyword = searchKeyword.value.toLowerCase();
return students.value.filter(s => s.name.toLowerCase().includes(keyword));
});
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: Student) => {
showStudentDetail(record);
};
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 () => {
loading.value = true;
try {
const data = await getTeacherClassStudents(classId.value);
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: Math.round(Math.random() * 40 + 60), // 临时模拟数据
}));
} catch (error: any) {
message.error(error.message || '加载失败');
} finally {
loading.value = false;
}
};
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>