|
|
@@ -11,6 +11,13 @@
|
|
|
</div>
|
|
|
</template>
|
|
|
<el-input v-model="deptName" placeholder="请输入院系名称" prefix-icon="Search" clearable />
|
|
|
+ <div class="class-status-filter mt-2">
|
|
|
+ <span class="class-status-filter__label">班级状态:</span>
|
|
|
+ <el-radio-group v-model="classStatusFilter" size="small" @change="handleClassStatusFilterChange">
|
|
|
+ <el-radio-button label="all">全部班级</el-radio-button>
|
|
|
+ <el-radio-button label="normal">{{ normalClassStatusLabel }}</el-radio-button>
|
|
|
+ </el-radio-group>
|
|
|
+ </div>
|
|
|
<el-tree
|
|
|
ref="deptTreeRef"
|
|
|
class="mt-2 dept-tree"
|
|
|
@@ -23,7 +30,22 @@
|
|
|
highlight-current
|
|
|
default-expand-all
|
|
|
@check="handleDeptCheck"
|
|
|
- />
|
|
|
+ >
|
|
|
+ <template #default="{ data }">
|
|
|
+ <div class="dept-tree-node-label">
|
|
|
+ <span class="dept-tree-node-text">{{ data.label }}</span>
|
|
|
+ <el-tag
|
|
|
+ v-if="isClassNode(data) && data.classStatus"
|
|
|
+ size="small"
|
|
|
+ effect="light"
|
|
|
+ :type="getClassStatusTagType(data.classStatus)"
|
|
|
+ class="dept-tree-node-tag"
|
|
|
+ >
|
|
|
+ {{ getClassStatusLabel(data.classStatus) }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-tree>
|
|
|
</el-card>
|
|
|
</div>
|
|
|
|
|
|
@@ -295,16 +317,19 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, reactive, toRefs, watchEffect, onMounted, computed, getCurrentInstance } from 'vue';
|
|
|
+import { ref, reactive, toRefs, watch, watchEffect, onMounted, computed, getCurrentInstance, nextTick } from 'vue';
|
|
|
import ElMessage from 'element-plus/es/components/message/index';
|
|
|
import ElMessageBox from 'element-plus/es/components/message-box/index';
|
|
|
import { Search, Refresh, Setting, Download, List, Grid, View, Picture, Male, Female, Loading } from '@element-plus/icons-vue';
|
|
|
import AdvancedSearchDialog from './components/AdvancedSearchDialog.vue';
|
|
|
import StudentDetailDialog from './components/StudentDetailDialog.vue';
|
|
|
import api from '@/api/xg/xsjcxx/stuRoster/index';
|
|
|
+import { listClassMsg } from '@/api/xg/xsjcxx/classMsg/classMsg/index';
|
|
|
+import type { ClassMsgVO } from '@/api/xg/xsjcxx/classMsg/types';
|
|
|
import request from '@/utils/request';
|
|
|
|
|
|
const { proxy } = getCurrentInstance() as any;
|
|
|
+const { xg_class_status } = toRefs<any>(proxy?.useDict('xg_class_status') || {});
|
|
|
|
|
|
// 响应式数据
|
|
|
const loading = ref(false);
|
|
|
@@ -314,6 +339,7 @@ const showAdvancedSearch = ref(false);
|
|
|
const deptName = ref('');
|
|
|
const deptTreeRef = ref();
|
|
|
const selectedDepts = ref<number[]>([]);
|
|
|
+const classStatusFilter = ref<'all' | 'normal'>('all');
|
|
|
|
|
|
// 学生照片缓存
|
|
|
const studentPhotoCache = ref<Map<string, string>>(new Map());
|
|
|
@@ -333,6 +359,11 @@ const tableMaxHeight = computed(() => {
|
|
|
return baseHeight - headerHeight - searchHeight - toolbarHeight - paginationHeight - padding - cardPadding;
|
|
|
});
|
|
|
|
|
|
+const normalClassStatusLabel = computed(() => {
|
|
|
+ const label = getClassStatusLabel('1');
|
|
|
+ return label || '正常';
|
|
|
+});
|
|
|
+
|
|
|
// 查询参数
|
|
|
const queryParams = reactive({
|
|
|
pageNum: 1,
|
|
|
@@ -449,8 +480,14 @@ const student_status = ref([
|
|
|
]);
|
|
|
|
|
|
// 部门树数据
|
|
|
+const rawDeptOptions = ref<any[]>([]);
|
|
|
const deptOptions = ref([]);
|
|
|
|
|
|
+// 班级状态缓存
|
|
|
+const classStatusMap = new Map<string, string>();
|
|
|
+let classStatusLoaded = false;
|
|
|
+let classStatusLoading = false;
|
|
|
+
|
|
|
// 表格列配置
|
|
|
const columns = ref([
|
|
|
{ key: 0, label: '学号', visible: true },
|
|
|
@@ -949,6 +986,204 @@ const isPhotoLoading = (student: any): boolean => {
|
|
|
return photoLoadingCache.value.get(photoPath) || false;
|
|
|
};
|
|
|
|
|
|
+const getClassStatusLabel = (status: string): string => {
|
|
|
+ if (!status) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ const options = (xg_class_status?.value as any[]) || [];
|
|
|
+ const found = options.find((item) => item?.value === status);
|
|
|
+ return (found && found.label) || status;
|
|
|
+};
|
|
|
+
|
|
|
+const getClassStatusTagType = (status: string): 'success' | 'warning' | 'info' | 'primary' | 'danger' => {
|
|
|
+ const statusTypeMap: Record<string, 'success' | 'warning' | 'info' | 'primary' | 'danger'> = {
|
|
|
+ '1': 'success',
|
|
|
+ '2': 'warning',
|
|
|
+ '3': 'primary',
|
|
|
+ '4': 'danger',
|
|
|
+ '5': 'info'
|
|
|
+ };
|
|
|
+ return statusTypeMap[status] || 'info';
|
|
|
+};
|
|
|
+
|
|
|
+const getDeptCategory = (node: any) => {
|
|
|
+ if (!node) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ const category = node.deptCategory ?? node.dept_category ?? node.params?.deptCategory;
|
|
|
+ if (category && !node.deptCategory) {
|
|
|
+ node.deptCategory = category;
|
|
|
+ }
|
|
|
+ return category;
|
|
|
+};
|
|
|
+
|
|
|
+const isClassNode = (node: any) => {
|
|
|
+ const category = getDeptCategory(node);
|
|
|
+ return typeof category === 'string' && category.toLowerCase().startsWith('bj-');
|
|
|
+};
|
|
|
+
|
|
|
+const ensureClassStatusMap = async () => {
|
|
|
+ if (classStatusLoaded || classStatusLoading) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ classStatusLoading = true;
|
|
|
+ try {
|
|
|
+ const params = {
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 9999,
|
|
|
+ params: {}
|
|
|
+ };
|
|
|
+ const res = await listClassMsg(params);
|
|
|
+ const rows: ClassMsgVO[] = (res && ((res as any).rows || (res as any).data?.rows)) || [];
|
|
|
+ classStatusMap.clear();
|
|
|
+ rows.forEach((item) => {
|
|
|
+ if (item?.id === undefined || item?.id === null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const statusValue = item.classStatus;
|
|
|
+ if (statusValue === undefined || statusValue === null || statusValue === '') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ classStatusMap.set(String(item.id), String(statusValue));
|
|
|
+ });
|
|
|
+ classStatusLoaded = true;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载班级状态失败:', error);
|
|
|
+ } finally {
|
|
|
+ classStatusLoading = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const resolveClassStatus = (node: any): string | undefined => {
|
|
|
+ const existing = node?.classStatus ?? node?.class_status;
|
|
|
+ if (existing !== undefined && existing !== null && existing !== '') {
|
|
|
+ return String(existing);
|
|
|
+ }
|
|
|
+ const deptId = node?.id ?? node?.deptId;
|
|
|
+ if (deptId === undefined || deptId === null) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ return classStatusMap.get(String(deptId));
|
|
|
+};
|
|
|
+
|
|
|
+const buildDeptTreeWithStatus = (nodes: any[]): any[] => {
|
|
|
+ return (nodes || []).map((node) => {
|
|
|
+ const enhanced: any = { ...node };
|
|
|
+ if (Array.isArray(node?.children) && node.children.length > 0) {
|
|
|
+ enhanced.children = buildDeptTreeWithStatus(node.children);
|
|
|
+ } else {
|
|
|
+ enhanced.children = [];
|
|
|
+ }
|
|
|
+ const baseLabel = enhanced.originalLabel ?? enhanced.label ?? '';
|
|
|
+ enhanced.originalLabel = baseLabel;
|
|
|
+
|
|
|
+ if (isClassNode(enhanced)) {
|
|
|
+ const status = resolveClassStatus(enhanced);
|
|
|
+ if (status) {
|
|
|
+ const statusLabel = getClassStatusLabel(status);
|
|
|
+ enhanced.classStatus = status;
|
|
|
+ enhanced.classStatusLabel = statusLabel;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ enhanced.label = baseLabel;
|
|
|
+ return enhanced;
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const collectNodeIds = (nodes: any[]): Set<string> => {
|
|
|
+ const ids = new Set<string>();
|
|
|
+ const traverse = (list: any[]) => {
|
|
|
+ (list || []).forEach((item) => {
|
|
|
+ if (item?.id !== undefined && item?.id !== null) {
|
|
|
+ ids.add(String(item.id));
|
|
|
+ }
|
|
|
+ if (Array.isArray(item?.children) && item.children.length > 0) {
|
|
|
+ traverse(item.children);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ };
|
|
|
+ traverse(nodes);
|
|
|
+ return ids;
|
|
|
+};
|
|
|
+
|
|
|
+const filterDeptTreeByStatus = (nodes: any[], status?: string): any[] => {
|
|
|
+ const targetStatus = status?.trim();
|
|
|
+ return (nodes || [])
|
|
|
+ .map((node) => {
|
|
|
+ const filtered: any = { ...node };
|
|
|
+ if (Array.isArray(node?.children) && node.children.length > 0) {
|
|
|
+ filtered.children = filterDeptTreeByStatus(node.children, status);
|
|
|
+ } else {
|
|
|
+ filtered.children = [];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (targetStatus && isClassNode(filtered)) {
|
|
|
+ return filtered.classStatus === targetStatus ? filtered : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (targetStatus && filtered.children.length === 0 && !isClassNode(filtered)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return filtered;
|
|
|
+ })
|
|
|
+ .filter((item) => item !== null);
|
|
|
+};
|
|
|
+
|
|
|
+const findFirstSelectableNode = (nodes: any[]): any | null => {
|
|
|
+ for (const node of nodes || []) {
|
|
|
+ if (!node?.disabled) {
|
|
|
+ return node;
|
|
|
+ }
|
|
|
+ if (Array.isArray(node?.children) && node.children.length > 0) {
|
|
|
+ const child = findFirstSelectableNode(node.children);
|
|
|
+ if (child) {
|
|
|
+ return child;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+};
|
|
|
+
|
|
|
+const updateDeptOptionsWithStatus = () => {
|
|
|
+ const checkedKeys = Array.isArray(selectedDepts.value) ? [...selectedDepts.value] : [];
|
|
|
+ const currentKey = deptTreeRef.value?.getCurrentKey?.();
|
|
|
+
|
|
|
+ let processedTree = buildDeptTreeWithStatus(rawDeptOptions.value);
|
|
|
+
|
|
|
+ if (classStatusFilter.value === 'normal') {
|
|
|
+ processedTree = filterDeptTreeByStatus(processedTree, '1');
|
|
|
+ }
|
|
|
+
|
|
|
+ const visibleIds = collectNodeIds(processedTree);
|
|
|
+ const validCheckedKeys = checkedKeys.filter((key) => visibleIds.has(String(key)));
|
|
|
+ const selectionChanged = validCheckedKeys.length !== checkedKeys.length;
|
|
|
+
|
|
|
+ selectedDepts.value = validCheckedKeys;
|
|
|
+ queryParams.deptIds = validCheckedKeys;
|
|
|
+
|
|
|
+ deptOptions.value = processedTree;
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ deptTreeRef.value?.setCheckedKeys(validCheckedKeys);
|
|
|
+ if (currentKey && visibleIds.has(String(currentKey))) {
|
|
|
+ deptTreeRef.value?.setCurrentKey(currentKey);
|
|
|
+ } else if (!currentKey) {
|
|
|
+ const fallbackNode = findFirstSelectableNode(processedTree);
|
|
|
+ if (fallbackNode?.id !== undefined) {
|
|
|
+ deptTreeRef.value?.setCurrentKey(fallbackNode.id);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (selectionChanged) {
|
|
|
+ getList();
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const handleClassStatusFilterChange = () => {
|
|
|
+ updateDeptOptionsWithStatus();
|
|
|
+};
|
|
|
+
|
|
|
// 监听部门名称变化
|
|
|
watchEffect(() => {
|
|
|
if (deptTreeRef.value) {
|
|
|
@@ -956,37 +1191,56 @@ watchEffect(() => {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
+watch(
|
|
|
+ () => xg_class_status?.value,
|
|
|
+ () => {
|
|
|
+ if (rawDeptOptions.value.length > 0) {
|
|
|
+ updateDeptOptionsWithStatus();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { deep: true }
|
|
|
+);
|
|
|
+
|
|
|
// 递归处理树数据,将所有节点的disabled属性设置为false
|
|
|
const processTreeData = (data: any[]): any[] => {
|
|
|
return data.map((node) => ({
|
|
|
...node,
|
|
|
disabled: false, // 强制设置为false,允许所有节点勾选
|
|
|
+ originalLabel: node.originalLabel ?? node.label,
|
|
|
children: node.children ? processTreeData(node.children) : undefined
|
|
|
}));
|
|
|
};
|
|
|
|
|
|
+const handleDeptTreeData = async (data: any[]) => {
|
|
|
+ rawDeptOptions.value = processTreeData(data);
|
|
|
+ await ensureClassStatusMap();
|
|
|
+ updateDeptOptionsWithStatus();
|
|
|
+};
|
|
|
+
|
|
|
/** 查询部门下拉树结构 */
|
|
|
const getDeptTree = async () => {
|
|
|
try {
|
|
|
const res = await api.XgDeptTreeSelect(101);
|
|
|
// XgDeptTreeSelect返回的数据结构与deptTreeSelect不同,需要进行适当处理
|
|
|
if (res.data && Array.isArray(res.data)) {
|
|
|
- deptOptions.value = processTreeData(res.data);
|
|
|
+ await handleDeptTreeData(res.data);
|
|
|
} else {
|
|
|
console.warn('部门树数据格式不符合预期');
|
|
|
// 回退到默认处理
|
|
|
const backup = await api.deptTreeSelect();
|
|
|
- deptOptions.value = processTreeData(backup.data.data);
|
|
|
+ await handleDeptTreeData(backup.data.data);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('获取部门树失败:', error);
|
|
|
// 如果API调用失败,可以使用默认的deptTreeSelect作为备选
|
|
|
try {
|
|
|
const backup = await api.deptTreeSelect();
|
|
|
- deptOptions.value = processTreeData(backup.data.data);
|
|
|
+ await handleDeptTreeData(backup.data.data);
|
|
|
} catch (backupError) {
|
|
|
console.error('备选API也失败:', backupError);
|
|
|
ElMessage.error('获取部门树失败,请重试');
|
|
|
+ rawDeptOptions.value = [];
|
|
|
+ deptOptions.value = [];
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
@@ -1028,6 +1282,10 @@ onMounted(() => {
|
|
|
padding-bottom: 20px; /* 增加底部内边距,为按钮留出空间 */
|
|
|
}
|
|
|
|
|
|
+.left-panel :deep(.el-card__header) {
|
|
|
+ padding: 9px 21px !important;
|
|
|
+}
|
|
|
+
|
|
|
.right-panel {
|
|
|
flex: 1;
|
|
|
overflow: hidden;
|
|
|
@@ -1035,6 +1293,18 @@ onMounted(() => {
|
|
|
flex-direction: column;
|
|
|
}
|
|
|
|
|
|
+.class-status-filter {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.class-status-filter__label {
|
|
|
+ color: #606266;
|
|
|
+ font-size: 13px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
.dept-tree {
|
|
|
height: calc(100vh - 280px); /* 距离视窗底部100px,进一步减少高度 */
|
|
|
overflow-y: auto;
|
|
|
@@ -1079,6 +1349,22 @@ onMounted(() => {
|
|
|
min-height: 32px;
|
|
|
}
|
|
|
|
|
|
+.dept-tree-node-label {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.dept-tree-node-text {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+
|
|
|
+.dept-tree-node-tag {
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
/* 确保展开图标有足够空间 */
|
|
|
.dept-tree :deep(.el-tree-node__expand-icon) {
|
|
|
margin-right: 4px;
|
|
|
@@ -1095,7 +1381,7 @@ onMounted(() => {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
|
- padding: 8px 0 !important; /* 减少头部内边距 */
|
|
|
+ padding: 0 !important;
|
|
|
margin-bottom: 6px; /* 减少与搜索框的间距 */
|
|
|
}
|
|
|
|