|
|
@@ -0,0 +1,468 @@
|
|
|
+<template>
|
|
|
+ <div class="student-selector">
|
|
|
+ <!-- 学生选择输入框 -->
|
|
|
+ <el-input v-model="displayValue" :placeholder="placeholder" :disabled="disabled" clearable @input="handleSearch" @clear="handleClear">
|
|
|
+ <template #suffix>
|
|
|
+ <el-icon class="search-icon" @click="openDialog">
|
|
|
+ <Search />
|
|
|
+ </el-icon>
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+
|
|
|
+ <!-- 搜索建议下拉 -->
|
|
|
+ <div v-if="showSuggestions && suggestions.length > 0" class="suggestions-dropdown">
|
|
|
+ <div v-for="student in suggestions" :key="student.id" class="suggestion-item" @click="selectStudent(student)">
|
|
|
+ <div class="student-info">
|
|
|
+ <span class="student-name">{{ student.name }}</span>
|
|
|
+ <span class="student-no">{{ student.studentNo }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="student-detail">{{ student.collegeName }} - {{ student.majorName }} - {{ student.className }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 学生选择弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="dialogVisible"
|
|
|
+ title="选择学生"
|
|
|
+ width="800px"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ :append-to-body="true"
|
|
|
+ :modal-append-to-body="true"
|
|
|
+ destroy-on-close
|
|
|
+ :z-index="5000"
|
|
|
+ >
|
|
|
+ <div class="selector-content">
|
|
|
+ <!-- 左侧树形结构 -->
|
|
|
+ <div class="tree-panel">
|
|
|
+ <el-tree ref="treeRef" :data="treeData" :props="treeProps" node-key="id" :expand-on-click-node="false" @node-click="handleTreeNodeClick">
|
|
|
+ <template #default="{ node, data }">
|
|
|
+ <span class="tree-node">
|
|
|
+ <span>{{ node.label }}</span>
|
|
|
+ <span v-if="data.studentCount" class="student-count">({{ data.studentCount }}人)</span>
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-tree>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧学生列表 -->
|
|
|
+ <div class="student-panel">
|
|
|
+ <div v-if="!selectedClassId" class="empty-tip">请选择左侧班级查看学生列表</div>
|
|
|
+ <div v-else>
|
|
|
+ <div class="panel-header">
|
|
|
+ <span>{{ selectedClassName }} - 学生列表</span>
|
|
|
+ <el-input v-model="studentSearchText" placeholder="搜索学生姓名或学号" size="small" style="width: 200px" clearable>
|
|
|
+ <template #prefix>
|
|
|
+ <el-icon><Search /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+ </div>
|
|
|
+ <div class="student-list">
|
|
|
+ <div
|
|
|
+ v-for="student in filteredStudents"
|
|
|
+ :key="student.id"
|
|
|
+ class="student-item"
|
|
|
+ :class="{ selected: selectedStudent?.id === student.id }"
|
|
|
+ @click="selectCurrentStudent(student)"
|
|
|
+ >
|
|
|
+ <div class="student-info">
|
|
|
+ <span class="student-name">{{ student.name }}</span>
|
|
|
+ <span class="student-no">{{ student.studentNo }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="student-detail">性别:{{ student.gender === '0' ? '男' : '女' }} | 电话:{{ student.phone || '暂无' }}</div>
|
|
|
+ </div>
|
|
|
+ <div v-if="filteredStudents.length === 0" class="empty-tip">暂无学生数据</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <span class="dialog-footer">
|
|
|
+ <el-button @click="closeDialog">取消</el-button>
|
|
|
+ <el-button type="primary" :disabled="!selectedStudent" @click="confirmSelect"> 确定 </el-button>
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
|
|
+import { ElMessage } from 'element-plus';
|
|
|
+import { Search } from '@element-plus/icons-vue';
|
|
|
+import { searchStudentByName, getStudentTreeData, getStudentsByClassId } from '@/api/xg/student';
|
|
|
+import { StudentVO, StudentTreeNode } from '@/api/xg/student/types';
|
|
|
+
|
|
|
+interface Props {
|
|
|
+ modelValue: StudentVO | null;
|
|
|
+ placeholder?: string;
|
|
|
+ disabled?: boolean;
|
|
|
+ showStudentNo?: boolean; // 是否在显示名称中包含学号,默认为true
|
|
|
+}
|
|
|
+
|
|
|
+interface Emits {
|
|
|
+ (e: 'update:modelValue', value: StudentVO | null): void;
|
|
|
+ (e: 'change', value: StudentVO | null): void;
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
+ placeholder: '请选择学生',
|
|
|
+ disabled: false,
|
|
|
+ showStudentNo: true
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits<Emits>();
|
|
|
+
|
|
|
+// 响应式数据
|
|
|
+const displayValue = ref('');
|
|
|
+const suggestions = ref<StudentVO[]>([]);
|
|
|
+const showSuggestions = ref(false);
|
|
|
+const dialogVisible = ref(false);
|
|
|
+const treeData = ref<StudentTreeNode[]>([]);
|
|
|
+const selectedClassId = ref<string | number>('');
|
|
|
+const selectedClassName = ref('');
|
|
|
+const classStudents = ref<StudentVO[]>([]);
|
|
|
+const studentSearchText = ref('');
|
|
|
+const selectedStudent = ref<StudentVO | null>(null);
|
|
|
+const searchTimer = ref<NodeJS.Timeout | null>(null);
|
|
|
+
|
|
|
+// 计算属性
|
|
|
+const filteredStudents = computed(() => {
|
|
|
+ if (!studentSearchText.value) return classStudents.value;
|
|
|
+ const searchText = studentSearchText.value.toLowerCase();
|
|
|
+ return classStudents.value.filter(
|
|
|
+ (student) => student.name.toLowerCase().includes(searchText) || student.studentNo.toLowerCase().includes(searchText)
|
|
|
+ );
|
|
|
+});
|
|
|
+
|
|
|
+// 树形结构配置
|
|
|
+const treeProps = {
|
|
|
+ children: 'children',
|
|
|
+ label: 'label'
|
|
|
+};
|
|
|
+
|
|
|
+// 监听外部值变化
|
|
|
+watch(
|
|
|
+ () => props.modelValue,
|
|
|
+ (newValue) => {
|
|
|
+ if (newValue) {
|
|
|
+ displayValue.value = props.showStudentNo ? `${newValue.name}(${newValue.studentNo})` : newValue.name;
|
|
|
+ } else {
|
|
|
+ displayValue.value = '';
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+
|
|
|
+// 搜索学生
|
|
|
+const handleSearch = (value: string) => {
|
|
|
+ if (searchTimer.value) {
|
|
|
+ clearTimeout(searchTimer.value);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!value.trim()) {
|
|
|
+ suggestions.value = [];
|
|
|
+ showSuggestions.value = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ searchTimer.value = setTimeout(async () => {
|
|
|
+ try {
|
|
|
+ const response = await searchStudentByName(value.trim());
|
|
|
+ suggestions.value = response.data;
|
|
|
+ showSuggestions.value = true;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('搜索学生失败:', error);
|
|
|
+ }
|
|
|
+ }, 300);
|
|
|
+};
|
|
|
+
|
|
|
+// 选择搜索建议中的学生
|
|
|
+const selectStudent = (student: StudentVO) => {
|
|
|
+ displayValue.value = props.showStudentNo ? `${student.name}(${student.studentNo})` : student.name;
|
|
|
+ suggestions.value = [];
|
|
|
+ showSuggestions.value = false;
|
|
|
+ emit('update:modelValue', student);
|
|
|
+ emit('change', student);
|
|
|
+};
|
|
|
+
|
|
|
+// 清除选择
|
|
|
+const handleClear = () => {
|
|
|
+ suggestions.value = [];
|
|
|
+ showSuggestions.value = false;
|
|
|
+ emit('update:modelValue', null);
|
|
|
+ emit('change', null);
|
|
|
+};
|
|
|
+
|
|
|
+// 打开弹窗
|
|
|
+const openDialog = () => {
|
|
|
+ if (props.disabled) return;
|
|
|
+
|
|
|
+ // 防止重复打开
|
|
|
+ if (dialogVisible.value) return;
|
|
|
+
|
|
|
+ // 先隐藏建议下拉,避免冲突
|
|
|
+ showSuggestions.value = false;
|
|
|
+
|
|
|
+ // 在TabPane环境下添加延迟,避免闪烁
|
|
|
+ const isInTabPane = document.querySelector('.el-tab-pane[aria-hidden="false"]');
|
|
|
+
|
|
|
+ if (isInTabPane) {
|
|
|
+ // 增加延迟时间,确保TabPane状态稳定
|
|
|
+ setTimeout(() => {
|
|
|
+ if (!dialogVisible.value) {
|
|
|
+ // 再次检查状态
|
|
|
+ dialogVisible.value = true;
|
|
|
+ nextTick(() => {
|
|
|
+ loadTreeData();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, 100);
|
|
|
+ } else {
|
|
|
+ nextTick(() => {
|
|
|
+ dialogVisible.value = true;
|
|
|
+ loadTreeData();
|
|
|
+ });
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 加载树形数据
|
|
|
+const loadTreeData = async () => {
|
|
|
+ selectedStudent.value = props.modelValue || null;
|
|
|
+
|
|
|
+ // 加载树形数据
|
|
|
+ if (treeData.value.length === 0) {
|
|
|
+ try {
|
|
|
+ const response = await getStudentTreeData();
|
|
|
+ treeData.value = response.data;
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error('加载学院专业信息失败');
|
|
|
+ console.error('加载树形数据失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 树节点点击
|
|
|
+const handleTreeNodeClick = async (data: StudentTreeNode) => {
|
|
|
+ if (data.type === 'class') {
|
|
|
+ selectedClassId.value = data.id;
|
|
|
+ selectedClassName.value = data.label;
|
|
|
+ studentSearchText.value = '';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await getStudentsByClassId(data.id);
|
|
|
+ classStudents.value = response.data;
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error('加载学生列表失败');
|
|
|
+ console.error('加载学生列表失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 选择当前学生
|
|
|
+const selectCurrentStudent = (student: StudentVO) => {
|
|
|
+ selectedStudent.value = student;
|
|
|
+};
|
|
|
+
|
|
|
+// 关闭弹窗并清理状态
|
|
|
+const closeDialog = () => {
|
|
|
+ // 立即设置为false,避免延迟导致的问题
|
|
|
+ dialogVisible.value = false;
|
|
|
+
|
|
|
+ // 强制触发响应式更新,确保UI状态同步
|
|
|
+ nextTick(() => {
|
|
|
+ if (dialogVisible.value !== false) {
|
|
|
+ dialogVisible.value = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 确认选择
|
|
|
+const confirmSelect = () => {
|
|
|
+ if (selectedStudent.value) {
|
|
|
+ displayValue.value = props.showStudentNo ? `${selectedStudent.value.name}(${selectedStudent.value.studentNo})` : selectedStudent.value.name;
|
|
|
+
|
|
|
+ // 先触发事件,让父组件处理数据更新
|
|
|
+ emit('update:modelValue', selectedStudent.value);
|
|
|
+ emit('change', selectedStudent.value);
|
|
|
+
|
|
|
+ // 使用nextTick确保所有响应式更新完成后再关闭弹窗
|
|
|
+ nextTick(() => {
|
|
|
+ closeDialog();
|
|
|
+ });
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 清理弹窗状态(不设置dialogVisible)
|
|
|
+const cleanupDialogState = () => {
|
|
|
+ selectedClassId.value = '';
|
|
|
+ selectedClassName.value = '';
|
|
|
+ selectedStudent.value = null;
|
|
|
+ classStudents.value = [];
|
|
|
+ studentSearchText.value = '';
|
|
|
+};
|
|
|
+
|
|
|
+// 监听弹窗关闭事件
|
|
|
+watch(dialogVisible, (newVal) => {
|
|
|
+ if (!newVal) {
|
|
|
+ // 延迟清理状态,避免关闭动画期间的闪烁
|
|
|
+ setTimeout(() => {
|
|
|
+ cleanupDialogState();
|
|
|
+ }, 300);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 全局点击事件处理
|
|
|
+const handleGlobalClick = (e: Event) => {
|
|
|
+ const target = e.target as HTMLElement;
|
|
|
+ // 只在建议下拉显示时才处理点击外部事件
|
|
|
+ if (showSuggestions.value && !target.closest('.student-selector')) {
|
|
|
+ showSuggestions.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 组件生命周期
|
|
|
+onMounted(() => {
|
|
|
+ document.addEventListener('click', handleGlobalClick);
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ document.removeEventListener('click', handleGlobalClick);
|
|
|
+ // 清理定时器
|
|
|
+ if (searchTimer.value) {
|
|
|
+ clearTimeout(searchTimer.value);
|
|
|
+ }
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.student-selector {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.search-icon {
|
|
|
+ cursor: pointer;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.search-icon:hover {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.suggestions-dropdown {
|
|
|
+ position: absolute;
|
|
|
+ top: 100%;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
|
|
+ z-index: 1000;
|
|
|
+ max-height: 200px;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.suggestion-item {
|
|
|
+ padding: 8px 12px;
|
|
|
+ cursor: pointer;
|
|
|
+ border-bottom: 1px solid #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.suggestion-item:hover {
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.suggestion-item:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+}
|
|
|
+
|
|
|
+.student-info {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.student-name {
|
|
|
+ font-weight: 500;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.student-no {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.student-detail {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ margin-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.selector-content {
|
|
|
+ display: flex;
|
|
|
+ height: 400px;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-panel {
|
|
|
+ width: 300px;
|
|
|
+ border-right: 1px solid #ebeef5;
|
|
|
+ padding-right: 12px;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.student-panel {
|
|
|
+ flex: 1;
|
|
|
+ padding-left: 12px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-node {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.student-count {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.student-list {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.student-item {
|
|
|
+ padding: 8px 12px;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: 4px;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.student-item:hover {
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.student-item.selected {
|
|
|
+ background: #e7f4ff;
|
|
|
+ border: 1px solid #b3d8ff;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-tip {
|
|
|
+ text-align: center;
|
|
|
+ color: #909399;
|
|
|
+ padding: 40px 0;
|
|
|
+}
|
|
|
+</style>
|