优化内容: - 参考课程管理页实现,将各页 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
778 lines
28 KiB
Vue
778 lines
28 KiB
Vue
<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>
|