Files
railwaysystem/build_ui/src/views/train/teacher/index.vue
T

1721 lines
63 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">教师信息</h2>
<span class="topbar-sub">在册 {{ total }} 人 </span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出教师台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增教师</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px" class="filter-form">
<el-form-item label="教师姓名" prop="tName">
<el-input
v-model="queryParams.tName"
placeholder="请输入教师姓名"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<!-- <el-form-item label="人员编号" prop="personNo">
<el-input
v-model="queryParams.personNo"
placeholder="请输入人员编号"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>-->
<el-form-item label="所属单位" prop="deptName">
<!-- <el-input
v-model="queryParams.deptName"
placeholder="请输入所属单位"
clearable
@keyup.enter.native="handleQuery"
/>-->
<treeselect style="width: 180px;" v-model="queryParams.deptName" :options="deptOptions" :normalizer="normalizer" :show-count="true" placeholder="请选择归属部门" />
</el-form-item>
<!-- <el-form-item label="职务职称" prop="jobTitle">
<el-input
v-model="queryParams.jobTitle"
placeholder="请输入职务/职称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>-->
<el-form-item label="专业方向" prop="major">
<el-input
v-model="queryParams.major"
placeholder="请输入专业方向"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<!-- <el-form-item label="主讲课程" prop="courses">
<el-input
v-model="queryParams.courses"
placeholder="请输入主讲课程"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>-->
<!-- <el-form-item label="联系电话" prop="tPhone">
<el-input
v-model="queryParams.tPhone"
placeholder="请输入联系电话"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>-->
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="教师状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- <el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:teacher:add']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['train:teacher:edit']"
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
>修改</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>-->
<el-table v-loading="loading" :data="teacherList">
<!-- @selection-change="handleSelectionChange"-->
<!-- <el-table-column type="selection" width="55" align="center" />-->
<el-table-column label="头像" align="center" width="80">
<template slot-scope="scope">
<el-image
:src="avatarUrl(scope.row.avatar)"
:preview-src-list="[avatarUrl(scope.row.avatar)]"
fit="cover"
class="teacher-avatar"
>
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline" />
</div>
</el-image>
</template>
</el-table-column>
<el-table-column label="教师姓名" align="center" prop="tName" min-width="120" />
<!-- <el-table-column label="人员编号" align="center" prop="personNo" min-width="120" show-overflow-tooltip />-->
<el-table-column label="性别" align="center" prop="tGender" width="80">
<template slot-scope="scope">
<span>{{ formatGender(scope.row.tGender) }}</span>
</template>
</el-table-column>
<!-- <el-table-column label="出生日期" align="center" prop="tBirth" width="120">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.tBirth, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>-->
<el-table-column label="所属单位" align="center" prop="deptName" :show-overflow-tooltip="true" />
<el-table-column label="职务/职称" align="center" prop="jobTitle" :show-overflow-tooltip="true" />
<!-- <el-table-column label="专业方向" align="center" prop="major" :show-overflow-tooltip="true" />
<el-table-column label="主讲课程" align="center" prop="courses" :show-overflow-tooltip="true" />-->
<!-- <el-table-column label="联系电话" align="center" prop="tPhone" width="130" />
<el-table-column label="邮箱" align="center" prop="tEmail" :show-overflow-tooltip="true" />-->
<!-- <el-table-column label="状态" align="center" prop="status" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ scope.row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>-->
<!-- <el-table-column label="关联系统账号" align="center" min-width="150">
<template slot-scope="scope">
<div v-if="scope.row.accountBindingStatus === 1 || scope.row.userName">
<el-tag type="success" size="mini">已关联</el-tag>
<div style="font-size: 12px; color: #606266; margin-top: 2px;">
{{ scope.row.userName }}
<span v-if="scope.row.nickName">({{ scope.row.nickName }})</span>
</div>
</div>
<el-tag v-else type="info" size="mini">未关联</el-tag>
</template>
</el-table-column>-->
<!-- <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />-->
<!-- 住宿安排:教师宿舍与班级无关,房间/床位下拉 + 安排宿舍、退宿操作(同一行展示,不换行) -->
<el-table-column label="住宿安排" align="center" min-width="480">
<template slot-scope="scope">
<div class="dorm-cell-line">
<el-select
:value="accTeacherOf(scope.row).dormId"
placeholder="宿舍房间号"
filterable
size="mini"
:disabled="!accTeacherOf(scope.row).editing"
style="width: 150px"
@change="val => handleAccDormChange(scope.row, val)"
>
<el-option
v-for="dorm in accDormOptions"
:key="dorm.dormId"
:label="accDormLabel(dorm)"
:value="dorm.dormId"
:disabled="isAccDormUnavailable(dorm, accTeacherOf(scope.row).dormId)"
/>
</el-select>
<el-select
:value="accTeacherOf(scope.row).bedNo"
placeholder="床位"
size="mini"
:disabled="!accTeacherOf(scope.row).editing || !accTeacherOf(scope.row).dormId"
style="width: 88px"
@change="val => handleAccBedChange(scope.row, val)"
>
<el-option v-for="bed in accBedOptions(scope.row)" :key="bed" :label="bed" :value="bed" />
</el-select>
<el-button
v-hasPermi="['train:teacher:edit']"
size="mini"
type="text"
:icon="accTeacherOf(scope.row).editing ? 'el-icon-close' : 'el-icon-office-building'"
:loading="accTeacherOf(scope.row).saving"
@click="handleToggleAccAdjust(scope.row)"
>{{ accTeacherOf(scope.row).editing ? '取消' : (hasAccAssignment(scope.row) ? '调整宿舍' : '安排宿舍') }}</el-button>
<el-button
v-hasPermi="['train:teacher:edit']"
size="mini"
type="text"
icon="el-icon-remove-outline"
style="color: #E6A23C;"
:disabled="!hasAccAssignment(scope.row)"
@click="handleTeacherAccCheckOut(scope.row)"
>退宿</el-button>
<!-- 授权结果:lockResult 经字典 train_accommodation_lock_result 翻译为中文,以图标+文案展示 -->
<el-tooltip :content="lockResultCell(scope.row).tip" placement="top">
<span class="dorm-lock-result" :style="{ color: lockResultCell(scope.row).color }">
<i :class="lockResultCell(scope.row).icon"></i>
<span class="dorm-lock-text">{{ lockResultCell(scope.row).text }}</span>
</span>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="260">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:teacher:query']"
size="mini"
type="text"
icon="el-icon-view"
@click="handleDetail(scope.row)"
>详情</el-button>
<el-button
v-hasPermi="['train:teacher:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改</el-button>
<!-- <el-button
v-if="!scope.row.userName && !scope.row.userId"
v-hasPermi="['train:teacher:bindUser']"
size="mini"
type="text"
icon="el-icon-link"
@click="handleOpenBinding(scope.row)"
>关联账号</el-button>
<el-button
v-else
v-hasPermi="['train:teacher:unbindUser']"
size="mini"
type="text"
icon="el-icon-circle-close"
style="color: #F56C6C;"
@click="handleUnbind(scope.row)"
>解绑账号</el-button>-->
<el-button
v-hasPermi="['train:teacher:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改教师信息对话框 -->
<el-dialog :title="title" :visible.sync="open" width="900px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="90px">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="教师头像" prop="avatar">
<div class="avatar-edit">
<studentAvatar ref="studentAvatar" :user="user" @success="handleAvatarSuccess" />
<div class="avatar-edit-actions">
<el-button size="mini" type="primary" icon="el-icon-picture" @click="handleSelectAvatar">选择图片</el-button>
<el-button size="mini" icon="el-icon-camera" :loading="cameraStarting" @click="openCamera">拍照</el-button>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="教师姓名" prop="tName">
<el-input v-model="form.tName" placeholder="请输入教师姓名" maxlength="50" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="人员编号" prop="personNo">
<el-input v-model="form.personNo" placeholder="请输入人员编号" maxlength="50" show-word-limit />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="性别" prop="tGender">
<el-radio-group v-model="form.tGender">
<el-radio label="男">男</el-radio>
<el-radio label="女">女</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="出生日期" prop="tBirth">
<el-date-picker
v-model="form.tBirth"
clearable
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择出生日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所属单位" prop="deptName">
<!-- <el-input v-model="form.deptName" placeholder="请输入所属单位/部门" maxlength="100" />-->
<treeselect v-model="form.deptName" :options="deptOptions" :normalizer="normalizer" :show-count="true" placeholder="请选择所属单位" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="职务/职称" prop="jobTitle">
<el-input v-model="form.jobTitle" placeholder="请输入职务/职称" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="专业方向" prop="major">
<el-input v-model="form.major" placeholder="请输入专业方向" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主讲课程" prop="courses">
<el-input v-model="form.courses" placeholder="请输入主讲课程,多门课程可用逗号分隔" maxlength="200" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="从教时间" prop="startTeaching">
<el-date-picker
v-model="form.startTeaching"
clearable
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择从教时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系电话" prop="tPhone">
<el-input v-model="form.tPhone" placeholder="请输入11位手机号码" maxlength="20" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="邮箱" prop="tEmail">
<el-input v-model="form.tEmail" placeholder="请输入电子邮箱" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">正常</el-radio>
<el-radio :label="0">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注信息" maxlength="500" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 拍照:调用浏览器摄像头拍摄教师头像 -->
<el-dialog title="拍照" :visible.sync="cameraOpen" width="640px" append-to-body @opened="handleCameraOpened" @close="closeCamera">
<div class="camera-wrap">
<video v-show="!cameraPhoto" ref="cameraVideo" class="camera-video" autoplay playsinline muted />
<canvas ref="cameraCanvas" style="display: none;" />
<img v-if="cameraPhoto" :src="cameraPhoto" class="camera-photo" alt="拍摄照片" />
<div v-if="cameraError" class="camera-error">{{ cameraError }}</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button size="mini" icon="el-icon-refresh" :disabled="!!cameraPhoto" @click="switchCamera">切换摄像头</el-button>
<el-button v-if="!cameraPhoto" type="primary" size="mini" icon="el-icon-camera-solid" @click="takePhoto">拍 摄</el-button>
<el-button v-if="cameraPhoto" size="mini" icon="el-icon-refresh-left" @click="retakePhoto">重 拍</el-button>
<el-button v-if="cameraPhoto" type="primary" size="mini" :loading="cameraUploading" @click="submitCameraPhoto">使用该照片</el-button>
<el-button size="mini" @click="cameraOpen = false">取 消</el-button>
</div>
</el-dialog>
<!-- 关联系统账号对话框 -->
<el-dialog title="关联系统账号 (教师业务档案)" :visible.sync="bindingOpen" width="700px" append-to-body>
<el-alert
title="关联说明"
type="info"
description="将教师业务档案与系统用户账号关联后,教师可凭该系统账号登录并使用“教师工作台”。系统仅允许选择正常启用且已分配教师角色(train_teacher)的账号。"
show-icon
:closable="false"
style="margin-bottom: 15px;"
/>
<div style="margin-bottom: 15px; font-weight: bold; color: #303133;">
当前教师:{{ currentTeacher.tName }} (工号: {{ currentTeacher.tId }})
</div>
<el-form size="small" :inline="true" @submit.native.prevent>
<el-form-item label="查找用户">
<el-input
v-model="bindingKeyword"
placeholder="请输入用户名或姓名搜索"
clearable
style="width: 240px;"
@keyup.enter.native="loadBindableUsers"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="loadBindableUsers">搜索</el-button>
</el-form-item>
</el-form>
<el-table v-loading="bindingLoading" :data="bindableUsers" max-height="300" highlight-current-row @current-change="handleSelectedUserChange">
<el-table-column width="55" align="center">
<template slot-scope="scope">
<el-radio v-model="selectedUserId" :label="scope.row.userId" :disabled="!scope.row.canBind">{{ '' }}</el-radio>
</template>
</el-table-column>
<el-table-column label="系统账号" prop="userName" min-width="110" />
<el-table-column label="姓名/昵称" prop="nickName" min-width="110" />
<el-table-column label="状态" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'" size="mini">
{{ scope.row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分配角色" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ (scope.row.roleKeys || []).join(', ') }}</span>
</template>
</el-table-column>
<el-table-column label="可否关联" min-width="150">
<template slot-scope="scope">
<el-tag v-if="scope.row.canBind" type="success" size="mini">可选</el-tag>
<span v-else style="color: #F56C6C; font-size: 12px;">{{ scope.row.reason }}</span>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :disabled="!selectedUserId" :loading="bindingSubmitting" @click="submitBinding">确 定 关 联</el-button>
<el-button @click="bindingOpen = false">取 消</el-button>
</div>
</el-dialog>
<!-- 教师详情弹窗 -->
<el-dialog
title="教师详情"
:visible.sync="detailOpen"
:width="detailDialogWidth"
append-to-body
:close-on-click-modal="true"
@closed="handleDetailClosed"
>
<div v-loading="detailLoading" class="teacher-detail-body">
<el-empty v-if="!detailLoading && !detail.tId" description="暂无教师详细信息" />
<el-descriptions v-else-if="detail.tId" :column="detailColumns" border>
<el-descriptions-item :span="detailColumns">
<template slot="label"><span class="teacher-desc-label">教师头像</span></template>
<el-image
:src="avatarUrl(detail.avatar)"
:preview-src-list="[avatarUrl(detail.avatar)]"
fit="cover"
class="detail-avatar"
>
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline" />
</div>
</el-image>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">教师姓名</span></template>
{{ detail.tName || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">人员编号</span></template>
{{ detail.personNo || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">性别</span></template>
{{ formatGender(detail.tGender) }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">出生日期</span></template>
{{ fmtDate(detail.tBirth) }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">所属单位</span></template>
{{ detail.deptName || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">职务/职称</span></template>
{{ detail.jobTitle || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">专业方向</span></template>
{{ detail.major || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">主讲课程</span></template>
{{ detail.courses || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">从教时间</span></template>
{{ fmtDate(detail.startTeaching) }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">联系电话</span></template>
{{ detail.tPhone || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">邮箱</span></template>
{{ detail.tEmail || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">状态</span></template>
<el-tag :type="detail.status === 1 ? 'success' : 'danger'">
{{ detail.status === 1 ? '正常' : '停用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="teacher-desc-label">创建时间</span></template>
{{ fmtDate(detail.createTime, true) }}
</el-descriptions-item>
<el-descriptions-item :span="detailColumns">
<template slot="label"><span class="teacher-desc-label">备注</span></template>
{{ detail.remark || '-' }}
</el-descriptions-item>
</el-descriptions>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detailOpen = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listTeacher, getTeacher, delTeacher, addTeacher, updateTeacher, getTeacherBindableUsers, bindTeacherUser, unbindTeacherUser } from '@/api/train/teacher'
import {deptTreeSelect} from "@/api/system/user";
import Treeselect from "@riophae/vue-treeselect";
import "@riophae/vue-treeselect/dist/vue-treeselect.css";
import {listDept} from "@/api/system/dept";
// 复用学员头像上传组件(通用:props.user 传入 {avatar},上传成功后抛出 success 事件)
import studentAvatar from '@/views/train/student/studentAvatar'
import request from '@/utils/request'
import { getTeacherAvatar } from '@/utils/avatar'
// 教师住宿安排(与学生住宿逻辑隔离,不关联班级)
import { listDormitory } from '@/api/train/dormitory'
import {
listTeacherAccommodation,
getTeacherAccommodation,
assignTeacherAccommodation,
checkOutTeacherAccommodation
} from '@/api/train/teacherAccommodation'
/**
* 门禁授权结果图标规则:字典值(train_accommodation_lock_result) → 图标 + 颜色。
* 与学员列表住宿安排列保持完全一致的取值口径与展示逻辑。
* 1 / unauthorized / pending 未授权 el-icon-time 灰色
* 2 / authorized / success 已授权 el-icon-circle-check 绿色
* 3 / expired 已过期 el-icon-warning-outline 橙色
* failed / revoked 失败/撤销 el-icon-circle-close 红色
*/
const LOCK_RESULT_ICON_RULES = {
1: { icon: 'el-icon-time', color: '#909399' },
2: { icon: 'el-icon-circle-check', color: '#67C23A' },
3: { icon: 'el-icon-warning-outline', color: '#E6A23C' },
unauthorized: { icon: 'el-icon-time', color: '#909399' },
pending: { icon: 'el-icon-time', color: '#909399' },
waiting: { icon: 'el-icon-time', color: '#909399' },
authorized: { icon: 'el-icon-circle-check', color: '#67C23A' },
success: { icon: 'el-icon-circle-check', color: '#67C23A' },
expired: { icon: 'el-icon-warning-outline', color: '#E6A23C' },
failed: { icon: 'el-icon-circle-close', color: '#F56C6C' },
fail: { icon: 'el-icon-circle-close', color: '#F56C6C' },
revoked: { icon: 'el-icon-remove-outline', color: '#909399' },
canceled: { icon: 'el-icon-remove-outline', color: '#909399' }
}
/** 授权结果默认图标:空值与字典未匹配时使用(灰色问号) */
const LOCK_RESULT_ICON_DEFAULT = { icon: 'el-icon-question', color: '#C0C4CC' }
export default {
name: 'Teacher',
// 字典注入:train_accommodation_lock_result 门禁授权结果(住宿安排列图标文案)
dicts: ['train_accommodation_lock_result'],
components: { Treeselect, studentAvatar },
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 教师信息表格数据
teacherList: [],
// ===== 教师住宿安排(与学生住宿逻辑隔离) =====
// 宿舍房间选项(全量,含停用房间,保证已分配教师的宿舍能正确回显)
accDormOptions: [],
// 每个教师行的住宿编辑态(key = tId)
accTeacherState: {},
// 弹出层标题
title: '',
// 是否显示弹出层
open: false,
// 账号关联弹窗
bindingOpen: false,
bindingLoading: false,
bindingSubmitting: false,
bindingKeyword: '',
bindableUsers: [],
currentTeacher: {},
selectedUserId: null,
// 教师详情弹窗
detailOpen: false,
detailLoading: false,
detail: {},
// 当前窗口宽度(用于详情弹窗与描述列数自适应)
screenWidth: typeof window !== 'undefined' ? window.innerWidth : 1280,
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
tName: null,
deptName: null,
personNo: null,
jobTitle: null,
tPhone: null,
status: null
},
// 部门树选项
deptOptions: undefined,
deptArr: {},
// 头像上传组件用的教师对象(avatar 为入库的相对地址)
user: { avatar: '' },
// 拍照弹窗
cameraOpen: false,
// 当前摄像头视频流
cameraStream: null,
// 摄像头是否正在启动
cameraStarting: false,
// 拍摄后的照片 DataURL
cameraPhoto: '',
// 摄像头前置/后置
cameraFacing: 'user',
// 摄像头不可用时的提示
cameraError: '',
// 拍摄照片上传中
cameraUploading: false,
form: {},
// 表单校验
rules: {
tName: [
{ required: true, message: '教师姓名不能为空', trigger: 'blur' },
{ max: 50, message: '教师姓名长度不能超过50个字符', trigger: 'blur' }
],
personNo: [
{ max: 50, message: '人员编号长度不能超过50个字符', trigger: 'blur' }
],
tPhone: [
{ required: true, message: '联系电话不能为空', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号码', trigger: 'blur' }
],
tEmail: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
],
major: [
{ max: 100, message: '专业方向长度不能超过100个字符', trigger: 'blur' }
],
courses: [
{ max: 200, message: '主讲课程长度不能超过200个字符', trigger: 'blur' }
]
}
}
},
computed: {
/** 详情描述列数:窄屏单列、宽屏双列 */
detailColumns() {
return this.screenWidth < 768 ? 1 : 2
},
/** 详情弹窗宽度:窄屏接近满宽、宽屏固定 */
detailDialogWidth() {
return this.screenWidth < 768 ? '92%' : '820px'
}
},
created() {
this.mapDept()
this.loadAccDormOptions()
this.getList()
this.getDeptTree()
},
mounted() {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
},
methods: {
/** 窗口尺寸变化:同步宽度,驱动详情弹窗自适应 */
handleResize() {
this.screenWidth = window.innerWidth
},
/** 日期格式化:空值显示 - */
fmtDate(value, withTime) {
if (!value) return '-'
const pattern = withTime ? '{y}-{m}-{d} {h}:{i}:{s}' : '{y}-{m}-{d}'
return this.parseTime ? this.parseTime(value, pattern) : value
},
/** 打开教师详情弹窗并拉取当前行完整信息 */
handleDetail(row) {
if (!row || row.tId === null || row.tId === undefined) {
this.$modal.msgWarning('暂无该教师的可用信息')
return
}
this.detail = {}
this.detailOpen = true
this.detailLoading = true
getTeacher(row.tId).then(res => {
this.detail = (res && res.data) || {}
}).catch(() => {
// 请求失败:错误已由请求拦截器提示,这里保持弹窗并展示空态
}).finally(() => {
this.detailLoading = false
})
},
/** 详情弹窗关闭后清理数据,避免再次打开闪现旧数据 */
handleDetailClosed() {
this.detail = {}
this.detailLoading = false
},
/** 查询教师信息列表 */
getList() {
this.loading = true
listTeacher(this.queryParams).then(response => {
this.teacherList = response.rows
this.total = response.total
this.teacherList.forEach(stud => {
let studOrgName = this.formatOrgName(stud.deptName);
//console.log(stud.deptName +"--"+ studOrgName);
this.$set(stud, "deptName", studOrgName);
});
// 回显教师当前住宿安排(独立接口,不影响教师列表本身)
this.loadTeacherAccommodations();
}).finally(() => {
this.loading = false
})
},
/** 查询部门下拉树结构 */
getDeptTree() {
deptTreeSelect().then(response => {
this.deptOptions = response.data;
});
},
// 筛选节点
filterNode(value, data) {
if (!value) return true;
return data.label.indexOf(value) !== -1;
},
// 节点单击事件
handleNodeClick(data) {
this.createForm.deptName = data.id;
this.handleQuery();
},
// 数据格式化函数,把后端返回部门树形转成treeselect需要格式
normalizer(node) {
return {
id: node.id,
label: node.label,
children: node.children
}
},
mapDept() {
listDept().then(response => {
let deptList = response.data || [];
let deptArr = {};
deptList.forEach(dept => {
if (dept && dept.deptId !== undefined && dept.deptId !== null) {
deptArr[dept.deptId] = dept.deptName;
}
});
this.deptArr = deptArr;
});
},
// 取消按钮
cancel() {
this.open = false
this.reset()
},
// 表单重置
reset() {
this.form = {
tId: null,
avatar: null,
tName: null,
personNo: null,
tGender: '男',
tBirth: null,
deptName: null,
jobTitle: null,
major: null,
courses: null,
startTeaching: null,
tPhone: null,
tEmail: null,
status: 1,
remark: null
}
// 重置头像组件预览
this.user = { avatar: '' }
this.resetForm('form')
},
/*
* “选择图片”:与点击头像图片效果一致,打开头像裁剪/上传弹窗
* (弹窗内的“选择”按钮可挑选本地图片,提交后写入 form.avatar)
*/
handleSelectAvatar() {
if (this.$refs.studentAvatar) {
this.$refs.studentAvatar.editCropper()
}
},
/** 打开拍照弹窗 */
openCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
this.$modal.msgError('当前浏览器不支持拍照功能,请使用 Chrome/Edge 等现代浏览器,并确保以 https 或 localhost 访问')
return
}
this.cameraPhoto = ''
this.cameraError = ''
this.cameraFacing = 'user'
this.cameraOpen = true
},
/** 拍照弹窗打开后启动摄像头 */
handleCameraOpened() {
this.startCamera()
},
/** 启动摄像头并播放视频流 */
startCamera() {
this.stopCameraStream()
this.cameraStarting = true
this.cameraError = ''
const constraints = {
audio: false,
video: {
facingMode: this.cameraFacing,
width: { ideal: 1280 },
height: { ideal: 720 }
}
}
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
this.cameraStream = stream
const video = this.$refs.cameraVideo
if (!video) {
this.stopCameraStream()
return
}
video.srcObject = stream
const playPromise = video.play()
if (playPromise && playPromise.catch) {
playPromise.catch(() => {})
}
}).catch(error => {
const name = error && error.name
if (name === 'NotAllowedError' || name === 'SecurityError') {
this.cameraError = '摄像头权限被拒绝,请在浏览器地址栏允许摄像头权限后重试'
} else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
this.cameraError = '未检测到可用的摄像头设备'
} else {
this.cameraError = '摄像头打开失败:' + ((error && (error.message || name)) || '未知错误')
}
}).finally(() => {
this.cameraStarting = false
})
},
/** 切换前置/后置摄像头 */
switchCamera() {
this.cameraFacing = this.cameraFacing === 'user' ? 'environment' : 'user'
this.startCamera()
},
/** 拍摄当前画面 */
takePhoto() {
const video = this.$refs.cameraVideo
const canvas = this.$refs.cameraCanvas
if (!video || !canvas) {
this.$modal.msgError('摄像头尚未就绪,请稍候重试')
return
}
if (!video.videoWidth || !video.videoHeight) {
this.$modal.msgWarning('摄像头画面尚未加载完成,请稍候再试')
return
}
// 取正方形区域,避免头像被拉伸变形
const size = Math.min(video.videoWidth, video.videoHeight)
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const sx = (video.videoWidth - size) / 2
const sy = (video.videoHeight - size) / 2
context.drawImage(video, sx, sy, size, size, 0, 0, size, size)
this.cameraPhoto = canvas.toDataURL('image/png')
// 拍摄完成即释放摄像头
this.stopCameraStream()
},
/** 重新拍摄 */
retakePhoto() {
this.cameraPhoto = ''
this.cameraError = ''
this.startCamera()
},
/** 使用拍摄照片:上传到 /common/upload 并回填教师头像 */
submitCameraPhoto() {
if (!this.cameraPhoto) {
this.$modal.msgWarning('请先拍摄照片')
return
}
const blob = this.dataURLtoBlob(this.cameraPhoto)
if (!blob) {
this.$modal.msgError('照片解析失败,请重新拍摄')
return
}
const formData = new FormData()
formData.append('file', new File([blob], 'avatar.png', { type: 'image/png' }))
this.cameraUploading = true
request({
url: '/common/upload',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => {
const imgUrl = response.fileName || response.url
if (!imgUrl) {
this.$modal.msgError('上传失败,未获取到文件地址')
return
}
// 与头像上传组件走同一回填逻辑,只入库相对地址
this.handleAvatarSuccess(imgUrl)
this.$modal.msgSuccess('拍照上传成功')
this.cameraOpen = false
}).catch(() => {}).finally(() => {
this.cameraUploading = false
})
},
/** DataURL -> Blob */
dataURLtoBlob(dataURL) {
if (!dataURL) {
return null
}
const parts = dataURL.split(',')
if (parts.length < 2) {
return null
}
const mime = (parts[0].match(/:(.*?);/) || [])[1] || 'image/png'
const binary = window.atob(parts[1])
const buffer = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
buffer[i] = binary.charCodeAt(i)
}
return new Blob([buffer], { type: mime })
},
/** 释放摄像头视频流 */
stopCameraStream() {
if (this.cameraStream) {
this.cameraStream.getTracks().forEach(track => track.stop())
this.cameraStream = null
}
const video = this.$refs.cameraVideo
if (video) {
video.srcObject = null
}
},
/** 关闭拍照弹窗:释放摄像头并清理临时照片 */
closeCamera() {
this.stopCameraStream()
this.cameraPhoto = ''
this.cameraError = ''
},
/** 头像上传成功回调:相对地址写入表单,随教师信息一并提交 */
handleAvatarSuccess(imgUrl) {
if (!imgUrl) {
return
}
this.$set(this.form, 'avatar', imgUrl)
// 同步更新头像组件的预览
this.user = Object.assign({}, this.user, { avatar: imgUrl })
if (this.$refs.form) {
this.$refs.form.clearValidate('avatar')
}
},
/** 头像地址解析:补全 baseApi,空值使用专业讲师默认头像 */
avatarUrl(avatar) {
return getTeacherAvatar(avatar)
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.tId)
this.single = selection.length !== 1
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = '添加教师信息'
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const tId = row.tId || this.ids[0]
getTeacher(tId).then(response => {
this.form = response.data
// 回填头像预览
this.user = { avatar: (this.form && this.form.avatar) || '' }
this.open = true
this.title = '修改教师信息'
})
},
/** 提交按钮 */
submitForm() {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.tId != null) {
updateTeacher(this.form).then(response => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addTeacher(this.form).then(response => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
/** 打开关联系统账号对话框 */
handleOpenBinding(row) {
this.currentTeacher = row
this.selectedUserId = null
this.bindingKeyword = ''
this.bindableUsers = []
this.bindingOpen = true
this.loadBindableUsers()
},
/** 加载候选用户列表 */
loadBindableUsers() {
this.bindingLoading = true
getTeacherBindableUsers(this.bindingKeyword).then(response => {
this.bindableUsers = response.data || []
this.bindingLoading = false
}).catch(() => {
this.bindingLoading = false
})
},
/** 单击行选中候选账号 */
handleSelectedUserChange(row) {
if (row && row.canBind) {
this.selectedUserId = row.userId
}
},
/** 提交账号关联 */
submitBinding() {
if (!this.selectedUserId) {
this.$modal.msgWarning('请选择要关联的系统账号')
return
}
const selectedUser = this.bindableUsers.find(u => u.userId === this.selectedUserId)
const userName = selectedUser ? selectedUser.userName : this.selectedUserId
this.$modal.confirm('确定要将教师档案【' + this.currentTeacher.tName + '】关联到账号【' + userName + '】吗?').then(() => {
this.bindingSubmitting = true
return bindTeacherUser(this.currentTeacher.tId, this.selectedUserId)
}).then(() => {
this.$modal.msgSuccess('关联成功')
this.bindingOpen = false
this.bindingSubmitting = false
this.getList()
}).catch(() => {
this.bindingSubmitting = false
})
},
/** 解除账号关联 */
handleUnbind(row) {
this.$modal.confirm('确定要解除教师【' + row.tName + '】与系统账号【' + (row.userName || row.userId) + '】的关联吗?').then(() => {
return unbindTeacherUser(row.tId)
}).then(() => {
this.$modal.msgSuccess('解除关联成功')
this.getList()
})
},
/** 删除按钮操作 */
handleDelete(row) {
const tId = row.tId;
const tName = row.tName;
this.$modal.confirm(`确定删除教师【${tName}】吗?`).then(() => {
return delTeacher(tId);
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
},
// 所属单位:deptName 为数字或 deptArr 中存在映射时取 deptArr 对应值,否则显示 deptName 本身
formatOrgName(deptName) {
if (deptName === null || deptName === undefined || deptName === '') return '-'
const map = this.deptArr || {}
const mapped = map[deptName]
const isNumber = /^\d+$/.test(String(deptName))
if (isNumber || mapped !== undefined) {
return (mapped !== undefined && mapped !== null && mapped !== '') ? mapped : deptName
}
return deptName
},
formatGender(gender) {
if (gender === '0' || gender === 0 || gender === '男') return '男'
if (gender === '1' || gender === 1 || gender === '女') return '女'
return gender || '未知'
},
/** 导出按钮操作 */
handleExport() {
this.download('train/teacher/export', {
...this.queryParams
}, `teacher_${new Date().getTime()}.xlsx`)
},
// ==================== 教师住宿安排(与学生住宿逻辑隔离) ====================
/** 取某教师行的住宿编辑态 */
accTeacherOf(row) {
const state = row && this.accTeacherState[row.tId]
return state || { editing: false, saving: false, accommodationId: null, dormId: null, bedNo: null }
},
/** 加载全量宿舍房间(含停用房间),保证已分配教师的宿舍能正确回显 */
loadAccDormOptions() {
return listDormitory({ pageNum: 1, pageSize: 1000 }).then(res => {
this.accDormOptions = res.rows || []
this.initAccTeacherState()
}).catch(() => {
this.accDormOptions = []
})
},
/** 批量回显教师当前住宿安排(key 为教师表主键 id,返回记录内为 enrollmentId) */
loadTeacherAccommodations() {
const teacherIds = (this.teacherList || [])
.map(row => row.id)
.filter(id => id !== null && id !== undefined && id !== '')
if (!teacherIds.length) {
this.initAccTeacherState()
return Promise.resolve()
}
return listTeacherAccommodation(teacherIds).then(res => {
const list = (res && res.data) || []
const byTeacherId = {}
list.forEach(acc => {
byTeacherId[String(acc.enrollmentId)] = acc
})
this.teacherList.forEach(row => {
const acc = byTeacherId[String(row.id)]
this.$set(row, 'accommodationId', acc ? acc.accommodationId : null)
this.$set(row, 'accommodationStatus', acc ? acc.accommodationStatus : null)
this.$set(row, 'dormId', acc ? acc.dormId : null)
this.$set(row, 'dormBuilding', acc ? acc.dormBuilding : '')
this.$set(row, 'dormRoom', acc ? acc.dormRoom : '')
this.$set(row, 'bedNo', acc ? acc.bedNo : null)
this.$set(row, 'lockApiBizNo', acc ? acc.lockApiBizNo : null)
this.$set(row, 'lockResult', acc ? acc.lockResult : null)
})
this.initAccTeacherState()
}).catch(() => {
this.initAccTeacherState()
})
},
/** 依据当前列表初始化每行住宿编辑态(下拉框默认禁用) */
initAccTeacherState() {
const state = {}
;(this.teacherList || []).forEach(row => {
const matched = this.matchAccDorm(row)
state[row.tId] = {
editing: false,
saving: false,
accommodationId: row.accommodationId || null,
dormId: matched ? matched.dormId : null,
bedNo: row.bedNo || null,
originDormId: matched ? matched.dormId : null,
originBedNo: row.bedNo || null
}
})
this.accTeacherState = state
},
/** 按楼栋+房间号匹配宿舍记录 */
matchAccDorm(row) {
if (!row || (!row.dormBuilding && !row.dormRoom)) return null
return this.accDormOptions.find(d =>
String(d.dormBuilding || '') === String(row.dormBuilding || '') &&
String(d.dormRoom || '') === String(row.dormRoom || '')
) || null
},
/** 是否已有「当前有效住宿」:后端仅返回 0/1 的住宿字段,这里再兜底排除已退宿(2) */
hasAccAssignment(row) {
return !!(row && row.accommodationStatus !== 2 && (row.dormBuilding || row.dormRoom))
},
/**
* 「住宿安排」列授权结果单元:lockResult → 字典 train_accommodation_lock_result 文案 + 图标。
* 与学员列表住宿安排列保持一致:
* - 空值:灰色问号图标 + 「未授权」,tooltip 提示暂无授权结果
* - 字典未匹配:灰色问号图标 + 「未知状态」,tooltip 展示原始值
* - 匹配成功:按 LOCK_RESULT_ICON_RULES 取图标与颜色,文案取字典 label
*/
lockResultCell(row) {
const raw = row ? row.lockResult : null
const isEmpty = raw === null || raw === undefined || String(raw).trim() === ''
if (isEmpty) {
return {
icon: LOCK_RESULT_ICON_DEFAULT.icon,
color: LOCK_RESULT_ICON_DEFAULT.color,
text: '未授权',
tip: '暂无门禁授权结果'
}
}
const options = (this.dict && this.dict.type && this.dict.type.train_accommodation_lock_result) || []
const matchedItem = options.find(item => String(item.value) === String(raw))
if (!matchedItem) {
return {
icon: LOCK_RESULT_ICON_DEFAULT.icon,
color: LOCK_RESULT_ICON_DEFAULT.color,
text: '未知状态',
tip: `未匹配到字典项:${raw}`
}
}
const rule = LOCK_RESULT_ICON_RULES[String(raw).trim().toLowerCase()] ||
LOCK_RESULT_ICON_RULES[String(raw).trim()] ||
LOCK_RESULT_ICON_DEFAULT
return {
icon: rule.icon,
color: rule.color,
text: matchedItem.label,
tip: matchedItem.label
}
},
/** 已分配人数 */
accUsedBeds(dorm) {
return dorm && dorm.usedCount != null ? Number(dorm.usedCount) || 0 : 0
},
/** 剩余床位数 */
accFreeBeds(dorm) {
const remaining = dorm && dorm.remainingBeds != null
? dorm.remainingBeds
: (dorm && dorm.capacity != null ? Number(dorm.capacity) - this.accUsedBeds(dorm) : 0)
return Number(remaining) || 0
},
/** 房间下拉展示文案(教师住宿跳过性别校验,不展示性别限制) */
accDormLabel(dorm) {
const free = this.accFreeBeds(dorm)
const capacity = dorm.capacity != null ? dorm.capacity : '-'
return `${dorm.dormBuilding || ''} ${dorm.dormRoom || ''}(余位:${free}/${capacity})`
},
/** 房间是否不可选:停用或已满(已选中的房间仍可展示) */
isAccDormUnavailable(dorm, currentDormId) {
if (String(dorm.dormId) === String(currentDormId)) return false
if (dorm.dormStatus === 0) return true
return this.accFreeBeds(dorm) <= 0
},
/** 床位选项:按所选房间核定容量生成 */
accBedOptions(row) {
const state = this.accTeacherOf(row)
const dorm = this.accDormOptions.find(d => String(d.dormId) === String(state.dormId))
const capacity = dorm && dorm.capacity != null ? Number(dorm.capacity) : 0
const options = []
for (let i = 1; i <= capacity; i++) {
options.push(`${i}号床`)
}
return options
},
handleAccDormChange(row, dormId) {
const state = this.accTeacherState[row.tId]
if (!state || !state.editing) return
this.$set(state, 'dormId', dormId)
// 换房间后床位需重新选择
this.$set(state, 'bedNo', null)
},
handleAccBedChange(row, bedNo) {
const state = this.accTeacherState[row.tId]
if (!state || !state.editing) return
this.$set(state, 'bedNo', bedNo)
this.saveTeacherAccommodation(row)
},
/** 房间与床位均已选择后提交(新增安排 / 调整宿舍) */
saveTeacherAccommodation(row) {
const state = this.accTeacherState[row.tId]
if (!state || !state.editing || state.saving) return
if (!state.dormId || !state.bedNo) return
const dorm = this.accDormOptions.find(d => String(d.dormId) === String(state.dormId)) || {}
const location = `${dorm.dormBuilding || ''} ${dorm.dormRoom || ''} ${state.bedNo}`.trim()
const isAdjust = !!state.accommodationId
this.$modal.confirm(`确认将教师【${row.tName || ''}】的宿舍安排为【${location}】吗?`)
.then(() => {
state.saving = true
return assignTeacherAccommodation({
teacherId: row.id,
dormId: state.dormId,
bedNo: state.bedNo
})
})
.then(res => {
// 后端开锁授权门禁:住宿已保存,但人脸不可用时拒绝开锁,需要明确告知原因
const result = (res && res.data) || {}
if (result.lockAuthorized === false && result.lockStatus === 'SKIPPED') {
this.$modal.msgWarning(`宿舍安排已保存,但未开通门锁:${result.lockMessage || '教师人脸照片不可用'}`)
} else if (result.lockAuthorized === false && result.lockStatus === 'FAILED') {
this.$modal.msgWarning(`宿舍安排已保存,门锁授权下发失败:${result.lockMessage || '请稍后重试'}`)
} else {
this.$modal.msgSuccess(isAdjust ? '教师宿舍调整成功' : '教师宿舍安排成功')
}
this.$set(state, 'editing', false)
// 刷新房间余位与列表回显
this.loadAccDormOptions().then(() => this.getList())
})
.catch(err => {
// 取消或失败均回滚,保持原住宿安排
this.$set(state, 'dormId', state.originDormId)
this.$set(state, 'bedNo', state.originBedNo)
if (err !== 'cancel') {
this.$modal.msgError(this.accErrorText(err, '教师宿舍安排失败'))
}
})
.finally(() => {
state.saving = false
})
},
/** 取教师当前有效住宿记录(供调整/退宿使用) */
resolveTeacherActiveAccommodation(row, state, fillSelection = false) {
if (!state || state.accommodationId) return Promise.resolve(state)
return getTeacherAccommodation(row.id).then(res => {
const acc = (res && res.data) || null
if (!acc || !acc.accommodationId) return state
this.$set(state, 'accommodationId', acc.accommodationId)
if (fillSelection) {
if (acc.dormId) {
this.$set(state, 'dormId', acc.dormId)
this.$set(state, 'originDormId', acc.dormId)
}
if (acc.bedNo) {
this.$set(state, 'bedNo', acc.bedNo)
this.$set(state, 'originBedNo', acc.bedNo)
}
}
return state
}).catch(() => state)
},
/** 安排/调整宿舍:激活或取消下拉框编辑 */
handleToggleAccAdjust(row) {
const state = this.accTeacherState[row.tId]
if (!state) return
if (state.editing) {
// 再次点击视为取消,回滚为原住宿安排并禁用下拉框
this.$set(state, 'editing', false)
this.$set(state, 'dormId', state.originDormId)
this.$set(state, 'bedNo', state.originBedNo)
return
}
this.$set(state, 'editing', true)
if (!this.hasAccAssignment(row)) return
// 已安排住宿:取有效住宿记录,供调房接口使用
this.resolveTeacherActiveAccommodation(row, state, true)
},
/** 退宿:后端统一办理门锁退宿撤权 + 人脸删除(不清空教师头像) */
handleTeacherAccCheckOut(row) {
if (!this.hasAccAssignment(row)) {
this.$modal.msgWarning('该教师暂无有效住宿记录,无需退宿')
return
}
const state = this.accTeacherState[row.tId]
this.$modal.confirm(`确认办理教师【${row.tName || ''}】的退宿吗?退宿后将解除门锁入住授权并删除人脸数据,教师头像不受影响。`)
.then(() => {
const accommodationIdPromise = state && state.accommodationId
? Promise.resolve(state.accommodationId)
: getTeacherAccommodation(row.id).then(res => (res && res.data && res.data.accommodationId) || null)
return accommodationIdPromise.then(accommodationId => {
if (!accommodationId) {
this.$modal.msgWarning('未查询到该教师的有效住宿记录(可能已退宿),无法办理退宿')
return
}
return checkOutTeacherAccommodation(accommodationId).then(() => {
this.$modal.msgSuccess('退宿办理成功')
if (state) {
this.$set(state, 'editing', false)
this.$set(state, 'accommodationId', null)
}
this.loadAccDormOptions().then(() => this.getList())
})
})
})
.catch(err => {
if (err !== 'cancel') {
this.$modal.msgError(this.accErrorText(err, '退宿办理失败'))
}
})
},
/** 统一描述住宿接口异常:业务错误带错误码与详情,超时/网络异常给出对应提示 */
accErrorText(err, fallbackMsg) {
const fallback = fallbackMsg || '操作失败'
if (!err) return fallback
if (typeof err === 'string') {
return err === 'cancel' || err === 'error' ? fallback : err
}
const response = err.response || {}
const body = response.data || err.data || {}
const code = err.code !== undefined && err.code !== null
? err.code
: (body && body.code !== undefined && body.code !== null ? body.code : response.status)
const detail = (body && (body.msg || body.message)) || err.msg || err.message || ''
if (/timeout/i.test(detail) || code === 'ECONNABORTED') {
return `${fallback}:请求超时,请检查网络后重试`
}
if (/network\s*error/i.test(detail)) {
return `${fallback}:网络异常,请检查网络连接后重试`
}
if (!detail) return code ? `${fallback}(错误码:${code})` : fallback
return code ? `${fallback}(错误码:${code}):${detail}` : `${fallback}:${detail}`
},
}
}
</script>
<style scoped lang="scss">
.teacher-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
vertical-align: middle;
background: #f5f7fa;
}
.teacher-avatar .image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #c0c4cc;
font-size: 18px;
}
/* 住宿安排列:房间、床位下拉与安排/退宿按钮同行展示,强制不换行 */
.dorm-cell-line {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
.dorm-cell-line ::v-deep .el-button {
padding: 0;
margin-left: 0;
white-space: nowrap;
}
/* 住宿安排列:授权结果图标 + 字典文案,固定不收缩,避免挤压房间/床位下拉 */
.dorm-lock-result {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
font-size: 12px;
line-height: 1;
white-space: nowrap;
cursor: default;
}
.dorm-lock-result i {
font-size: 14px;
}
/* 响应式:中等屏幕起只保留图标,中文文案由 tooltip 承载,保证列宽不溢出 */
@media (max-width: 1500px) {
.dorm-lock-text {
display: none;
}
}
@media (max-width: 1200px) {
.dorm-lock-result {
margin-left: 0;
}
}
/* 教师详情弹窗 */
.teacher-detail-body {
min-height: 120px;
}
/* 详情属性名列:按名称长度自适应列宽,禁止折行;超长名称限制上限并省略 */
.teacher-detail-body ::v-deep .el-descriptions-item__label {
white-space: nowrap;
word-break: keep-all;
}
.teacher-desc-label {
display: inline-block;
min-width: 64px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.detail-avatar {
width: 64px;
height: 64px;
border-radius: 6px;
background: #f5f7fa;
}
.detail-avatar .image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #c0c4cc;
font-size: 20px;
}
.avatar-edit {
display: flex;
align-items: center;
}
.avatar-edit-actions {
display: flex;
flex-direction: column;
margin-left: 20px;
.el-button + .el-button {
margin-left: 0;
margin-top: 8px;
}
}
.camera-wrap {
width: 100%;
text-align: center;
}
.camera-video,
.camera-photo {
width: 100%;
max-height: 420px;
background: #000;
border-radius: 4px;
display: inline-block;
}
.camera-error {
margin-top: 12px;
color: #F56C6C;
font-size: 13px;
line-height: 1.6;
}
/* 顶部静态布局样式 */
.page-topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.topbar-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.topbar-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #1f2d3d;
}
.topbar-sub {
font-size: 13px;
color: #8a94a6;
}
}
.topbar-filter {
background: #fff;
border: 1px solid #eef0f5;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 16px;
.filter-item {
.filter-label {
font-size: 12px;
color: #8a94a6;
margin-bottom: 6px;
}
}
.filter-keyword {
flex: 1;
min-width: 260px;
}
.filter-buttons {
display: flex;
gap: 8px;
.el-button + .el-button {
margin-left: 0;
}
}
}
/* 查询区:查询条件与按钮同一行、水平垂直对齐,窄屏自动换行 */
.topbar-filter ::v-deep .filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
}
/* 清除 element-ui 默认的表单下/右外边距,由 gap 统一控制间距,保证水平对齐 */
.topbar-filter ::v-deep .filter-form .el-form-item {
display: flex;
align-items: center;
margin: 0;
}
.topbar-filter ::v-deep .filter-form .el-form-item__label {
padding-right: 8px;
line-height: 32px;
}
.topbar-filter ::v-deep .filter-form .el-form-item__content {
display: flex;
align-items: center;
line-height: 32px;
}
/* 统一输入控件宽度,保持整体样式一致 */
.topbar-filter ::v-deep .filter-form .el-input,
.topbar-filter ::v-deep .filter-form .el-select {
width: 180px;
}
/* 按钮组:宽屏靠右,与查询项同一行 */
.topbar-filter ::v-deep .filter-form .el-form-item.filter-actions {
margin-left: auto;
}
.topbar-filter ::v-deep .filter-form .filter-actions .el-button + .el-button {
margin-left: 8px;
}
/* 窄屏适配:缩小间距与控件宽度,按钮不再强制靠右 */
@media (max-width: 768px) {
.topbar-filter ::v-deep .filter-form {
gap: 10px 12px;
}
.topbar-filter ::v-deep .filter-form .el-form-item.filter-actions {
margin-left: 0;
}
.topbar-filter ::v-deep .filter-form .el-input,
.topbar-filter ::v-deep .filter-form .el-select {
width: 160px;
}
}
</style>