提交代码

This commit is contained in:
zlwu committed 2025-08-14 18:13:34 +08:00
1 parent 1db2f91af1
commit 574660ce9f
20 files changed
+2790 -144

No files matched your search

+12
View File
@@ -94,6 +94,18 @@ export const getBedDetailsByBuildingId = (buildingId: string | number): AxiosPro
});
};
/**
* 查询指定房间中属于指定班级的床位详细信息列表(包含学生信息)
* @param roomId 房间ID
* @param classId 班级ID
*/
export const getBedDetailsByRoomIdAndClassId = (roomId: string | number, classId: string | number): AxiosPromise<DormBedDetailVO[]> => {
return request({
url: `/dormbed/dormBed/details/room/${roomId}/class/${classId}`,
method: 'get'
});
};
/**
* 批量分配床位给二级学院
* @param data 批量分配参数
+26
View File
@@ -64,6 +64,32 @@ export const updateDormRoom = (data: DormRoomForm) => {
});
};
/**
* 查询指定班级的宿舍房间信息列表
* @param classId 班级ID
* @param query 查询条件
* @returns {*}
*/
export const listDormRoomByClassId = (classId: string | number, query?: DormRoomQuery): AxiosPromise<DormRoomVO[]> => {
return request({
url: `/xgss/dormRoom/class/${classId}/list`,
method: 'get',
params: query
});
};
/**
* 查询指定房间的床位详细信息列表(包含学生信息)
* @param roomId 房间ID
* @returns {*}
*/
export const getDormBedDetailsByRoomId = (roomId: string | number): AxiosPromise<any[]> => {
return request({
url: `/dormbed/dormBed/details/room/${roomId}`,
method: 'get'
});
};
/**
* 删除宿舍房间信息
* @param id
+685
View File
@@ -0,0 +1,685 @@
<template>
<div class="p-2">
<div class="flex-layout">
<!-- 左侧:宿舍树(不含学生树) -->
<div class="tree-sidebar">
<!-- 班级切换 Tabs -->
<el-card shadow="hover" class="tree-card">
<template #header>
<div class="card-header-title">我的班级</div>
</template>
<el-tabs v-model="activeClassId" type="card" @tab-click="handleClassTabChange">
<el-tab-pane v-for="cls in myClassList" :key="String(cls.id)" :label="`${cls.className}`" :name="String(cls.id)" />
</el-tabs>
</el-card>
<!-- 宿舍楼树 -->
<el-card shadow="hover" class="tree-card mt-4">
<div class="card-header-title">宿舍楼</div>
<el-input
v-model="buildingName"
prefix-icon="Search"
placeholder="请输入宿舍楼名称"
clearable
@clear="watchBuildingName"
@keyup.enter="watchBuildingName"
/>
<el-tree
ref="dormTreeRef"
class="mt-2"
node-key="id"
:data="dormOptions"
:props="{ label: 'label', children: 'children', disabled: 'disabled' }"
:expand-on-click-node="false"
:filter-node-method="filterNode"
highlight-current
default-expand-all
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="custom-tree-node">
<el-tooltip v-if="data.disabled" effect="dark" content="此节点不可选择,请选择具体的宿舍楼" placement="right">
<span :class="{ 'disabled-node': data.disabled }">{{ node.label }}</span>
</el-tooltip>
<span v-else>
<span v-if="data.type === 'building'" :class="data.buildingType === '1' ? 'male-building' : 'female-building'">
{{ data.buildingType === '1' ? '(男)' : '(女)' }}
</span>
{{ node.label }}
</span>
</div>
</template>
</el-tree>
</el-card>
</div>
<!-- 右侧内容区 -->
<div class="content-area">
<transition :enter-active-class="animate.searchAnimate.enter" :leave-active-class="animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" inline>
<el-form-item label="房间号" prop="roomName">
<el-input v-model="queryParams.roomName" placeholder="请输入房间号" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="roomStatus">
<el-select v-model="queryParams.roomStatus" placeholder="房间状态" clearable>
<el-option label="正常" value="0" />
<el-option label="维修中" value="1" />
<el-option label="停用" value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</transition>
<el-card v-if="!activeClassId" shadow="hover" class="empty-tip">
<div class="flex flex-col items-center justify-center h-[400px]">
<el-icon class="text-gray-400 text-5xl mb-4"><InfoFilled /></el-icon>
<p class="text-gray-500 text-lg">当前账号没有班主任班级</p>
</div>
</el-card>
<el-card v-else-if="!selectedBuilding" shadow="hover" class="empty-tip">
<div class="flex flex-col items-center justify-center h-[400px]">
<el-icon class="text-gray-400 text-5xl mb-4"><InfoFilled /></el-icon>
<p class="text-gray-500 text-lg">请在左侧选择宿舍楼</p>
</div>
</el-card>
<template v-else>
<el-card shadow="hover" class="mb-4">
<template #header>
<el-row>
<el-col :span="12">
<el-button type="primary" icon="check" size="small" @click="openSelectiveAllocation">分配给学生</el-button>
<el-button type="success" icon="Refresh" size="small" @click="handleRandomAllocation" :loading="actionLoading"
>一键随机分配</el-button
>
<el-button type="primary" icon="Sort" size="small" @click="handleStudentNoAllocation" :loading="actionLoading"
>按学号分配</el-button
>
<el-button type="warning" icon="RefreshLeft" size="small" @click="handleRecall" :loading="actionLoading">一键收回</el-button>
</el-col>
<el-col :span="12" style="text-align: right">
<el-button link type="primary" size="small" :loading="expandAllLoading" :disabled="expandAllLoading || loading" @click="expandAll"
>全部展开</el-button
>
<el-button
link
type="primary"
size="small"
:loading="collapseAllLoading"
:disabled="collapseAllLoading || loading"
@click="collapseAll"
>全部收起</el-button
>
</el-col>
</el-row>
</template>
<!-- 操作信息 -->
<div class="mb-4">
<el-row>
<el-col :span="24">
<div class="flex flex-wrap items-center gap-4">
<span class="font-bold whitespace-nowrap"
>{{ selectedBuilding?.label }}
<span :class="selectedBuilding?.buildingType === '1' ? 'male-building' : 'female-building'">
{{ selectedBuilding?.buildingType === '1' ? '(男生楼)' : '(女生楼)' }}
</span>
- 房间与床位</span
>
<div class="text-blue-500 flex flex-wrap items-center gap-2">
<span class="whitespace-nowrap"> 已选择: {{ selectedBedCount }} 个床位 </span>
<span v-if="selectedBedCount > 0" class="whitespace-nowrap text-sm">
[可分配: <el-tag size="small" type="success">{{ selectedBedStats.available }}</el-tag> 个, 已分配:
<el-tag size="small" type="warning">{{ selectedBedStats.allocated }}</el-tag> 个, 维修中:
<el-tag size="small" type="danger">{{ selectedBedStats.maintenance }}</el-tag>
个]
</span>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 房间及床位树形表格(仅床位可选) -->
<el-table
ref="tableRef"
v-loading="loading || expandAllLoading || collapseAllLoading"
element-loading-text="处理中..."
:data="filteredRoomList"
row-key="id"
border
:expand-row-keys="expandedRowKeys"
@expand-change="handleExpandChange"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
table-layout="auto"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" :selectable="checkSelectable" />
<el-table-column prop="roomName" label="房间/宿舍名称" min-width="160">
<template #default="scope">
<span v-if="!scope.row.isBed">{{ scope.row.roomName }} (房间)</span>
<span v-else>{{ scope.row.bedName }} (床位)</span>
</template>
</el-table-column>
<el-table-column prop="floorNum" label="楼层" width="80" align="center">
<template #default="scope">
<span v-if="!scope.row.isBed">{{ scope.row.floorNum }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="类型" width="120" align="center">
<template #default="scope">
<template v-if="!scope.row.isBed">
<span>{{ scope.row.roomType === '1' ? '标准间' : scope.row.roomType === '2' ? '单人间' : '套间' }}</span>
</template>
<template v-else>
<el-tag v-if="scope.row.bedType === '1'" type="success" size="small">下铺</el-tag>
<el-tag v-else-if="scope.row.bedType === '2'" type="warning" size="small">上铺</el-tag>
<el-tag v-else-if="scope.row.bedType === '3'" type="danger" size="small">中铺</el-tag>
<el-tag v-else type="info" size="small">独立床</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="scope">
<template v-if="!scope.row.isBed">
<div class="flex flex-col items-center gap-1">
<el-tag v-if="scope.row.roomStatus === '0'" type="success">正常</el-tag>
<el-tag v-else-if="scope.row.roomStatus === '1'" type="warning">维修中</el-tag>
<el-tag v-else type="danger">停用</el-tag>
<span v-if="scope.row.roomStatus === '0'" class="text-xs text-gray-500">
(已分配: {{ scope.row.children?.filter((bed) => bed.studentId).length || 0 }}/{{ scope.row.children?.length || 0 }}床位)
</span>
</div>
</template>
<template v-else>
<el-tag v-if="scope.row.bedStatus === '2'" type="danger">维修中</el-tag>
<el-tag v-else-if="scope.row.studentId" type="warning">已分配</el-tag>
<el-tag v-else type="success">空闲</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="学号" width="120" align="center">
<template #default="scope">
<span v-if="scope.row.isBed && scope.row.studentNo">{{ scope.row.studentNo }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="学生姓名" width="100" align="center">
<template #default="scope">
<span v-if="scope.row.isBed && scope.row.studentName">{{ scope.row.studentName }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="性别" width="60" align="center">
<template #default="scope">
<span v-if="scope.row.isBed && scope.row.studentGender !== undefined">
<el-tag v-if="scope.row.studentGender === '0'" type="primary" size="small">男</el-tag>
<el-tag v-else-if="scope.row.studentGender === '1'" class="female-tag" size="small">女</el-tag>
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="150" :show-overflow-tooltip="true">
<template #default="scope">
{{ scope.row.remark || scope.row.bedRemark }}
</template>
</el-table-column>
</el-table>
</el-card>
</template>
</div>
</div>
<!-- 选择性分配对话框(床位 ←→ 学生) -->
<selective-allocation-dialog
v-model="selectiveDialog.visible"
:title="selectiveDialog.title"
:building-info="selectedBuilding"
:class-id="activeClassId"
@success="handleAllocationSuccess"
/>
</div>
</template>
<script setup lang="ts" name="ClassTeacherBedAllocation">
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import type { DormBuildingTreeVO } from '@/api/xg/xgss/dormBedStudent/dormBedStudent/types';
import {
getClassTeacherDormTree,
classTeacherRandomAllocation,
classTeacherStudentNoAllocation,
classTeacherRecallBeds
} from '@/api/xg/xgss/dormBedStudent/dormBedStudent';
import { listDormRoomByClassId, getDormBedDetailsByRoomId } from '@/api/xg/xgss/dormRoom';
import type { ClassMsgVO } from '@/api/xg/xsjcxx/classMsg/types';
import { getMyClassListAsTeacher } from '@/api/xg/xsjcxx/classMsg/classMsg';
import ElMessage from 'element-plus/es/components/message/index';
import ElMessageBox from 'element-plus/es/components/message-box/index';
// 组件
import SelectiveAllocationDialog from './components/SelectiveAllocationDialog.vue';
// 左侧树&查询
const dormTreeRef = ref();
const queryFormRef = ref();
const tableRef = ref();
const buildingName = ref('');
const showSearch = ref(true);
const loading = ref(false);
const expandAllLoading = ref(false);
const collapseAllLoading = ref(false);
const actionLoading = ref(false);
const expandedRowKeys = ref<string[]>([]);
const animate = reactive({
searchAnimate: {
enter: 'animate__animated animate__fadeInDown',
leave: 'animate__animated animate__fadeOutUp'
}
});
// Tabs 班级
const myClassList = ref<ClassMsgVO[]>([]);
const activeClassId = ref<string>('');
// 宿舍树数据
const dormOptions = ref<DormBuildingTreeVO[]>([]);
const selectedBuilding = ref<DormBuildingTreeVO>();
// 房间/床位
const roomList = ref<any[]>([]);
// 查询参数(前端过滤)
const queryParams = reactive({
roomName: '',
roomStatus: undefined as string | undefined
});
// 选择统计
const selectedRows = ref<any[]>([]);
const selectedBedCount = ref(0);
const selectedBedStats = ref({
available: 0,
allocated: 0,
maintenance: 0
});
/** 通过条件过滤节点 */
const filterNode = (value: string, data: DormBuildingTreeVO) => {
if (!value) return true;
return (data.label || '').toLowerCase().indexOf(value.toLowerCase()) !== -1;
};
/** 监听宿舍楼名称变化,筛选树 */
const watchBuildingName = () => {
dormTreeRef.value?.filter(buildingName.value);
};
watch(buildingName, watchBuildingName);
/** 班级切换 */
const handleClassTabChange = async () => {
selectedBuilding.value = undefined;
roomList.value = [];
await loadDormTree();
};
/** 获取我的班级(班主任) */
const loadMyClassList = async () => {
try {
const res = await getMyClassListAsTeacher();
myClassList.value = (res.data || []) as any;
if (myClassList.value.length > 0) {
activeClassId.value = String(myClassList.value[0].id);
await loadDormTree();
}
} catch (e) {
console.error(e);
}
};
/** 获取班主任宿舍楼树 */
const loadDormTree = async () => {
try {
const res = await getClassTeacherDormTree(activeClassId.value);
dormOptions.value = (res.data || []).map((node: any) => ({
...node,
disabled: node.type !== 'building'
}));
} catch (e) {
console.error('获取宿舍楼树失败', e);
}
};
/** 节点点击:仅 building 有效 */
const handleNodeClick = (data: any) => {
if (data.type !== 'building') return;
selectedBuilding.value = data;
handleQuery();
};
/** 查询(重新加载房间床位数据) */
const handleQuery = () => {
loadRoomData();
};
/** 重置查询 */
const resetQuery = () => {
queryFormRef.value?.resetFields?.();
handleQuery();
};
/** 加载房间与床位(使用新的班级房间查询接口) */
const loadRoomData = async () => {
if (!selectedBuilding.value) return;
loading.value = true;
try {
// 1. 调用新的班级房间查询接口,获取指定班级在指定宿舍楼的房间信息
const roomRes = await listDormRoomByClassId(activeClassId.value as any, {
buildingId: selectedBuilding.value.id as any,
pageNum: 1,
pageSize: 1000 // 设置较大的页面大小获取所有数据
});
// 后端返回的是TableDataInfo格式,从rows字段获取实际数据
const roomData = (roomRes.data as any)?.rows || roomRes.data || [];
// 2. 为每个房间获取床位详细信息
const roomsWithBeds = await Promise.all(
roomData.map(async (room: any) => {
try {
const bedRes = await getDormBedDetailsByRoomId(room.id);
const bedData = bedRes.data || [];
return {
...room,
isBed: false,
children: bedData.map((bed: any) => ({
...bed,
isBed: true
}))
};
} catch (e) {
console.warn(`获取房间 ${room.id} 的床位信息失败:`, e);
return {
...room,
isBed: false,
children: []
};
}
})
);
roomList.value = roomsWithBeds;
// 默认清空选择
nextTick(() => tableRef.value?.clearSelection());
selectedRows.value = [];
updateSelectionCount([]);
} catch (e) {
console.error('加载房间床位失败', e);
} finally {
loading.value = false;
}
};
/** 过滤后的房间数据(前端过滤) */
const filteredRoomList = computed(() => {
const name = (queryParams.roomName || '').trim().toLowerCase();
const status = queryParams.roomStatus;
if (!name && !status) return roomList.value;
const matchRoom = (room: any) => {
const nameOk = !name || (room.roomName || '').toLowerCase().includes(name);
const statusOk = !status || room.roomStatus === status;
return nameOk && statusOk;
};
return roomList.value
.filter((room) => matchRoom(room))
.map((room) => ({
...room,
children: room.children || []
}));
});
/** 展开/收起 */
const handleExpandChange = (row: any, expanded: boolean) => {
const key = String(row.id);
if (expanded) expandedRowKeys.value.push(key);
else expandedRowKeys.value = expandedRowKeys.value.filter((k) => k !== key);
};
const expandAll = async () => {
if (expandAllLoading.value || !filteredRoomList.value?.length) return;
expandAllLoading.value = true;
await nextTick();
expandedRowKeys.value = filteredRoomList.value.map((room) => String(room.id));
await nextTick();
expandAllLoading.value = false;
};
const collapseAll = async () => {
if (collapseAllLoading.value) return;
collapseAllLoading.value = true;
await nextTick();
expandedRowKeys.value = [];
await nextTick();
collapseAllLoading.value = false;
};
/** 行可选(仅未分配且非维修中的床位可选) */
const checkSelectable = (row: any) => {
return row.isBed && !row.studentId && row.bedStatus !== '2';
};
/** 选择变化 */
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
updateSelectionCount(rows);
};
/** 更新选择统计 */
const updateSelectionCount = (rows: any[]) => {
const bedStats = { available: 0, allocated: 0, maintenance: 0 } as any;
rows.forEach((row) => {
if (!row.isBed) return;
if (row.bedStatus === '2') bedStats.maintenance++;
else if (row.studentId) bedStats.allocated++;
else bedStats.available++;
});
selectedBedCount.value = rows.filter((r) => r.isBed).length;
selectedBedStats.value = bedStats;
};
/** 打开选择性分配弹窗 */
const selectiveDialog = reactive({ visible: false, title: '分配床位给学生' });
const openSelectiveAllocation = () => {
if (!selectedBuilding.value) {
ElMessage.warning('请先选择宿舍楼');
return;
}
selectiveDialog.visible = true;
};
/** 分配成功回调(刷新) */
const handleAllocationSuccess = () => {
loadRoomData();
};
/** 一键随机分配 */
const handleRandomAllocation = async () => {
if (!selectedBuilding.value) return ElMessage.warning('请先选择宿舍楼');
try {
await ElMessageBox.confirm('确认对本班级在该楼栋内进行随机分配吗?', '提示', { type: 'warning' });
} catch {
return;
}
try {
actionLoading.value = true;
await classTeacherRandomAllocation(selectedBuilding.value.id as any, activeClassId.value as any);
ElMessage.success('随机分配成功');
loadRoomData();
} catch (e) {
ElMessage.error('随机分配失败');
} finally {
actionLoading.value = false;
}
};
/** 按学号分配 */
const handleStudentNoAllocation = async () => {
if (!selectedBuilding.value) return ElMessage.warning('请先选择宿舍楼');
try {
await ElMessageBox.confirm('确认按学号顺序为学生分配床位吗?', '提示', { type: 'warning' });
} catch {
return;
}
try {
actionLoading.value = true;
await classTeacherStudentNoAllocation(selectedBuilding.value.id as any, activeClassId.value as any);
ElMessage.success('按学号分配成功');
loadRoomData();
} catch (e) {
ElMessage.error('按学号分配失败');
} finally {
actionLoading.value = false;
}
};
/** 一键收回 */
const handleRecall = async () => {
if (!selectedBuilding.value) return ElMessage.warning('请先选择宿舍楼');
try {
await ElMessageBox.confirm('确认收回本班级在该楼栋内的所有床位分配吗?', '提示', { type: 'warning' });
} catch {
return;
}
try {
actionLoading.value = true;
await classTeacherRecallBeds(selectedBuilding.value.id as any, activeClassId.value as any);
ElMessage.success('收回成功');
loadRoomData();
} catch (e) {
ElMessage.error('收回失败');
} finally {
actionLoading.value = false;
}
};
onMounted(() => {
loadMyClassList();
});
</script>
<style scoped>
.flex-layout {
display: flex;
gap: 16px;
}
.tree-sidebar {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.tree-card {
flex: 1;
max-height: calc(50vh - 24px);
display: flex;
flex-direction: column;
}
.tree-card :deep(.el-card__body) {
flex: 1;
overflow-y: auto;
overflow-x: auto;
padding-top: 10px;
}
.content-area {
flex-grow: 1;
overflow: visible;
min-width: 0;
}
.el-card {
overflow: visible !important;
}
.el-table :deep(.el-table__cell) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.el-table :deep(.el-table__fixed-right) {
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
}
.empty-tip {
height: 100%;
}
.el-card :deep(.el-table) {
width: 100% !important;
overflow: visible !important;
}
.el-card :deep(.el-table__body-wrapper) {
overflow-x: auto !important;
overflow-y: auto !important;
}
.el-card :deep(.el-table__header-wrapper) {
overflow: visible !important;
}
.el-card :deep(.el-table__body),
.el-card :deep(.el-table__header) {
min-width: 100%;
width: 100%;
}
.el-card :deep(.el-table__inner-wrapper) {
overflow: visible !important;
}
.card-header-title {
font-weight: bold;
margin-bottom: 10px;
font-size: 16px;
color: var(--el-color-primary);
}
.custom-tree-node {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
min-width: max-content;
}
.disabled-node {
cursor: not-allowed;
}
.male-building {
color: var(--el-color-primary);
font-weight: bold;
}
.female-building {
color: #ff69b4;
font-weight: bold;
}
.female-tag {
background-color: rgba(255, 105, 180, 0.1) !important;
color: #ff69b4 !important;
border-color: rgba(255, 105, 180, 0.1) !important;
}
</style>
+128 -18
View File
@@ -95,9 +95,9 @@
<template #header>
<el-row>
<el-col :span="12">
<el-button type="primary" icon="UserFilled" size="small" @click="handleSelectiveAllocation">选择分配</el-button>
<el-button type="success" icon="Shuffle" size="small" @click="handleRandomAllocation">随机分配</el-button>
<el-button type="info" icon="Sort" size="small" @click="handleStudentNoAllocation">按学号分配</el-button>
<el-button type="primary" icon="UserFilled" size="small" @click="handleSelectiveAllocation">分配床位</el-button>
<!-- <el-button type="success" icon="UserFilled" size="small" @click="handleRandomAllocation">随机分配</el-button>
<el-button type="info" icon="Sort" size="small" @click="handleStudentNoAllocation">按学号分配</el-button> -->
<el-button type="warning" icon="RefreshLeft" size="small" @click="handleRecallBeds">收回床位</el-button>
</el-col>
<el-col :span="12" style="text-align: right">
@@ -136,6 +136,16 @@
- 床位分配管理</span
>
<div class="text-blue-500 flex flex-wrap items-center gap-2">
<span v-if="unallocatedStudentStats.total > 0" class="text-orange-500 text-sm">
未分配学生:
<el-tag size="small" type="warning">{{ unallocatedStudentStats.total }}</el-tag
>人
<span class="ml-2 text-xs">
[男生 <el-tag size="small" type="primary">{{ unallocatedStudentStats.male }}</el-tag
>人, 女生 <el-tag size="small" class="female-tag">{{ unallocatedStudentStats.female }}</el-tag
>人]
</span>
</span>
<span class="whitespace-nowrap"> 已选择: {{ selectedRoomCount }}个房间 / {{ selectedBedCount }}个床位 </span>
<span v-if="selectedBedCount > 0" class="whitespace-nowrap text-sm">
[可分配: <el-tag size="small" type="success">{{ selectedBedStats.available }}</el-tag
@@ -143,10 +153,6 @@
>个, 维修中: <el-tag size="small" type="danger">{{ selectedBedStats.maintenance }}</el-tag
>个]
</span>
<span v-if="unallocatedStudentCount > 0" class="text-orange-500 text-sm">
未分配学生: <el-tag size="small" type="warning">{{ unallocatedStudentCount }}</el-tag
>人
</span>
</div>
</div>
</el-col>
@@ -170,10 +176,16 @@
:row-class-name="getRowClassName"
>
<el-table-column type="selection" width="55" :selectable="checkSelectable" />
<el-table-column prop="roomName" label="房间/宿舍名称" min-width="160">
<el-table-column prop="roomName" label="房间/宿舍名称" min-width="180">
<template #default="scope">
<span v-if="!scope.row.isBed">{{ scope.row.roomName }} (房间)</span>
<span v-else>{{ scope.row.bedName }} (床位)</span>
<span v-if="!scope.row.isBed" class="inline-flex items-center gap-1 whitespace-nowrap">
<el-icon><House /></el-icon>
{{ scope.row.roomName }}
</span>
<span v-else class="inline-flex items-center gap-1 whitespace-nowrap">
<el-icon><Avatar /></el-icon>
{{ scope.row.bedName }}
</span>
</template>
</el-table-column>
<el-table-column prop="floorNum" label="楼层" width="80" align="center">
@@ -277,6 +289,7 @@
:title="selectiveAllocationDialog.title"
:building-info="selectiveAllocationDialog.buildingInfo"
:class-id="activeClassId"
:parent-class-info="currentClassInfo"
@success="handleAllocationSuccess"
/>
</div>
@@ -284,16 +297,18 @@
<script setup name="ClassTeacherBedAllocation" lang="ts">
import { ref, computed, reactive, onMounted, nextTick, watch } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import { InfoFilled, House, Avatar } from '@element-plus/icons-vue';
import type { DormBuildingTreeVO } from '@/api/xg/xgss/dormBedStudent/dormBedStudent/types';
import {
getClassTeacherDormTree,
getClassTeacherBedDetails,
getClassTeacherUnallocatedStudents,
classTeacherRandomAllocation,
classTeacherStudentNoAllocation,
classTeacherRecallBeds
} from '@/api/xg/xgss/dormBedStudent/dormBedStudent';
// 导入API方法
import { listDormRoomByClassId } from '@/api/xg/xgss/dormRoom/index';
import { getBedDetailsByRoomIdAndClassId } from '@/api/xg/xgss/dormBed/index';
import { getMyClassListAsTeacher } from '@/api/xg/xsjcxx/classMsg/classMsg';
// 定义一个最小班级信息类型以避免跨文件类型不兼容
interface SimpleClassVO {
@@ -329,6 +344,11 @@ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
const classList = ref<SimpleClassVO[]>([]);
const activeClassId = ref<string>('');
// 当前选中的班级信息
const currentClassInfo = computed(() => {
return classList.value.find((cls) => String(cls.id) === activeClassId.value) || null;
});
// 选择状态
const selectedRows = ref<any[]>([]);
const selectedRoomCount = ref(0);
@@ -342,6 +362,12 @@ const selectedBedStats = ref({
// 未分配学生数量
const unallocatedStudentCount = ref(0);
// 按性别统计的未分配学生数量
const unallocatedStudentStats = ref({
male: 0, // 男生数量
female: 0, // 女生数量
total: 0 // 总数量
});
const animate = reactive({
searchAnimate: {
@@ -432,6 +458,7 @@ const initForActiveClass = async () => {
selectedBedCount.value = 0;
selectedBedStats.value = { available: 0, allocated: 0, maintenance: 0 };
unallocatedStudentCount.value = 0;
unallocatedStudentStats.value = { male: 0, female: 0, total: 0 };
// 重新加载宿舍树
await getDormTreeData();
};
@@ -485,18 +512,72 @@ const resetQuery = () => {
/** 加载房间数据 */
const loadRoomData = async () => {
if (!queryParams.buildingId) return;
if (!queryParams.buildingId || !activeClassId.value) return;
loading.value = true;
const controller = currentController;
try {
// 获取班主任管理班级的房间床位详情
const res = await getClassTeacherBedDetails(queryParams.buildingId, activeClassId.value);
// 构建查询参数
const query = {
buildingId: queryParams.buildingId,
roomName: queryParams.roomName,
roomStatus: queryParams.roomStatus,
pageNum: queryParams.pageNum,
pageSize: queryParams.pageSize
};
// 获取指定班级的宿舍房间信息列表
const res = await listDormRoomByClassId(activeClassId.value, query);
const rooms = res.rows || [];
if (controller?.signal?.aborted) return;
roomList.value = res.data || [];
// 并行获取所有房间的床位详细信息
const roomPromises = rooms.map(async (room) => {
// 创建房间记录
const roomRecord = {
...room,
isBed: false,
children: []
};
try {
// 调用新的接口获取指定房间和班级的床位详细信息
const bedRes = await getBedDetailsByRoomIdAndClassId(room.id, activeClassId.value);
// 如果请求已被取消,不继续处理
if (controller?.signal?.aborted) return roomRecord;
const beds = bedRes.data || [];
// 将床位信息转换为符合表格展示的格式
if (beds.length > 0) {
roomRecord.children = beds.map((bed) => ({
...bed,
isBed: true,
roomName: room.roomName // 保留房间名称,方便展示
}));
}
} catch (error: any) {
// 忽略请求被取消的错误
if (error.name !== 'AbortError') {
console.error(`获取房间 ${room.id} 的床位详细信息失败`, error);
}
}
return roomRecord;
});
// 等待所有请求完成
const tempList = await Promise.all(roomPromises);
// 如果请求已被取消,不更新UI
if (controller?.signal?.aborted) return;
roomList.value = tempList;
console.log('房间数据:', tempList);
// 构建床位到房间的映射缓存
buildBedToRoomMap();
@@ -520,6 +601,12 @@ const loadRoomData = async () => {
isUpdatingSelection.value = false;
}
// 自动展开所有房间行,这样用户可以立即看到床位信息
await nextTick();
await nextTick(); // 双重 nextTick 确保 DOM 完全更新
expandedRowKeys.value = tempList.map((room) => String(room.id));
console.log('设置展开的房间ID:', expandedRowKeys.value);
// 获取未分配学生数量
await getUnallocatedStudentCount();
} catch (error: any) {
@@ -537,7 +624,22 @@ const loadRoomData = async () => {
const getUnallocatedStudentCount = async () => {
try {
const res = await getClassTeacherUnallocatedStudents(activeClassId.value);
unallocatedStudentCount.value = res.data?.length || 0;
const students = res.data || [];
// 按性别统计
const maleCount = students.filter((student) => student.gender === '0').length;
const femaleCount = students.filter((student) => student.gender === '1').length;
const totalCount = students.length;
// 更新统计数据
unallocatedStudentStats.value = {
male: maleCount,
female: femaleCount,
total: totalCount
};
// 保持向后兼容
unallocatedStudentCount.value = totalCount;
} catch (error) {
console.error('获取未分配学生数量失败', error);
}
@@ -638,8 +740,15 @@ const handleRecallBeds = async () => {
const handleAllocationSuccess = () => {
// 清除缓存,确保数据刷新
selectableRowsCache.value = [];
// 保存当前展开状态
const currentExpandedKeys = [...expandedRowKeys.value];
// 刷新房间和床位数据
loadRoomData();
loadRoomData().then(() => {
// 数据刷新后,恢复展开状态
nextTick(() => {
expandedRowKeys.value = currentExpandedKeys;
});
});
// 清空选择状态
isUpdatingSelection.value = true;
try {
@@ -647,6 +756,7 @@ const handleAllocationSuccess = () => {
selectedRoomCount.value = 0;
selectedBedCount.value = 0;
selectedBedStats.value = { available: 0, allocated: 0, maintenance: 0 };
unallocatedStudentStats.value = { male: 0, female: 0, total: 0 };
} finally {
isUpdatingSelection.value = false;
}
@@ -0,0 +1,519 @@
<template>
<el-dialog v-model="dialogVisible" :title="title" width="1200px" :before-close="handleClose" destroy-on-close class="selective-allocation-dialog">
<div class="allocation-container">
<!-- 左侧:床位选择区域 -->
<div class="selection-area">
<div class="area-header">
<h3 class="area-title">
<el-icon><House /></el-icon>
可用床位选择
</h3>
<div class="stats">
已选择 <el-tag type="primary" size="small">{{ selectedBeds.length }}</el-tag> 个床位
</div>
</div>
<div class="search-bar">
<el-input v-model="bedSearchKey" placeholder="搜索房间号、床位号" prefix-icon="Search" clearable @input="handleBedSearch" />
</div>
<div class="bed-list" v-loading="bedLoading">
<el-table
ref="bedTableRef"
:data="filteredBedList"
row-key="id"
height="400"
@selection-change="handleBedSelectionChange"
@select-all="handleBedSelectAll"
>
<el-table-column type="selection" width="55" :selectable="checkBedSelectable" />
<el-table-column prop="roomName" label="房间" width="100" />
<el-table-column prop="bedName" label="床位" width="100" />
<el-table-column label="类型" width="80" align="center">
<template #default="scope">
<el-tag v-if="scope.row.bedType === '1'" type="success" size="small">下铺</el-tag>
<el-tag v-else-if="scope.row.bedType === '2'" type="warning" size="small">上铺</el-tag>
<el-tag v-else-if="scope.row.bedType === '3'" type="danger" size="small">中铺</el-tag>
<el-tag v-else type="info" size="small">独立床</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default>
<el-tag type="success" size="small">空闲</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 中间:操作区域 -->
<div class="operation-area">
<div class="match-info">
<el-icon class="match-icon"><Connection /></el-icon>
<div class="match-stats">
<div class="stat-item">
<span class="label">床位:</span>
<span class="value">{{ selectedBeds.length }}</span>
</div>
<div class="stat-item">
<span class="label">学生:</span>
<span class="value">{{ selectedStudents.length }}</span>
</div>
<div class="match-status" :class="{ 'matched': isMatched, 'unmatched': !isMatched }">
{{ isMatched ? '✓ 数量匹配' : '✗ 数量不匹配' }}
</div>
</div>
</div>
<div class="allocation-buttons">
<el-button type="success" icon="Refresh" :disabled="!isMatched" @click="handleRandomAllocation"> 随机分配 </el-button>
<el-button type="primary" icon="Sort" :disabled="!isMatched" @click="handleStudentNoAllocation"> 按学号分配 </el-button>
</div>
</div>
<!-- 右侧:学生选择区域 -->
<div class="selection-area">
<div class="area-header">
<h3 class="area-title">
<el-icon><User /></el-icon>
未分配学生选择
</h3>
<div class="stats">
已选择 <el-tag type="warning" size="small">{{ selectedStudents.length }}</el-tag> 名学生
</div>
</div>
<div class="search-bar">
<el-input v-model="studentSearchKey" placeholder="搜索学号、姓名" prefix-icon="Search" clearable @input="handleStudentSearch" />
</div>
<div class="student-list" v-loading="studentLoading">
<el-table
ref="studentTableRef"
:data="filteredStudentList"
row-key="id"
height="400"
@selection-change="handleStudentSelectionChange"
@select-all="handleStudentSelectAll"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="studentNo" label="学号" width="120" />
<el-table-column prop="studentName" label="姓名" width="100" />
<el-table-column label="性别" width="60" align="center">
<template #default="scope">
<el-tag v-if="scope.row.studentGender === '0'" type="primary" size="small">男</el-tag>
<el-tag v-else-if="scope.row.studentGender === '1'" class="female-tag" size="small">女</el-tag>
</template>
</el-table-column>
<el-table-column prop="className" label="班级" min-width="120" :show-overflow-tooltip="true" />
</el-table>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!isMatched">确认分配</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { House, User, Connection } from '@element-plus/icons-vue';
import type { DormBuildingTreeVO } from '@/api/xg/xgss/dormBedStudent/dormBedStudent/types';
import {
getClassTeacherBedDetails,
getClassTeacherUnallocatedStudents,
classTeacherSelectiveAllocation
} from '@/api/xg/xgss/dormBedStudent/dormBedStudent';
import ElMessage from 'element-plus/es/components/message/index';
// 定义组件选项
defineOptions({
name: 'SelectiveAllocationDialog'
});
interface Props {
modelValue: boolean;
title?: string;
buildingInfo?: DormBuildingTreeVO;
classId?: string | number;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'success'): void;
}
const props = withDefaults(defineProps<Props>(), {
title: '选择性分配床位'
});
const emit = defineEmits<Emits>();
// 对话框显示状态
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// 加载状态
const bedLoading = ref(false);
const studentLoading = ref(false);
// 搜索关键字
const bedSearchKey = ref('');
const studentSearchKey = ref('');
// 表格引用
const bedTableRef = ref();
const studentTableRef = ref();
// 数据列表
const bedList = ref<any[]>([]);
const studentList = ref<any[]>([]);
const selectedBeds = ref<any[]>([]);
const selectedStudents = ref<any[]>([]);
// 计算属性:过滤后的列表
const filteredBedList = computed(() => {
if (!bedSearchKey.value) return bedList.value;
const key = bedSearchKey.value.toLowerCase();
return bedList.value.filter((bed) => (bed.roomName || '').toLowerCase().includes(key) || (bed.bedName || '').toLowerCase().includes(key));
});
const filteredStudentList = computed(() => {
if (!studentSearchKey.value) return studentList.value;
const key = studentSearchKey.value.toLowerCase();
return studentList.value.filter(
(student) => (student.studentNo || '').toLowerCase().includes(key) || (student.studentName || '').toLowerCase().includes(key)
);
});
// 计算属性:是否匹配
const isMatched = computed(() => {
return selectedBeds.value.length > 0 && selectedStudents.value.length > 0 && selectedBeds.value.length === selectedStudents.value.length;
});
// 监听建筑信息变化,加载数据
watch(
() => props.buildingInfo,
async (newValue) => {
if (newValue && props.modelValue) {
await loadData();
}
},
{ immediate: true }
);
// 监听对话框打开,加载数据
watch(
() => props.modelValue,
async (newValue) => {
if (newValue && props.buildingInfo) {
await loadData();
}
}
);
/** 加载数据 */
const loadData = async () => {
await Promise.all([loadBedData(), loadStudentData()]);
};
/** 加载床位数据 */
const loadBedData = async () => {
if (!props.buildingInfo) return;
bedLoading.value = true;
try {
const res = await getClassTeacherBedDetails(props.buildingInfo.id as any, props.classId as any);
const allBeds: any[] = [];
(res.data || []).forEach((room: any) => {
if (room.children && room.children.length > 0) {
room.children.forEach((bed: any) => {
// 只要空闲床位(没有学生分配)
if (!bed.studentId && bed.bedStatus !== '2') {
allBeds.push({
...bed,
roomName: room.roomName
});
}
});
}
});
bedList.value = allBeds;
} catch (error) {
console.error('获取床位数据失败', error);
ElMessage.error('获取床位数据失败');
} finally {
bedLoading.value = false;
}
};
/** 加载学生数据 */
const loadStudentData = async () => {
studentLoading.value = true;
try {
const res = await getClassTeacherUnallocatedStudents(props.classId as any);
studentList.value = res.data || [];
} catch (error) {
console.error('获取学生数据失败', error);
ElMessage.error('获取学生数据失败');
} finally {
studentLoading.value = false;
}
};
/** 床位搜索 */
const handleBedSearch = () => {};
/** 学生搜索 */
const handleStudentSearch = () => {};
/** 检查床位是否可选 */
const checkBedSelectable = (row: any) => {
return !row.studentId && row.bedStatus !== '2';
};
/** 床位选择变化 */
const handleBedSelectionChange = (selection: any[]) => {
selectedBeds.value = selection;
};
/** 床位全选 */
const handleBedSelectAll = (selection: any[]) => {
selectedBeds.value = selection;
};
/** 学生选择变化 */
const handleStudentSelectionChange = (selection: any[]) => {
selectedStudents.value = selection;
};
/** 学生全选 */
const handleStudentSelectAll = (selection: any[]) => {
selectedStudents.value = selection;
};
/** 随机分配 */
const handleRandomAllocation = async () => {
if (!isMatched.value) {
ElMessage.warning('请确保床位和学生数量匹配');
return;
}
try {
const allocationParams = {
bedIds: selectedBeds.value.map((bed) => bed.id),
studentIds: selectedStudents.value.map((student) => student.id),
allocationType: 'random' as const
};
await classTeacherSelectiveAllocation(allocationParams, props.classId as any);
ElMessage.success('随机分配成功');
handleSuccess();
} catch (error) {
ElMessage.error('随机分配失败');
}
};
/** 按学号分配 */
const handleStudentNoAllocation = async () => {
if (!isMatched.value) {
ElMessage.warning('请确保床位和学生数量匹配');
return;
}
try {
const allocationParams = {
bedIds: selectedBeds.value.map((bed) => bed.id),
studentIds: selectedStudents.value.map((student) => student.id),
allocationType: 'studentNo' as const
};
await classTeacherSelectiveAllocation(allocationParams, props.classId as any);
ElMessage.success('按学号分配成功');
handleSuccess();
} catch (error) {
ElMessage.error('按学号分配失败');
}
};
/** 确认分配(默认随机) */
const handleConfirm = () => {
handleRandomAllocation();
};
/** 分配成功处理 */
const handleSuccess = () => {
emit('success');
handleClose();
};
/** 关闭对话框 */
const handleClose = () => {
selectedBeds.value = [];
selectedStudents.value = [];
bedSearchKey.value = '';
studentSearchKey.value = '';
bedList.value = [];
studentList.value = [];
nextTick(() => {
bedTableRef.value?.clearSelection();
studentTableRef.value?.clearSelection();
});
dialogVisible.value = false;
};
</script>
<style scoped>
.selective-allocation-dialog :deep(.el-dialog__body) {
padding: 20px;
}
.allocation-container {
display: flex;
gap: 20px;
height: 500px;
}
.selection-area {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
}
.area-header {
background: #f5f7fa;
padding: 15px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
}
.area-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
}
.stats {
font-size: 14px;
color: #606266;
}
.search-bar {
padding: 15px;
border-bottom: 1px solid #e4e7ed;
}
.bed-list,
.student-list {
flex: 1;
padding: 15px;
overflow: hidden;
}
.operation-area {
width: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 30px;
padding: 20px;
background: #fafafa;
border-radius: 8px;
}
.match-info {
text-align: center;
}
.match-icon {
font-size: 32px;
color: #909399;
margin-bottom: 15px;
}
.match-stats {
margin-bottom: 15px;
}
.stat-item {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.label {
color: #606266;
}
.value {
font-weight: 600;
color: #303133;
}
.match-status {
font-size: 14px;
font-weight: 600;
padding: 8px 12px;
border-radius: 4px;
transition: all 0.3s;
}
.match-status.matched {
background: #f0f9ff;
color: #67c23a;
border: 1px solid #b3e19d;
}
.match-status.unmatched {
background: #fef0f0;
color: #f56c6c;
border: 1px solid #f5c6cb;
}
.allocation-buttons {
display: flex;
flex-direction: column;
gap: 15px;
width: 100%;
}
.allocation-buttons .el-button {
width: 100%;
}
.dialog-footer {
text-align: right;
}
.female-tag {
background-color: rgba(255, 105, 180, 0.1) !important;
color: #ff69b4 !important;
border-color: rgba(255, 105, 180, 0.1) !important;
}
.selection-area :deep(.el-table) {
border: none;
}
.selection-area :deep(.el-table__header) {
background: #f8f9fa;
}
.selection-area :deep(.el-table th) {
background: #f8f9fa !important;
color: #606266;
font-weight: 600;
}
.selection-area :deep(.el-table td) {
border-bottom: 1px solid #f0f0f0;
}
.selection-area :deep(.el-table__row:hover > td) {
background-color: #f5f7fa !important;
}
</style>
File diff suppressed because it is too large. Load diff
+11 -5
View File
@@ -55,7 +55,7 @@
<!-- 学生树 -->
<el-card shadow="hover" class="tree-card mt-4">
<div class="card-header-title">学生信息</div>
<div class="card-header-title">班级床位需求信息(已分配/总需求)</div>
<el-input
v-model="studentSearchKey"
prefix-icon="Search"
@@ -205,10 +205,16 @@
:row-class-name="getRowClassName"
>
<el-table-column type="selection" width="55" :selectable="checkSelectable" />
<el-table-column prop="roomName" label="房间/宿舍名称" min-width="160">
<el-table-column prop="roomName" label="房间/宿舍名称" min-width="180">
<template #default="scope">
<span v-if="!scope.row.isBed">{{ scope.row.roomName }} (房间)</span>
<span v-else>{{ scope.row.bedName }} (床位)</span>
<span v-if="!scope.row.isBed" class="inline-flex items-center gap-1 whitespace-nowrap">
<el-icon><House /></el-icon>
{{ scope.row.roomName }}
</span>
<span v-else class="inline-flex items-center gap-1 whitespace-nowrap">
<el-icon><Avatar /></el-icon>
{{ scope.row.bedName }}
</span>
</template>
</el-table-column>
<el-table-column prop="floorNum" label="楼层" width="80" align="center">
@@ -352,7 +358,7 @@
<script setup name="DormRoomTreeManagement" lang="ts">
import { ref, computed, reactive, onMounted, nextTick, watch, getCurrentInstance } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import { InfoFilled, House, Avatar } from '@element-plus/icons-vue';
import type { DormBuildingTreeVO } from '@/api/xg/xgss/dormBedStudent/dormBedStudent/types';
import type { DormTreeNode, BedInfo } from '@/api/xg/xgss/xsssfjgl/types';
import type { DormRoomVO } from '@/api/xg/xgss/dormRoom/types';
+10 -4
View File
@@ -205,10 +205,16 @@
:row-class-name="getRowClassName"
>
<el-table-column type="selection" width="55" :selectable="checkSelectable" />
<el-table-column prop="roomName" label="房间/宿舍名称" min-width="160">
<el-table-column prop="roomName" label="房间/宿舍名称" min-width="180">
<template #default="scope">
<span v-if="!scope.row.isBed">{{ scope.row.roomName }} (房间)</span>
<span v-else>{{ scope.row.bedName }} (床位)</span>
<span v-if="!scope.row.isBed" class="inline-flex items-center gap-1 whitespace-nowrap">
<el-icon><House /></el-icon>
{{ scope.row.roomName }}
</span>
<span v-else class="inline-flex items-center gap-1 whitespace-nowrap">
<el-icon><Avatar /></el-icon>
{{ scope.row.bedName }}
</span>
</template>
</el-table-column>
<el-table-column prop="floorNum" label="楼层" width="80" align="center">
@@ -350,7 +356,7 @@
<script setup name="DormRoomTreeManagement" lang="ts">
import { ref, computed, reactive, onMounted, nextTick, watch, getCurrentInstance } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import { InfoFilled, House, Avatar } from '@element-plus/icons-vue';
import type { DormTreeNode, BedInfo } from '@/api/xg/xgss/xsssfjgl/types';
import type { DormRoomVO } from '@/api/xg/xgss/dormRoom/types';
import type { DormBedDetailVO } from '@/api/xg/xgss/dormBed/types';