1474 lines
34 KiB
Vue
1474 lines
34 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="student-list-view">
|
|||
|
|
<!-- 页面头部 -->
|
|||
|
|
<div class="page-header">
|
|||
|
|
<div class="header-content">
|
|||
|
|
<div class="header-title">
|
|||
|
|
<div class="title-icon">
|
|||
|
|
<TeamOutlined />
|
|||
|
|
</div>
|
|||
|
|
<div class="title-text">
|
|||
|
|
<h2>学生管理</h2>
|
|||
|
|
<p>管理学校学生信息与成长记录</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="header-stats">
|
|||
|
|
<div class="stat-item">
|
|||
|
|
<span class="stat-value">{{ pagination.total }}</span>
|
|||
|
|
<span class="stat-label">学生总数</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="stat-item">
|
|||
|
|
<span class="stat-value active">{{ boysCount }}</span>
|
|||
|
|
<span class="stat-label stat-label-with-icon">
|
|||
|
|
<UserOutlined class="stat-icon boy" />
|
|||
|
|
男生
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="stat-item">
|
|||
|
|
<span class="stat-value pink">{{ girlsCount }}</span>
|
|||
|
|
<span class="stat-label stat-label-with-icon">
|
|||
|
|
<UserOutlined class="stat-icon girl" />
|
|||
|
|
女生
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 操作栏 -->
|
|||
|
|
<div class="action-bar">
|
|||
|
|
<div class="filters">
|
|||
|
|
<a-select
|
|||
|
|
v-model:value="selectedClassId"
|
|||
|
|
placeholder="选择班级"
|
|||
|
|
style="width: 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="搜索学生姓名/家长"
|
|||
|
|
style="width: 220px;"
|
|||
|
|
@search="handleSearch"
|
|||
|
|
allow-clear
|
|||
|
|
>
|
|||
|
|
<template #prefix>
|
|||
|
|
<SearchOutlined style="color: #B2BEC3;" />
|
|||
|
|
</template>
|
|||
|
|
</a-input-search>
|
|||
|
|
</div>
|
|||
|
|
<div class="actions">
|
|||
|
|
<a-button class="import-btn" @click="showImportModal">
|
|||
|
|
<DownloadOutlined class="btn-icon" />
|
|||
|
|
批量导入
|
|||
|
|
</a-button>
|
|||
|
|
<a-button type="primary" class="add-btn" @click="showAddModal">
|
|||
|
|
<PlusOutlined class="btn-icon" />
|
|||
|
|
添加学生
|
|||
|
|
</a-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 学生卡片网格 -->
|
|||
|
|
<div class="student-grid" v-if="!loading && students.length > 0">
|
|||
|
|
<div
|
|||
|
|
v-for="student in students"
|
|||
|
|
:key="student.id"
|
|||
|
|
class="student-card"
|
|||
|
|
>
|
|||
|
|
<div class="card-header">
|
|||
|
|
<div class="student-avatar" :class="normalizeGender(student.gender) === '男' ? 'boy' : 'girl'">
|
|||
|
|
<UserOutlined class="avatar-icon" />
|
|||
|
|
</div>
|
|||
|
|
<div class="student-basic">
|
|||
|
|
<div class="student-name">{{ student.name }}</div>
|
|||
|
|
<div class="student-class">{{ student.className }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="info-row">
|
|||
|
|
<CalendarOutlined class="info-icon" />
|
|||
|
|
<span class="info-value">{{ calculateAge(student.birthDate) || '--' }}{{ student.birthDate ? '岁' : '' }}</span>
|
|||
|
|
<span class="gender-tag" :class="normalizeGender(student.gender) === '男' ? 'boy' : 'girl'">
|
|||
|
|
{{ normalizeGender(student.gender) }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="info-row">
|
|||
|
|
<TeamOutlined class="info-icon" />
|
|||
|
|
<span class="info-value">{{ student.parentName || '未设置' }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="info-row">
|
|||
|
|
<PhoneOutlined class="info-icon" />
|
|||
|
|
<span class="info-value">{{ student.parentPhone || '未设置' }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="info-row">
|
|||
|
|
<BookOutlined class="info-icon" />
|
|||
|
|
<span class="info-value">参与课程 <strong>{{ student.lessonCount || 0 }}</strong> 次</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card-actions">
|
|||
|
|
<a-button type="link" size="small" @click="handleEdit(student)">
|
|||
|
|
<EditOutlined /> 编辑
|
|||
|
|
</a-button>
|
|||
|
|
<a-button type="link" size="small" @click="handleTransfer(student)">
|
|||
|
|
<SwapOutlined /> 调班
|
|||
|
|
</a-button>
|
|||
|
|
<a-popconfirm
|
|||
|
|
title="确定要删除这位学生吗?"
|
|||
|
|
@confirm="handleDelete(student.id)"
|
|||
|
|
>
|
|||
|
|
<a-button type="link" size="small" danger>
|
|||
|
|
<DeleteOutlined /> 删除
|
|||
|
|
</a-button>
|
|||
|
|
</a-popconfirm>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 空状态 -->
|
|||
|
|
<div class="empty-state" v-if="!loading && students.length === 0">
|
|||
|
|
<div class="empty-icon">
|
|||
|
|
<InboxOutlined />
|
|||
|
|
</div>
|
|||
|
|
<p>暂无学生数据</p>
|
|||
|
|
<a-button type="primary" @click="showAddModal">
|
|||
|
|
添加第一位学生
|
|||
|
|
</a-button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 加载状态 -->
|
|||
|
|
<div class="loading-state" v-if="loading">
|
|||
|
|
<a-spin size="large" />
|
|||
|
|
<p>加载中...</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 分页 -->
|
|||
|
|
<div class="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="modal-title">
|
|||
|
|
<component :is="isEdit ? EditOutlined : PlusOutlined" class="modal-title-icon" />
|
|||
|
|
{{ 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 style="color: #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="gender-icon boy" /> 男孩</a-radio>
|
|||
|
|
<a-radio value="女"><UserOutlined class="gender-icon girl" /> 女孩</a-radio>
|
|||
|
|
</a-radio-group>
|
|||
|
|
</a-form-item>
|
|||
|
|
<a-form-item label="出生日期" name="birthDate">
|
|||
|
|
<a-date-picker
|
|||
|
|
v-model:value="formState.birthDate"
|
|||
|
|
style="width: 100%;"
|
|||
|
|
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 style="color: #B2BEC3;" />
|
|||
|
|
</template>
|
|||
|
|
</a-input>
|
|||
|
|
</a-form-item>
|
|||
|
|
<a-form-item label="家长电话" name="parentPhone">
|
|||
|
|
<a-input v-model:value="formState.parentPhone" placeholder="请输入家长电话">
|
|||
|
|
<template #prefix>
|
|||
|
|
<PhoneOutlined style="color: #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="transfer-modal-content">
|
|||
|
|
<div class="current-info">
|
|||
|
|
<span>当前学生:</span>
|
|||
|
|
<strong>{{ selectedTransferStudent?.name }}</strong>
|
|||
|
|
<span class="current-class">({{ 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="transfer-history" v-if="transferHistory.length > 0">
|
|||
|
|
<div class="history-header">
|
|||
|
|
<HistoryOutlined /> 调班历史
|
|||
|
|
</div>
|
|||
|
|
<div class="history-list">
|
|||
|
|
<div v-for="h in transferHistory" :key="h.id" class="history-item">
|
|||
|
|
<span class="history-from">{{ h.fromClass?.name || '无' }}</span>
|
|||
|
|
<span class="history-arrow">→</span>
|
|||
|
|
<span class="history-to">{{ h.toClass.name }}</span>
|
|||
|
|
<span class="history-time">{{ formatDate(h.createdAt) }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</a-modal>
|
|||
|
|
|
|||
|
|
<!-- 批量导入模态框 -->
|
|||
|
|
<a-modal
|
|||
|
|
v-model:open="importModalVisible"
|
|||
|
|
:footer="null"
|
|||
|
|
width="560px"
|
|||
|
|
>
|
|||
|
|
<template #title>
|
|||
|
|
<span class="modal-title">
|
|||
|
|
<DownloadOutlined class="modal-title-icon" />
|
|||
|
|
批量导入学生
|
|||
|
|
</span>
|
|||
|
|
</template>
|
|||
|
|
<div class="import-content">
|
|||
|
|
<div class="import-tips">
|
|||
|
|
<div class="tips-header">
|
|||
|
|
<SolutionOutlined class="tips-icon" />
|
|||
|
|
<span>导入说明</span>
|
|||
|
|
</div>
|
|||
|
|
<ol class="tips-list">
|
|||
|
|
<li v-for="(note, index) in templateNotes" :key="index">{{ note }}</li>
|
|||
|
|
</ol>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<a-button class="download-btn" @click="downloadTemplate">
|
|||
|
|
<DownloadOutlined class="btn-icon" />
|
|||
|
|
下载导入模板
|
|||
|
|
</a-button>
|
|||
|
|
|
|||
|
|
<a-upload-dragger
|
|||
|
|
:before-upload="beforeUpload"
|
|||
|
|
:show-upload-list="false"
|
|||
|
|
accept=".xlsx,.xls,.csv"
|
|||
|
|
class="upload-area"
|
|||
|
|
>
|
|||
|
|
<div class="upload-content">
|
|||
|
|
<FileAddOutlined class="upload-icon" />
|
|||
|
|
<p class="upload-text">点击或拖拽文件到此区域上传</p>
|
|||
|
|
<p class="upload-hint">支持 .xlsx, .xls, .csv 格式</p>
|
|||
|
|
</div>
|
|||
|
|
</a-upload-dragger>
|
|||
|
|
|
|||
|
|
<div v-if="importFile" class="file-info">
|
|||
|
|
<FileOutlined class="file-icon" />
|
|||
|
|
<span class="file-name">{{ importFile.name }}</span>
|
|||
|
|
<a-button type="link" size="small" @click="importFile = null">移除</a-button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="importFile" class="default-class-select">
|
|||
|
|
<label>默认班级(用于未指定班级的学生)</label>
|
|||
|
|
<a-select
|
|||
|
|
v-model:value="importDefaultClassId"
|
|||
|
|
placeholder="选择默认班级"
|
|||
|
|
style="width: 100%;"
|
|||
|
|
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="import-submit-btn"
|
|||
|
|
>
|
|||
|
|
<template v-if="!importing">
|
|||
|
|
<RocketOutlined class="btn-icon" />
|
|||
|
|
开始导入
|
|||
|
|
</template>
|
|||
|
|
<span v-else>导入中...</span>
|
|||
|
|
</a-button>
|
|||
|
|
|
|||
|
|
<!-- 导入结果 -->
|
|||
|
|
<div v-if="importResult" class="import-result" :class="importResult.failed === 0 ? 'success' : 'warning'">
|
|||
|
|
<div class="result-header">
|
|||
|
|
<component :is="importResult.failed === 0 ? CheckCircleOutlined : WarningOutlined" class="result-icon" />
|
|||
|
|
<span>导入完成</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="result-stats">
|
|||
|
|
<span class="stat success">成功 {{ importResult.success }} 条</span>
|
|||
|
|
<span class="stat failed">失败 {{ importResult.failed }} 条</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="importResult.errors.length > 0" class="result-errors">
|
|||
|
|
<p v-for="(error, index) in importResult.errors" :key="index">
|
|||
|
|
第 {{ error.row }} 行:{{ error.message }}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</a-modal>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, reactive, computed, onMounted } from 'vue';
|
|||
|
|
import {
|
|||
|
|
SearchOutlined,
|
|||
|
|
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>
|
|||
|
|
.student-list-view {
|
|||
|
|
min-height: 100vh;
|
|||
|
|
background: linear-gradient(180deg, #FFF8F0 0%, #FFFFFF 100%);
|
|||
|
|
padding: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 页面头部 */
|
|||
|
|
.page-header {
|
|||
|
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|||
|
|
border-radius: 20px;
|
|||
|
|
padding: 24px 32px;
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-content {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-title {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title-icon {
|
|||
|
|
width: 64px;
|
|||
|
|
height: 64px;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
background: rgba(255, 255, 255, 0.2);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
font-size: 32px;
|
|||
|
|
color: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title-text h2 {
|
|||
|
|
color: white;
|
|||
|
|
font-size: 24px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title-text p {
|
|||
|
|
color: rgba(255, 255, 255, 0.8);
|
|||
|
|
font-size: 14px;
|
|||
|
|
margin: 4px 0 0 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-stats {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 32px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-item {
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-value {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 28px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-value.active {
|
|||
|
|
color: #4FC3F7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-value.pink {
|
|||
|
|
color: #FFD93D;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-label {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: rgba(255, 255, 255, 0.8);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-label-with-icon {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-icon {
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-icon.boy {
|
|||
|
|
color: #4FC3F7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-icon.girl {
|
|||
|
|
color: #FFD93D;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 操作栏 */
|
|||
|
|
.action-bar {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
padding: 16px 20px;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.filters {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.filters :deep(.ant-select-selector),
|
|||
|
|
.filters :deep(.ant-input-affix-wrapper) {
|
|||
|
|
border-radius: 12px;
|
|||
|
|
border: 2px solid #F0F0F0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.filters :deep(.ant-select-selector:hover),
|
|||
|
|
.filters :deep(.ant-input-affix-wrapper:hover) {
|
|||
|
|
border-color: #f093fb;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-btn {
|
|||
|
|
border-radius: 12px;
|
|||
|
|
height: 40px;
|
|||
|
|
border: 2px solid #f093fb;
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-btn:hover {
|
|||
|
|
background: #FFF0F5;
|
|||
|
|
border-color: #f5576c;
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-btn {
|
|||
|
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
height: 40px;
|
|||
|
|
padding: 0 24px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-btn:hover {
|
|||
|
|
background: linear-gradient(135deg, #e080e8 0%, #e04a5d 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-icon {
|
|||
|
|
margin-right: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 学生卡片网格 */
|
|||
|
|
.student-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|||
|
|
gap: 20px;
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-card {
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
border: 2px solid transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-card:hover {
|
|||
|
|
transform: translateY(-4px);
|
|||
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
|||
|
|
border-color: #f093fb;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-card .card-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
padding: 16px 20px;
|
|||
|
|
background: linear-gradient(135deg, #F8F9FA 0%, #FFFFFF 100%);
|
|||
|
|
border-bottom: 1px solid #F0F0F0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-avatar {
|
|||
|
|
width: 48px;
|
|||
|
|
height: 48px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-avatar.boy {
|
|||
|
|
background: linear-gradient(135deg, #4FC3F7 0%, #29B6F6 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-avatar.girl {
|
|||
|
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar-icon {
|
|||
|
|
font-size: 24px;
|
|||
|
|
color: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-basic {
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-name {
|
|||
|
|
font-size: 16px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #2D3436;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-class {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #636E72;
|
|||
|
|
margin-top: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-body {
|
|||
|
|
padding: 16px 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-row {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-row:last-child {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-icon {
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #f5576c;
|
|||
|
|
width: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-value {
|
|||
|
|
color: #636E72;
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-value strong {
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gender-tag {
|
|||
|
|
padding: 2px 8px;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
font-size: 11px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gender-tag.boy {
|
|||
|
|
background: #E3F2FD;
|
|||
|
|
color: #1976D2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gender-tag.girl {
|
|||
|
|
background: #FCE4EC;
|
|||
|
|
color: #E91E63;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Modal title styles */
|
|||
|
|
.modal-title {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-size: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-title-icon {
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Gender icon styles in form */
|
|||
|
|
.gender-icon {
|
|||
|
|
margin-right: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gender-icon.boy {
|
|||
|
|
color: #4FC3F7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gender-icon.girl {
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-actions {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 12px 20px;
|
|||
|
|
border-top: 1px solid #F0F0F0;
|
|||
|
|
background: #FAFAFA;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-actions :deep(.ant-btn-link) {
|
|||
|
|
padding: 4px 8px;
|
|||
|
|
height: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 空状态 */
|
|||
|
|
.empty-state {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 80px 0;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty-icon {
|
|||
|
|
width: 80px;
|
|||
|
|
height: 80px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: linear-gradient(135deg, #FFF0F5 0%, #FCE4EC 100%);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
font-size: 40px;
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty-state p {
|
|||
|
|
color: #636E72;
|
|||
|
|
font-size: 16px;
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty-state :deep(.ant-btn-primary) {
|
|||
|
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|||
|
|
border: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 加载状态 */
|
|||
|
|
.loading-state {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 80px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-state p {
|
|||
|
|
color: #636E72;
|
|||
|
|
margin-top: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 分页 */
|
|||
|
|
.pagination-wrapper {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 24px;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 导入内容样式 */
|
|||
|
|
.import-content {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-tips {
|
|||
|
|
background: #FFF8F0;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #FF8C42;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-icon {
|
|||
|
|
font-size: 18px;
|
|||
|
|
color: #FF8C42;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-list {
|
|||
|
|
margin: 0;
|
|||
|
|
padding-left: 20px;
|
|||
|
|
color: #636E72;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-list li {
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.download-btn {
|
|||
|
|
border-radius: 12px;
|
|||
|
|
border: 2px dashed #f093fb;
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.download-btn:hover {
|
|||
|
|
border-color: #f5576c;
|
|||
|
|
color: #f5576c;
|
|||
|
|
background: #FFF0F5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-area {
|
|||
|
|
border-radius: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-content {
|
|||
|
|
padding: 24px;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-icon {
|
|||
|
|
font-size: 48px;
|
|||
|
|
color: #f5576c;
|
|||
|
|
display: block;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-text {
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #636E72;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-hint {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #B2BEC3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-info {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
background: #F0F0F0;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-icon {
|
|||
|
|
font-size: 20px;
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-name {
|
|||
|
|
flex: 1;
|
|||
|
|
color: #636E72;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.default-class-select {
|
|||
|
|
margin-top: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.default-class-select label {
|
|||
|
|
display: block;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #636E72;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-submit-btn {
|
|||
|
|
height: 44px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
font-size: 15px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|||
|
|
border: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-submit-btn:hover:not(:disabled) {
|
|||
|
|
background: linear-gradient(135deg, #e080e8 0%, #e04a5d 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-result {
|
|||
|
|
padding: 16px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-result.success {
|
|||
|
|
background: #E8F5E9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-result.warning {
|
|||
|
|
background: #FFF8E1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-icon {
|
|||
|
|
font-size: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-result.success .result-icon {
|
|||
|
|
color: #43A047;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.import-result.warning .result-icon {
|
|||
|
|
color: #FF8C42;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-stats {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 16px;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-stats .stat {
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-stats .stat.success {
|
|||
|
|
color: #43A047;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-stats .stat.failed {
|
|||
|
|
color: #E53935;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-errors {
|
|||
|
|
max-height: 150px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: 12px;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #636E72;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-errors p {
|
|||
|
|
margin: 4px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 响应式设计 */
|
|||
|
|
@media (max-width: 768px) {
|
|||
|
|
.header-content {
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 16px;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-stats {
|
|||
|
|
width: 100%;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-bar {
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.filters, .actions {
|
|||
|
|
width: 100%;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.student-grid {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 调班模态框样式 */
|
|||
|
|
.transfer-modal-content {
|
|||
|
|
padding: 8px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.current-info {
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
background: #F8F9FA;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.current-info strong {
|
|||
|
|
color: #f5576c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.current-class {
|
|||
|
|
color: #888;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.transfer-history {
|
|||
|
|
margin-top: 16px;
|
|||
|
|
border-top: 1px solid #F0F0F0;
|
|||
|
|
padding-top: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #636E72;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-list {
|
|||
|
|
max-height: 150px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
background: #F8F9FA;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-from {
|
|||
|
|
color: #888;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-arrow {
|
|||
|
|
color: #B2BEC3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-to {
|
|||
|
|
color: #2D3436;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-time {
|
|||
|
|
margin-left: auto;
|
|||
|
|
color: #B2BEC3;
|
|||
|
|
font-size: 11px;
|
|||
|
|
}
|
|||
|
|
</style>
|