kindergarten/reading-platform-frontend/src/views/school/students/StudentListView.vue
zhonghua bab12cbed3 fix(school): 统一学校端列表页搜索框为整体样式,与课程管理页一致
优化内容:
- 参考课程管理页实现,将各页 a-input-search 改为 :enter-button="false",
  使搜索图标在输入框内与边框一体,视觉为一个整体
- 教师/学生/家长/班级管理:移除自定义 #prefix(SearchOutlined),
  避免双放大镜或图标与输入框分离,并移除未使用的 SearchOutlined 导入
- 家长管理:主列表搜索 + 弹窗内学生搜索均使用一体式搜索框
- 课程管理:授权弹窗内课程搜索去掉 prefix,增加 allow-clear 与 :enter-button="false"
- 校本课程包、阅读任务、课程反馈、成长档案、任务模板:为搜索框增加 :enter-button="false"

涉及页面:
- 教师管理 TeacherListView
- 学生管理 StudentListView
- 家长管理 ParentListView
- 班级管理 ClassListView
- 课程管理 CourseListView(含授权弹窗)
- 校本课程包 SchoolCourseListView
- 阅读任务 TaskListView
- 课程反馈 FeedbackView
- 成长档案 GrowthRecordView
- 任务模板 TaskTemplateView

Made-with: Cursor
2026-03-06 11:32:05 +08:00

778 lines
28 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="min-h-100vh bg-[linear-gradient(180deg,#FFF8F0_0%,#FFFFFF_100%)] px-4 py-4 md:px-6 md:py-6">
<!-- 页面头部 -->
<div class="rounded-[20px] py-6 px-8 mb-6 bg-[linear-gradient(135deg,#f093fb_0%,#f5576c_100%)]">
<div class="flex justify-between items-center max-md:flex-col max-md:items-start max-md:gap-4">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-2xl flex items-center justify-center bg-white/20 text-[32px] text-white">
<TeamOutlined />
</div>
<div>
<h2 class="text-white text-2xl font-700 m-0">学生管理</h2>
<p class="text-white/80 text-sm mt-1 m-0">管理学校学生信息与成长记录</p>
</div>
</div>
<div class="flex gap-8 max-md:w-full max-md:justify-center">
<div class="text-center stat-item">
<span class="block text-[28px] font-700 text-white">{{ pagination.total }}</span>
<span class="text-xs text-white/80">学生总数</span>
</div>
<div class="text-center stat-item flex flex-col items-center gap-0.5">
<span class="block text-[28px] font-700 text-[#4FC3F7]">{{ boysCount }}</span>
<span class="text-xs text-white/80 flex items-center gap-1">
<UserOutlined class="text-sm text-[#4FC3F7]" /> 男生
</span>
</div>
<div class="text-center stat-item flex flex-col items-center gap-0.5">
<span class="block text-[28px] font-700 text-[#FFD93D]">{{ girlsCount }}</span>
<span class="text-xs text-white/80 flex items-center gap-1">
<UserOutlined class="text-sm text-[#FFD93D]" /> 女生
</span>
</div>
</div>
</div>
</div>
<!-- 操作栏 -->
<div
class="flex justify-between items-center mb-6 py-4 px-5 bg-white rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.04)] max-md:flex-col max-md:gap-3">
<div class="flex gap-3 max-md:w-full flex-wrap filters">
<a-select v-model:value="selectedClassId" placeholder="选择班级" class="w-[150px]" @change="handleClassChange"
allow-clear>
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }}
</a-select-option>
</a-select>
<a-input-search v-model:value="searchKeyword" placeholder="搜索学生姓名/家长" class="w-[220px] search-input-wrap"
@search="handleSearch" allow-clear :enter-button="false" />
</div>
<div class="flex gap-3 max-md:w-full flex-wrap actions">
<a-button
class="rounded-xl h-10 border-2 border-[#f093fb] text-[#f5576c] hover:bg-[#FFF0F5] hover:border-[#f5576c] hover:text-[#f5576c]"
@click="showImportModal">
<DownloadOutlined class="mr-2" />
批量导入
</a-button>
<a-button type="primary"
class="!bg-[linear-gradient(135deg,#f093fb_0%,#f5576c_100%)] hover:!bg-[linear-gradient(135deg,#e080e8_0%,#e04a5d_100%)] !border-0 rounded-xl h-10 px-6 font-600"
@click="showAddModal">
<PlusOutlined class="mr-2" />
添加学生
</a-button>
</div>
</div>
<!-- 学生卡片网格 -->
<div class="grid gap-5 mb-6 grid-cols-1 md:grid-cols-[repeat(auto-fill,minmax(280px,1fr))]"
v-if="!loading && students.length > 0">
<div v-for="student in students" :key="student.id"
class="bg-white rounded-2xl overflow-hidden shadow-[0_4px_12px_rgba(0,0,0,0.05)] transition-all duration-300 border-2 border-transparent hover:translate-y-[-4px] hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] hover:border-[#f093fb]">
<div
class="flex items-center gap-3 py-4 px-5 bg-[linear-gradient(135deg,#F8F9FA_0%,#FFFFFF_100%)] border-b border-[#F0F0F0]">
<div class="w-12 h-12 rounded-full flex items-center justify-center text-2xl text-white"
:class="normalizeGender(student.gender) === '男' ? 'bg-[linear-gradient(135deg,#4FC3F7_0%,#29B6F6_100%)]' : 'bg-[linear-gradient(135deg,#f093fb_0%,#f5576c_100%)]'">
<UserOutlined />
</div>
<div class="flex-1 min-w-0">
<div class="text-base font-600 text-[#2D3436]">{{ student.name }}</div>
<div class="text-xs text-[#636E72] mt-0.5">{{ student.className }}</div>
</div>
</div>
<div class="py-4 px-5 card-body">
<div class="flex items-center gap-2 mb-2 text-[13px]">
<CalendarOutlined class="text-sm text-[#f5576c] w-5" />
<span class="text-[#636E72] flex-1">{{ calculateAge(student.birthDate) || '--' }}{{ student.birthDate ? '岁'
: '' }}</span>
<span class="py-0.5 px-2 rounded-[10px] text-[11px]"
:class="normalizeGender(student.gender) === '男' ? 'bg-[#E3F2FD] text-[#1976D2]' : 'bg-[#FCE4EC] text-[#E91E63]'">
{{ normalizeGender(student.gender) }}
</span>
</div>
<div class="flex items-center gap-2 mb-2 text-[13px]">
<TeamOutlined class="text-sm text-[#f5576c] w-5" />
<span class="text-[#636E72]">{{ student.parentName || '未设置' }}</span>
</div>
<div class="flex items-center gap-2 mb-2 text-[13px]">
<PhoneOutlined class="text-sm text-[#f5576c] w-5" />
<span class="text-[#636E72]">{{ student.parentPhone || '未设置' }}</span>
</div>
<div class="flex items-center gap-2 text-[13px]">
<BookOutlined class="text-sm text-[#f5576c] w-5" />
<span class="text-[#636E72]">参与课程 <strong class="text-[#f5576c]">{{ student.lessonCount || 0 }}</strong>
次</span>
</div>
</div>
<div class="flex justify-end gap-2 py-3 px-5 border-t border-[#F0F0F0] bg-[#FAFAFA] card-actions">
<a-button type="link" size="small" @click="handleEdit(student)"
class="!py-1 !px-2 !h-auto inline-flex items-center gap-1">
<EditOutlined /> 编辑
</a-button>
<a-button type="link" size="small" @click="handleTransfer(student)"
class="!py-1 !px-2 !h-auto inline-flex items-center gap-1">
<SwapOutlined /> 调班
</a-button>
<a-popconfirm title="确定要删除这位学生吗?" @confirm="handleDelete(student.id)">
<a-button type="link" size="small" danger class="!py-1 !px-2 !h-auto">
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</div>
</div>
</div>
<!-- 空状态 -->
<div class="flex flex-col items-center justify-center py-20 bg-white rounded-2xl empty-state"
v-if="!loading && students.length === 0">
<div
class="w-20 h-20 rounded-full flex items-center justify-center mb-4 text-[40px] text-[#f5576c] bg-[linear-gradient(135deg,#FFF0F5_0%,#FCE4EC_100%)]">
<InboxOutlined />
</div>
<p class="text-[#636E72] text-base mb-6">暂无学生数据</p>
<a-button type="primary" class="!bg-[linear-gradient(135deg,#f093fb_0%,#f5576c_100%)] !border-0 rounded-xl"
@click="showAddModal">
添加第一位学生
</a-button>
</div>
<!-- 加载状态 -->
<div class="flex flex-col items-center justify-center py-20 loading-state" v-if="loading">
<a-spin size="large" />
<p class="text-[#636E72] mt-4">加载中...</p>
</div>
<!-- 分页 -->
<div class="flex justify-center py-6 bg-white rounded-2xl pagination-wrapper" v-if="students.length > 0">
<a-pagination v-model:current="pagination.current" v-model:pageSize="pagination.pageSize"
:total="pagination.total" :show-size-changer="true" :show-total="(total: number) => `共 ${total} 条`"
@change="handlePageChange" />
</div>
<!-- 添加/编辑学生模态框 -->
<a-modal v-model:open="modalVisible" @ok="handleModalOk" @cancel="handleModalCancel" :confirm-loading="submitting"
:width="520">
<template #title>
<span class="flex items-center gap-2">
<component :is="isEdit ? EditOutlined : PlusOutlined" class="text-[#f5576c]" />
{{ isEdit ? '编辑学生' : '添加学生' }}
</span>
</template>
<a-form ref="formRef" :model="formState" :rules="rules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="姓名" name="name">
<a-input v-model:value="formState.name" placeholder="请输入学生姓名">
<template #prefix>
<UserOutlined class="text-[#B2BEC3]" />
</template>
</a-input>
</a-form-item>
<a-form-item label="性别" name="gender">
<a-radio-group v-model:value="formState.gender">
<a-radio value="男">
<UserOutlined class="mr-1 text-[#4FC3F7]" /> 男孩
</a-radio>
<a-radio value="女">
<UserOutlined class="mr-1 text-[#f5576c]" /> 女孩
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="出生日期" name="birthDate">
<a-date-picker v-model:value="formState.birthDate" class="w-full" value-format="YYYY-MM-DD"
placeholder="选择出生日期" />
</a-form-item>
<a-form-item label="所在班级" name="classId">
<a-select v-model:value="formState.classId" placeholder="请选择班级" :loading="classesLoading">
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="家长姓名" name="parentName">
<a-input v-model:value="formState.parentName" placeholder="请输入家长姓名">
<template #prefix>
<TeamOutlined class="text-[#B2BEC3]" />
</template>
</a-input>
</a-form-item>
<a-form-item label="家长电话" name="parentPhone">
<a-input v-model:value="formState.parentPhone" placeholder="请输入家长电话">
<template #prefix>
<PhoneOutlined class="text-[#B2BEC3]" />
</template>
</a-input>
</a-form-item>
</a-form>
</a-modal>
<!-- 学生调班模态框 -->
<a-modal v-model:open="transferModalVisible" title="学生调班" :confirm-loading="transferSubmitting"
@ok="handleTransferSubmit" @cancel="transferModalVisible = false" width="480px">
<div class="py-2 transfer-modal-content">
<div class="py-3 px-4 bg-[#F8F9FA] rounded-lg mb-4 current-info">
<span>当前学生:</span>
<strong class="text-[#f5576c]">{{ selectedTransferStudent?.name }}</strong>
<span class="text-[#888] text-xs">{{ selectedTransferStudent?.className }}</span>
</div>
<a-form layout="vertical">
<a-form-item label="目标班级" required>
<a-select v-model:value="transferTargetClassId" placeholder="请选择目标班级" :loading="classesLoading">
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }} ({{ cls.grade }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="调班原因">
<a-textarea v-model:value="transferReason" placeholder="请输入调班原因(选填)" :rows="3" />
</a-form-item>
</a-form>
<div class="mt-4 pt-4 border-t border-[#F0F0F0] transfer-history" v-if="transferHistory.length > 0">
<div class="flex items-center gap-2 font-600 text-[#636E72] mb-3 history-header">
<HistoryOutlined /> 调班历史
</div>
<div class="max-h-[150px] overflow-y-auto history-list">
<div v-for="h in transferHistory" :key="h.id"
class="flex items-center gap-2 py-2 px-3 bg-[#F8F9FA] rounded-md mb-2 text-[13px] history-item">
<span class="text-[#888]">{{ h.fromClass?.name || '无' }}</span>
<span class="text-[#B2BEC3]">→</span>
<span class="text-[#2D3436] font-500">{{ h.toClass.name }}</span>
<span class="ml-auto text-[#B2BEC3] text-[11px]">{{ formatDate(h.createdAt) }}</span>
</div>
</div>
</div>
</div>
</a-modal>
<!-- 批量导入模态框 -->
<a-modal v-model:open="importModalVisible" :footer="null" width="560px">
<template #title>
<span class="flex items-center gap-2">
<DownloadOutlined class="text-[#f5576c]" />
批量导入学生
</span>
</template>
<div class="flex flex-col gap-4 import-content">
<div class="p-4 bg-[#FFF8F0] rounded-xl import-tips">
<div class="flex items-center gap-2 font-600 text-[#FF8C42] mb-2">
<SolutionOutlined class="text-lg" />
<span>导入说明</span>
</div>
<ol class="m-0 pl-5 text-[#636E72] text-[13px] tips-list">
<li v-for="(note, index) in templateNotes" :key="index" class="mb-1">{{ note }}</li>
</ol>
</div>
<a-button
class="rounded-xl border-2 border-dashed border-[#f093fb] text-[#f5576c] hover:border-[#f5576c] hover:text-[#f5576c] hover:bg-[#FFF0F5]"
@click="downloadTemplate">
<DownloadOutlined class="mr-2" />
下载导入模板
</a-button>
<a-upload-dragger :before-upload="beforeUpload" :show-upload-list="false" accept=".xlsx,.xls,.csv"
class="upload-area rounded-xl">
<div class="py-6 text-center upload-content">
<FileAddOutlined class="block text-[48px] text-[#f5576c] mb-3" />
<p class="text-sm text-[#636E72] mb-1">点击或拖拽文件到此区域上传</p>
<p class="text-xs text-[#B2BEC3]">支持 .xlsx, .xls, .csv 格式</p>
</div>
</a-upload-dragger>
<div v-if="importFile" class="flex items-center gap-2 py-3 px-4 bg-[#F0F0F0] rounded-lg file-info">
<FileOutlined class="text-xl text-[#f5576c]" />
<span class="flex-1 text-[#636E72] text-[13px]">{{ importFile.name }}</span>
<a-button type="link" size="small" @click="importFile = null">移除</a-button>
</div>
<div v-if="importFile" class="mt-2 default-class-select">
<label class="block mb-2 text-[13px] text-[#636E72]">默认班级(用于未指定班级的学生)</label>
<a-select v-model:value="importDefaultClassId" placeholder="选择默认班级" class="w-full" allow-clear>
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }}
</a-select-option>
</a-select>
</div>
<a-button type="primary" :loading="importing" :disabled="!importFile" @click="handleImport" block
class="h-11 rounded-xl text-[15px] font-600 !bg-[linear-gradient(135deg,#f093fb_0%,#f5576c_100%)] hover:!bg-[linear-gradient(135deg,#e080e8_0%,#e04a5d_100%)] !border-0">
<template v-if="!importing">
<RocketOutlined class="mr-2" />
开始导入
</template>
<span v-else>导入中...</span>
</a-button>
<div v-if="importResult" class="p-4 rounded-xl import-result"
:class="importResult.failed === 0 ? 'bg-[#E8F5E9]' : 'bg-[#FFF8E1]'">
<div class="flex items-center gap-2 font-600 mb-3 result-header">
<component :is="importResult.failed === 0 ? CheckCircleOutlined : WarningOutlined" class="text-xl"
:class="importResult.failed === 0 ? 'text-[#43A047]' : 'text-[#FF8C42]'" />
<span>导入完成</span>
</div>
<div class="flex gap-4 mb-3 result-stats">
<span class="text-[13px]" :class="importResult.failed === 0 ? 'text-[#43A047]' : ''">成功 {{
importResult.success
}} 条</span>
<span class="text-[13px] text-[#E53935]">失败 {{ importResult.failed }} 条</span>
</div>
<div v-if="importResult.errors.length > 0"
class="max-h-[150px] overflow-y-auto py-3 px-3 bg-white rounded-lg text-xs text-[#636E72] result-errors">
<p v-for="(error, index) in importResult.errors" :key="index" class="my-1">
第 {{ error.row }} 行:{{ error.message }}
</p>
</div>
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import {
TeamOutlined,
UserOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
UploadOutlined,
DownloadOutlined,
CalendarOutlined,
PhoneOutlined,
BookOutlined,
InboxOutlined,
FileOutlined,
RocketOutlined,
CheckCircleOutlined,
WarningOutlined,
FileAddOutlined,
SolutionOutlined,
SwapOutlined,
HistoryOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import type { FormInstance, UploadProps } from 'ant-design-vue';
import {
getStudents,
createStudent,
updateStudent,
deleteStudent,
getClasses,
getStudentImportTemplate,
importStudents,
transferStudent,
getStudentClassHistory,
} from '@/api/school';
import type { Student, CreateStudentDto, ClassInfo, ImportResult, StudentClassHistory, TransferStudentDto } from '@/api/school';
import dayjs from 'dayjs';
const loading = ref(false);
const classesLoading = ref(false);
const submitting = ref(false);
const modalVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
const selectedClassId = ref<number | undefined>();
const searchKeyword = ref('');
const columns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '性别', dataIndex: 'gender', key: 'gender' },
{ title: '年龄', key: 'age' },
{ title: '所在班级', dataIndex: 'className', key: 'className' },
{ title: '家长姓名', dataIndex: 'parentName', key: 'parentName' },
{ title: '家长电话', dataIndex: 'parentPhone', key: 'parentPhone' },
{ title: '参与课程', dataIndex: 'lessonCount', key: 'lessonCount' },
{ title: '操作', key: 'action', width: 150 },
];
const pagination = reactive({
current: 1,
pageSize: 12,
total: 0,
showSizeChanger: true,
showTotal: (total: number) => `${total}`,
});
const students = ref<Student[]>([]);
const classes = ref<ClassInfo[]>([]);
// 标准化性别显示
const normalizeGender = (gender?: string): string => {
if (!gender) return '未知';
const normalized = gender.toUpperCase();
if (normalized === 'MALE' || gender === '男') return '男';
if (normalized === 'FEMALE' || gender === '女') return '女';
return gender;
};
const boysCount = computed(() => students.value.filter(s => {
const g = s.gender?.toUpperCase();
return g === 'MALE' || s.gender === '男';
}).length);
const girlsCount = computed(() => students.value.filter(s => {
const g = s.gender?.toUpperCase();
return g === 'FEMALE' || s.gender === '女';
}).length);
interface FormState {
id?: number;
name: string;
gender: string;
birthDate?: string | null;
classId: number;
parentName: string;
parentPhone: string;
}
const formState = reactive<FormState>({
name: '',
gender: '男',
birthDate: null,
classId: 0,
parentName: '',
parentPhone: '',
});
const rules: Record<string, any[]> = {
name: [{ required: true, message: '请输入学生姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
classId: [{ required: true, message: '请选择班级', trigger: 'change', type: 'number' }],
};
const calculateAge = (birthDate?: string | null): number | null => {
if (!birthDate) return null;
const birth = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age >= 0 ? age : null;
};
const loadStudents = async () => {
loading.value = true;
try {
const result = await getStudents({
page: pagination.current,
pageSize: pagination.pageSize,
classId: selectedClassId.value,
keyword: searchKeyword.value || undefined,
});
students.value = result.items;
pagination.total = result.total;
} catch (error) {
console.error('Failed to load students:', error);
} finally {
loading.value = false;
}
};
const loadClasses = async () => {
classesLoading.value = true;
try {
classes.value = await getClasses();
} catch (error) {
console.error('Failed to load classes:', error);
} finally {
classesLoading.value = false;
}
};
const handlePageChange = (page: number, pageSize: number) => {
pagination.current = page;
pagination.pageSize = pageSize;
loadStudents();
};
const handleClassChange = () => {
pagination.current = 1;
loadStudents();
};
const handleSearch = () => {
pagination.current = 1;
loadStudents();
};
const resetForm = () => {
formState.id = undefined;
formState.name = '';
formState.gender = '男';
formState.birthDate = null;
formState.classId = 0;
formState.parentName = '';
formState.parentPhone = '';
};
const showAddModal = () => {
isEdit.value = false;
resetForm();
modalVisible.value = true;
};
const handleEdit = (record: Student) => {
isEdit.value = true;
formState.id = record.id;
formState.name = record.name;
formState.gender = record.gender || '男';
formState.birthDate = record.birthDate ? dayjs(record.birthDate).format('YYYY-MM-DD') : null;
formState.classId = record.classId;
formState.parentName = record.parentName || '';
formState.parentPhone = record.parentPhone || '';
modalVisible.value = true;
};
const handleModalOk = async () => {
try {
await formRef.value?.validate();
submitting.value = true;
if (isEdit.value && formState.id) {
await updateStudent(formState.id, {
name: formState.name,
gender: formState.gender,
birthDate: formState.birthDate,
classId: formState.classId,
parentName: formState.parentName,
parentPhone: formState.parentPhone,
});
message.success('更新成功');
} else {
await createStudent({
name: formState.name,
gender: formState.gender,
birthDate: formState.birthDate,
classId: formState.classId,
parentName: formState.parentName,
parentPhone: formState.parentPhone,
});
message.success('添加成功');
}
modalVisible.value = false;
loadStudents();
} catch (error: any) {
if (error?.response?.data?.message) {
message.error(error.response.data.message);
}
} finally {
submitting.value = false;
}
};
const handleModalCancel = () => {
modalVisible.value = false;
formRef.value?.resetFields();
};
const handleDelete = async (id: number) => {
try {
await deleteStudent(id);
message.success('删除成功');
loadStudents();
} catch (error: any) {
message.error(error?.response?.data?.message || '删除失败');
}
};
// ==================== 批量导入相关 ====================
const importModalVisible = ref(false);
const importFile = ref<File | null>(null);
const importDefaultClassId = ref<number | undefined>();
const importing = ref(false);
const importResult = ref<ImportResult | null>(null);
const templateNotes = ref<string[]>([
'姓名为必填项',
'性别可选:男/女,默认为男',
'出生日期格式YYYY-MM-DD',
'班级ID为必填项可在班级管理中查看',
'家长姓名和家长电话为选填项',
]);
const showImportModal = () => {
importFile.value = null;
importDefaultClassId.value = undefined;
importResult.value = null;
importModalVisible.value = true;
};
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isValidType = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
].includes(file.type) || file.name.endsWith('.xlsx') || file.name.endsWith('.xls') || file.name.endsWith('.csv');
if (!isValidType) {
message.error('只能上传 Excel 或 CSV 文件!');
return false;
}
importFile.value = file;
importResult.value = null;
return false;
};
const downloadTemplate = async () => {
try {
const templateData = await getStudentImportTemplate();
const csvContent = [
templateData.headers.join(','),
templateData.example.join(','),
].join('\n');
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = '学生导入模板.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('模板下载成功');
} catch (error) {
const defaultHeaders = ['姓名', '性别', '出生日期', '班级ID', '家长姓名', '家长电话'];
const defaultExample = ['张小明', '男', '2020-01-15', '1', '张三', '13800138000'];
const csvContent = [
defaultHeaders.join(','),
defaultExample.join(','),
].join('\n');
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = '学生导入模板.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('模板下载成功');
}
};
const handleImport = async () => {
if (!importFile.value) {
message.warning('请先选择要导入的文件');
return;
}
importing.value = true;
importResult.value = null;
try {
const result = await importStudents(importFile.value, importDefaultClassId.value);
importResult.value = result;
if (result.success > 0) {
loadStudents();
loadClasses();
}
} catch (error: any) {
message.error(error?.response?.data?.message || '导入失败');
} finally {
importing.value = false;
}
};
// ==================== 学生调班相关 ====================
const transferModalVisible = ref(false);
const transferSubmitting = ref(false);
const selectedTransferStudent = ref<Student | null>(null);
const transferTargetClassId = ref<number | undefined>();
const transferReason = ref('');
const transferHistory = ref<StudentClassHistory[]>([]);
const handleTransfer = async (student: Student) => {
selectedTransferStudent.value = student;
transferTargetClassId.value = undefined;
transferReason.value = '';
transferModalVisible.value = true;
// 加载调班历史
try {
transferHistory.value = await getStudentClassHistory(student.id);
} catch (error) {
console.error('Failed to load transfer history:', error);
transferHistory.value = [];
}
};
const handleTransferSubmit = async () => {
if (!transferTargetClassId.value) {
message.warning('请选择目标班级');
return;
}
if (transferTargetClassId.value === selectedTransferStudent.value?.classId) {
message.warning('目标班级与当前班级相同');
return;
}
transferSubmitting.value = true;
try {
const dto: TransferStudentDto = {
toClassId: transferTargetClassId.value,
reason: transferReason.value || undefined,
};
await transferStudent(selectedTransferStudent.value!.id, dto);
message.success('调班成功');
transferModalVisible.value = false;
loadStudents();
loadClasses();
} catch (error: any) {
message.error(error?.response?.data?.message || '调班失败');
} finally {
transferSubmitting.value = false;
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};
onMounted(() => {
loadStudents();
loadClasses();
});
</script>
<style scoped>
.filters :deep(.ant-select-selector) {
border-radius: 12px;
border: 2px solid #F0F0F0;
}
.filters :deep(.ant-select-selector:hover),
.filters :deep(.ant-input-affix-wrapper:hover) {
border-color: #f093fb;
}
.card-actions :deep(.ant-btn-link) {
padding: 4px 8px;
height: auto;
}
.upload-area :deep(.ant-upload-drag) {
border-radius: 12px;
border: 2px dashed #E0E0E0;
background: #FAFAFA;
}
.upload-area :deep(.ant-upload-drag:hover) {
border-color: #f093fb;
}
</style>