修复住宿:实现学员住宿安排分配UI流程及修复modal异常

This commit is contained in:
wangzhibiao committed 2026-09-23 20:13:02 +08:00
1 parent 62085e8a49
commit 2f05f310ad
4 files changed
+776 -9

No files matched your search

+4
View File
@@ -7,6 +7,10 @@ export default {
msg(content) {
Message.info(content)
},
// 信息消息
msgInfo(content) {
Message.info(content)
},
// 错误消息
msgError(content) {
Message.error(content)
@@ -270,7 +270,7 @@ export default {
this.handleQuery()
},
handleAdd() {
this.$modal.msgInfo('请在学员管理或报名名单中点击“住宿安排”办理分配')
this.$modal.msgInfo('请在【培训计划】->【参训人员】名单中点击对应学员的“住宿安排”办理分配')
},
handleCheckIn(row) {
this.$modal.confirm('确认办理学员 [' + (row.studentName || row.studentId) + '] 的入住手续?').then(() => {
@@ -89,7 +89,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="180" fixed="right">
<el-table-column label="操作" align="center" width="240" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:enrollment:query']"
@@ -98,6 +98,13 @@
icon="el-icon-document"
@click="handleViewDetail(scope.row)"
>学员档案</el-button>
<el-button
v-hasPermi="['train:accommodation:query', 'train:accommodation:add']"
size="mini"
type="text"
icon="el-icon-office-building"
@click="handleOpenAccommodation(scope.row)"
>住宿安排</el-button>
<el-button
v-if="scope.row.enrollmentStatus === 0"
v-hasPermi="['train:report:confirm']"
@@ -306,6 +313,25 @@
<div style="margin-top: 15px;" />
<el-descriptions title="住宿安排" :column="2" border size="small">
<template slot="extra">
<el-button
v-if="!detailDialog.data.accommodation || !detailDialog.data.accommodation.room"
v-hasPermi="['train:accommodation:add']"
size="mini"
type="primary"
plain
icon="el-icon-plus"
@click="handleOpenAccommodationFromDetail"
>办理住宿分配</el-button>
<el-button
v-else
v-hasPermi="['train:accommodation:query']"
size="mini"
type="text"
icon="el-icon-view"
@click="handleOpenAccommodationFromDetail"
>查看/变更住宿</el-button>
</template>
<el-descriptions-item label="楼栋">{{ (detailDialog.data.accommodation && detailDialog.data.accommodation.building) || '未分配' }}</el-descriptions-item>
<el-descriptions-item label="房间">{{ (detailDialog.data.accommodation && detailDialog.data.accommodation.room) || '未分配' }}</el-descriptions-item>
<el-descriptions-item label="床位号">{{ (detailDialog.data.accommodation && detailDialog.data.accommodation.bedNo) || '未分配' }}</el-descriptions-item>
@@ -333,6 +359,209 @@
<el-button @click="detailDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
<!-- 学员住宿安排对话框 (ACCOM-ASSIGN-01) -->
<el-dialog
:title="`学员住宿安排 - ${accommodationDialog.currentStudent.studentName || ''}`"
:visible.sync="accommodationDialog.open"
width="680px"
append-to-body
>
<div v-loading="accommodationDialog.loading">
<!-- 学员基本信息卡片(只读绑定上下文,不得让用户手工输入内部ID) -->
<el-descriptions title="参训学员信息" :column="3" border size="small" style="margin-bottom: 16px;">
<el-descriptions-item label="学员姓名">{{ accommodationDialog.currentStudent.studentName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学号">{{ accommodationDialog.currentStudent.studentId || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ formatGender(accommodationDialog.currentStudent.gender) }}</el-descriptions-item>
<el-descriptions-item label="参训编号">{{ accommodationDialog.currentStudent.enrollmentId || '-' }}</el-descriptions-item>
<el-descriptions-item label="所属班级">{{ planId || '-' }}</el-descriptions-item>
<el-descriptions-item label="住宿需求">
<el-tag size="mini" :type="accommodationDialog.currentStudent.accommodationRequired === 1 ? 'warning' : 'info'">
{{ accommodationDialog.currentStudent.accommodationRequired === 1 ? '需住宿' : '不住宿' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<!-- 当前住宿状态分支 -->
<!-- 分支 A:已有有效住宿记录 -->
<div v-if="accommodationDialog.currentAccommodation && accommodationDialog.currentAccommodation.accommodationId" style="margin-bottom: 15px;">
<div style="font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #303133;">
<i class="el-icon-circle-check" style="color: #67C23A; margin-right: 5px;" />当前住宿安排
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="宿舍楼栋">{{ accommodationDialog.currentAccommodation.dormBuilding || '-' }}</el-descriptions-item>
<el-descriptions-item label="房间号">{{ accommodationDialog.currentAccommodation.dormRoom || '-' }}</el-descriptions-item>
<el-descriptions-item label="床位号">{{ accommodationDialog.currentAccommodation.bedNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="住宿状态">
<el-tag v-if="accommodationDialog.currentAccommodation.accommodationStatus === 1" type="success" size="mini">在住</el-tag>
<el-tag v-else-if="accommodationDialog.currentAccommodation.accommodationStatus === 0" type="info" size="mini">预分配</el-tag>
<el-tag v-else-if="accommodationDialog.currentAccommodation.accommodationStatus === 2" type="danger" size="mini">已退宿</el-tag>
<el-tag v-else size="mini">{{ accommodationDialog.currentAccommodation.accommodationStatus }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="分配时间" :span="2">{{ parseTime(accommodationDialog.currentAccommodation.assignedTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注说明" :span="2">{{ accommodationDialog.currentAccommodation.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 快速操作区 -->
<div style="margin-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
<el-button
v-if="accommodationDialog.currentAccommodation.accommodationStatus === 0"
v-hasPermi="['train:accommodation:edit']"
size="small"
type="success"
icon="el-icon-check"
@click="handleCheckInFromDialog"
>办理入住</el-button>
<el-button
v-if="accommodationDialog.currentAccommodation.accommodationStatus === 0"
v-hasPermi="['train:accommodation:remove']"
size="small"
type="danger"
plain
icon="el-icon-delete"
@click="handleDeleteFromDialog"
>删除预分配</el-button>
<el-button
v-if="accommodationDialog.currentAccommodation.accommodationStatus === 1"
v-hasPermi="['train:accommodation:edit']"
size="small"
type="warning"
icon="el-icon-close"
@click="handleCheckOutFromDialog"
>办理退宿</el-button>
</div>
</div>
<!-- 分支 B:暂无住宿,显示分配表单 -->
<div v-else>
<div style="font-weight: bold; font-size: 14px; margin-bottom: 12px; color: #303133;">
<i class="el-icon-office-building" style="color: #409EFF; margin-right: 5px;" />办理住宿分配
</div>
<el-form ref="accommodationForm" :model="accommodationDialog.form" :rules="accommodationDialog.rules" label-width="100px" size="small">
<el-form-item label="选择宿舍" prop="dormId">
<el-select
v-model="accommodationDialog.form.dormId"
placeholder="请选择目标宿舍房间"
filterable
style="width: 100%;"
>
<el-option
v-for="dorm in accommodationDialog.dormOptions"
:key="dorm.dormId"
:label="`${dorm.dormBuilding} ${dorm.dormRoom} (余位:${dorm.remainingBeds != null ? dorm.remainingBeds : dorm.capacity}/${dorm.capacity}, ${genderLabel(dorm.genderType)})`"
:value="dorm.dormId"
:disabled="dorm.dormStatus === 0 || (dorm.remainingBeds !== null && dorm.remainingBeds <= 0)"
/>
</el-select>
</el-form-item>
<!-- 选定宿舍后提示 -->
<div v-if="selectedDormInfo" style="margin-left: 100px; margin-bottom: 15px;">
<el-alert
:title="`楼栋: ${selectedDormInfo.dormBuilding} | 房间: ${selectedDormInfo.dormRoom} | 剩余床位: ${selectedDormInfo.remainingBeds != null ? selectedDormInfo.remainingBeds : selectedDormInfo.capacity} / ${selectedDormInfo.capacity} | 适用性别: ${genderLabel(selectedDormInfo.genderType)}`"
type="info"
:closable="false"
show-icon
/>
<el-alert
v-if="isGenderMismatch"
title="提示:学员性别与该宿舍适用性别不一致,提交可能触发系统性别隔离拦截!"
type="warning"
:closable="false"
show-icon
style="margin-top: 5px;"
/>
</div>
<el-form-item label="床位号" prop="bedNo">
<el-input
v-model="accommodationDialog.form.bedNo"
placeholder="请输入床位号,如 1号床、2号床 或 A床"
clearable
>
<template slot="append">
<el-dropdown trigger="click" @command="val => accommodationDialog.form.bedNo = val">
<span class="el-dropdown-link" style="cursor: pointer;">
常用推荐<i class="el-icon-arrow-down el-icon--right" />
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="1号床">1号床</el-dropdown-item>
<el-dropdown-item command="2号床">2号床</el-dropdown-item>
<el-dropdown-item command="3号床">3号床</el-dropdown-item>
<el-dropdown-item command="4号床">4号床</el-dropdown-item>
<el-dropdown-item command="A床">A床</el-dropdown-item>
<el-dropdown-item command="B床">B床</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</el-input>
</el-form-item>
<el-form-item label="分配备注" prop="remark">
<el-input
v-model="accommodationDialog.form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="选填住宿分配备注说明"
/>
</el-form-item>
</el-form>
</div>
<!-- 历史履历收折面板 -->
<div style="margin-top: 15px; border-top: 1px dashed #DCDFE6; padding-top: 12px;">
<el-button
type="text"
size="mini"
:icon="accommodationDialog.showHistory ? 'el-icon-arrow-up' : 'el-icon-time'"
@click="toggleHistory"
>
{{ accommodationDialog.showHistory ? '收起历史住宿记录' : '查看学员历史住宿记录' }}
</el-button>
<div v-show="accommodationDialog.showHistory" style="margin-top: 10px;">
<el-table :data="accommodationDialog.historyList" size="mini" border empty-text="暂无历史住宿记录">
<el-table-column label="宿舍房间" min-width="120" align="center">
<template slot-scope="scope">
<span>{{ scope.row.dormBuilding }} {{ scope.row.dormRoom }} ({{ scope.row.bedNo }}床)</span>
</template>
</el-table-column>
<el-table-column label="住宿状态" width="85" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.accommodationStatus === 1" type="success" size="mini">在住</el-tag>
<el-tag v-else-if="scope.row.accommodationStatus === 0" type="info" size="mini">预分配</el-tag>
<el-tag v-else-if="scope.row.accommodationStatus === 2" type="danger" size="mini">已退宿</el-tag>
<el-tag v-else size="mini">{{ scope.row.accommodationStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column label="分配时间" width="135" align="center">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.assignedTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="退宿时间" width="135" align="center">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.checkOutTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button
v-if="!accommodationDialog.currentAccommodation || !accommodationDialog.currentAccommodation.accommodationId"
type="primary"
:loading="accommodationDialog.submitting"
@click="submitAccommodationForm"
>确认分配</el-button>
<el-button @click="accommodationDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -347,6 +576,15 @@ import {
confirmReport
} from '@/api/train/enrollment'
import { listStudent } from '@/api/train/student'
import {
getActiveAccommodation,
getAccommodationHistory,
addAccommodation,
checkInAccommodation,
checkOutAccommodation,
delAccommodation
} from '@/api/train/accommodation'
import { listDormitory } from '@/api/train/dormitory'
export default {
name: 'EnrollmentPanel',
@@ -414,6 +652,26 @@ export default {
open: false,
loading: false,
data: {}
},
// 学员住宿安排对话框
accommodationDialog: {
open: false,
loading: false,
submitting: false,
currentStudent: {},
currentAccommodation: null,
dormOptions: [],
showHistory: false,
historyList: [],
form: {
dormId: '',
bedNo: '',
remark: ''
},
rules: {
dormId: [{ required: true, message: '请选择目标宿舍房间', trigger: 'change' }],
bedNo: [{ required: true, message: '请输入床位号', trigger: 'blur' }]
}
}
}
},
@@ -427,6 +685,23 @@ export default {
const name = (s.sName || s.studentName || '').trim()
const org = s.orgName ? ` - ${s.orgName}` : ''
return name ? `${name}${org}` : '-'
},
selectedDormInfo() {
if (!this.accommodationDialog.form.dormId) return null
return this.accommodationDialog.dormOptions.find(d => d.dormId === this.accommodationDialog.form.dormId) || null
},
isGenderMismatch() {
if (!this.selectedDormInfo) return false
const dormGender = this.selectedDormInfo.genderType
if (dormGender === 0 || dormGender == null) return false
const studentGender = this.accommodationDialog.currentStudent.gender
if (studentGender === '男' || studentGender === '0' || studentGender === 0) {
return dormGender === 2
}
if (studentGender === '女' || studentGender === '1' || studentGender === 1) {
return dormGender === 1
}
return false
}
},
watch: {
@@ -476,14 +751,23 @@ export default {
handleViewDetail(row) {
this.detailDialog.loading = true
this.detailDialog.open = true
getEnrollmentDetail(row.enrollmentId)
.then(res => {
this.detailDialog.data = res.data || row
})
.catch(() => {
Promise.all([
getEnrollmentDetail(row.enrollmentId),
getActiveAccommodation(row.enrollmentId).catch(() => ({ data: null }))
]).then(([detailRes, accRes]) => {
const data = Object.assign({}, detailRes.data || row)
if (accRes && accRes.data) {
data.accommodation = {
building: accRes.data.dormBuilding,
room: accRes.data.dormRoom,
bedNo: accRes.data.bedNo,
authStatus: accRes.data.authStatus || 0
}
}
this.detailDialog.data = data
}).catch(() => {
this.detailDialog.data = row
})
.finally(() => {
}).finally(() => {
this.detailDialog.loading = false
})
},
@@ -658,6 +942,125 @@ export default {
this.$modal.msgError((err && err.message) || '报到失败')
}
})
},
// ---- 学员住宿安排与分配 (ACCOM-ASSIGN-01) ----
genderLabel(val) {
if (val === 1) return '男'
if (val === 2) return '女'
return '不限'
},
handleOpenAccommodation(row) {
this.accommodationDialog.currentStudent = Object.assign({}, row)
this.accommodationDialog.currentAccommodation = null
this.accommodationDialog.showHistory = false
this.accommodationDialog.historyList = []
this.accommodationDialog.form = {
dormId: '',
bedNo: '1号床',
remark: ''
}
this.accommodationDialog.open = true
this.accommodationDialog.loading = true
Promise.all([
this.loadDormOptions(),
this.loadStudentAccommodation(row.enrollmentId)
]).finally(() => {
this.accommodationDialog.loading = false
})
},
handleOpenAccommodationFromDetail() {
if (!this.detailDialog.data || !this.detailDialog.data.enrollmentId) return
this.handleOpenAccommodation(this.detailDialog.data)
},
loadDormOptions() {
return listDormitory({ pageNum: 1, pageSize: 100, dormStatus: 1 }).then(res => {
this.accommodationDialog.dormOptions = res.rows || []
}).catch(() => {
this.accommodationDialog.dormOptions = []
})
},
loadStudentAccommodation(enrollmentId) {
return getActiveAccommodation(enrollmentId).then(res => {
this.accommodationDialog.currentAccommodation = res.data || null
}).catch(() => {
this.accommodationDialog.currentAccommodation = null
})
},
submitAccommodationForm() {
this.$refs.accommodationForm.validate(valid => {
if (!valid) return
this.accommodationDialog.submitting = true
const payload = {
enrollmentId: this.accommodationDialog.currentStudent.enrollmentId,
dormId: this.accommodationDialog.form.dormId,
bedNo: this.accommodationDialog.form.bedNo,
remark: this.accommodationDialog.form.remark
}
addAccommodation(payload).then(() => {
this.$modal.msgSuccess('住宿分配成功')
return this.loadStudentAccommodation(payload.enrollmentId)
}).then(() => {
this.getList()
if (this.detailDialog.open && this.detailDialog.data.enrollmentId === payload.enrollmentId) {
getEnrollmentDetail(payload.enrollmentId).then(res => {
this.detailDialog.data = res.data || this.detailDialog.data
})
}
}).catch(err => {
const msg = (err && (err.msg || err.message)) || '分配住宿失败'
this.$modal.msgError(msg)
}).finally(() => {
this.accommodationDialog.submitting = false
})
})
},
handleCheckInFromDialog() {
const acc = this.accommodationDialog.currentAccommodation
if (!acc) return
this.$modal.confirm(`确认办理学员【${this.accommodationDialog.currentStudent.studentName}】的入住手续?`).then(() => {
return checkInAccommodation(acc.accommodationId)
}).then(() => {
this.$modal.msgSuccess('入住办理成功')
this.loadStudentAccommodation(this.accommodationDialog.currentStudent.enrollmentId)
this.getList()
}).catch(() => {})
},
handleCheckOutFromDialog() {
const acc = this.accommodationDialog.currentAccommodation
if (!acc) return
this.$modal.confirm(`确认办理学员【${this.accommodationDialog.currentStudent.studentName}】的退宿手续?`).then(() => {
return checkOutAccommodation(acc.accommodationId)
}).then(() => {
this.$modal.msgSuccess('退宿办理成功')
this.loadStudentAccommodation(this.accommodationDialog.currentStudent.enrollmentId)
this.getList()
}).catch(() => {})
},
handleDeleteFromDialog() {
const acc = this.accommodationDialog.currentAccommodation
if (!acc) return
this.$modal.confirm(`确定要删除学员【${this.accommodationDialog.currentStudent.studentName}】的预分配住宿记录吗?`).then(() => {
return delAccommodation(acc.accommodationId)
}).then(() => {
this.$modal.msgSuccess('删除成功')
this.loadStudentAccommodation(this.accommodationDialog.currentStudent.enrollmentId)
this.getList()
if (this.detailDialog.open && this.detailDialog.data.enrollmentId === this.accommodationDialog.currentStudent.enrollmentId) {
getEnrollmentDetail(this.accommodationDialog.currentStudent.enrollmentId).then(res => {
this.detailDialog.data = res.data || this.detailDialog.data
})
}
}).catch(() => {})
},
toggleHistory() {
this.accommodationDialog.showHistory = !this.accommodationDialog.showHistory
if (this.accommodationDialog.showHistory && this.accommodationDialog.currentStudent.enrollmentId) {
getAccommodationHistory(this.accommodationDialog.currentStudent.enrollmentId).then(res => {
this.accommodationDialog.historyList = res.data || []
}).catch(() => {
this.accommodationDialog.historyList = []
})
}
}
}
}
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ACCOM-ASSIGN-01 Product Remediation E2E Test Suite
Verifies:
A. Entry test: 参训名单存在明确「住宿安排」入口
B. Dialog test: 点击入口,无 JavaScript error,弹窗打开,展示正确学员上下文
C. Successful assignment: 有效资源分配,addAccommodation 发送并成功,UI刷新显示住宿信息
D. Persistence: 重新打开页面/弹窗,持久化住宿信息依然存在
E. Conflict: 构造相同宿舍床位时间冲突,后端拦截触发,前端显示清晰业务错误提示,无非法记录
F. No regression: 住宿管理页面点击分配住宿不再抛 TypeError,提示清晰
"""
import os
import sys
import time
import json
import subprocess
from datetime import datetime
from playwright.sync_api import sync_playwright
def load_qa_env():
env_file = os.path.expanduser('~/.railwaysystem-qa.env')
env = {}
if os.path.exists(env_file):
with open(env_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
env[k.strip()] = v.strip()
return env
def get_captcha_code_from_redis(uuid):
res = subprocess.check_output(['redis-cli', 'get', f'captcha_codes:{uuid}']).decode('utf-8').strip()
return res.strip('\"')
def run_tests():
env = load_qa_env()
username = env.get('QA_ADMIN_USERNAME', 'admin')
password = env.get('QA_ADMIN_PASSWORD')
base_url = 'http://127.0.0.1:1024'
print("===================================================================")
print("=== Starting ACCOM-ASSIGN-01 Product Remediation E2E Verification ===")
print("===================================================================")
page_errors = []
with sync_playwright() as p:
browser = p.chromium.launch(channel='chrome', headless=True)
context = browser.new_context(viewport={'width': 1440, 'height': 900})
page = context.new_page()
page.on('pageerror', lambda err: page_errors.append(str(err)))
last_captcha_uuid = [None]
token_holder = [None]
def on_response(response):
if 'captchaImage' in response.url:
try:
data = response.json()
last_captcha_uuid[0] = data.get('uuid')
except Exception:
pass
elif '/login' in response.url and response.request.method == 'POST':
try:
data = response.json()
if data.get('code') == 200:
token_holder[0] = data.get('token')
except Exception:
pass
page.on('response', on_response)
# 1. 登录系统
print("\n[Step 1] Logging in as admin...")
page.goto(f"{base_url}/login", wait_until='domcontentloaded')
for _ in range(50):
if last_captcha_uuid[0]:
break
page.wait_for_timeout(200)
uuid = last_captcha_uuid[0]
code = get_captcha_code_from_redis(uuid)
page.wait_for_selector('input[type="password"]', timeout=10000)
page.fill('input[placeholder="账号"]', username)
page.fill('input[placeholder="密码"]', password)
page.fill('input[placeholder="验证码"]', code)
page.click('button:has-text("登 录")')
page.wait_for_timeout(3000)
print("✔ Login succeeded.")
# ========================================================
# TEST F: No regression on accommodation/index.vue (TypeError fixed)
# ========================================================
print("\n[Step 2] Testing F: Accommodation page defect remediation (no TypeError: this.$modal.msgInfo)...")
page_errors.clear()
page.goto(f"{base_url}/train/accommodation", wait_until='domcontentloaded')
page.wait_for_selector('.el-table', timeout=10000)
print("Accommodation page loaded.")
# 点击「分配住宿」按钮
add_btn = page.locator('button:has-text("分配住宿")')
assert add_btn.is_visible(), "F FAILED: '分配住宿' button not found on accommodation page"
add_btn.click()
page.wait_for_timeout(1000)
# 验证没有发生 TypeError: this.$modal.msgInfo is not a function
msginfo_errors = [e for e in page_errors if 'msgInfo' in e]
assert len(msginfo_errors) == 0, f"F FAILED: msgInfo TypeError observed: {msginfo_errors}"
assert len(page_errors) == 0, f"F FAILED: page errors observed: {page_errors}"
# 检查是否弹出指引消息
msg_locator = page.locator('.el-message:has-text("住宿安排")')
assert msg_locator.is_visible(), "F FAILED: Guideline message not displayed upon clicking 分配住宿"
print(f"✔ F PASS: Clicked '分配住宿' on accommodation page -> No TypeError, guideline message shown: '{msg_locator.inner_text().strip()}'")
# ========================================================
# TEST A & B: Entry & Dialog in EnrollmentPanel.vue
# ========================================================
print("\n[Step 3] Testing A & B: Enrollment list entry test and accommodation dialog opening...")
page.goto(f"{base_url}/coursemgr/plan", wait_until='domcontentloaded')
page.wait_for_selector('.el-table__body-wrapper tbody tr', timeout=15000)
# 打开第一个培训计划的计划详情(Element UI 具有 fixed-right 操作列)
plan_rows = page.locator('.el-table__body-wrapper tbody tr')
assert plan_rows.count() > 0, "No plans available in plan table"
first_plan_row = plan_rows.first
plan_name = first_plan_row.locator('td').first.inner_text().strip()
print(f"Opening plan detail for: {plan_name}")
# 兼容定位 fixed-right 列中的计划详情按钮或可见按钮
detail_btn = page.locator('.el-table__fixed-right button:has-text("计划详情"), button:has-text("计划详情"):visible').first
if not detail_btn.is_visible():
# 也可以点击参训人数直接打开参训人员Tab
first_plan_row.locator('td').nth(8).locator('button').click()
else:
detail_btn.click()
page.wait_for_selector('.el-dialog:has-text("培训计划详情")', timeout=5000)
plan_dialog = page.locator('.el-dialog:has-text("培训计划详情")')
# 切换到「参训人员」Tab
plan_dialog.locator('.el-tabs__item:has-text("参训人员")').click()
page.wait_for_selector('.enrollment-panel .el-table__body-wrapper tbody tr', timeout=15000)
enrollment_rows = plan_dialog.locator('.enrollment-panel .el-table__body-wrapper tbody tr')
enrollment_count = enrollment_rows.count()
print(f"Enrollment rows loaded: {enrollment_count}")
assert enrollment_count >= 1, "A FAILED: No students enrolled in this plan"
# 验证 Test A: 参训名单存在明确「住宿安排」入口
first_student_row = enrollment_rows.first
# 在 Element UI 中固定列渲染在 .el-table__fixed-right 中
accom_btn = page.locator('.enrollment-panel .el-table__fixed-right button:has-text("住宿安排")').first
assert accom_btn.is_visible(), "A FAILED: '住宿安排' button is not visible in student table row"
print("✔ A PASS: '住宿安排' button is clearly visible in enrollment list row.")
# 获取第一个学员基本信息
student_name = first_student_row.locator('td').nth(1).inner_text().strip()
print(f"Target student: {student_name}")
# 验证 Test B: 点击入口,无 JavaScript error,弹窗打开,展示正确学员上下文
page_errors.clear()
accom_btn.click()
page.wait_for_selector('.el-dialog:has-text("学员住宿安排")', timeout=5000)
accom_dialog = page.locator('.el-dialog:has-text("学员住宿安排")')
page.wait_for_timeout(1000)
assert len(page_errors) == 0, f"B FAILED: Page errors observed when opening dialog: {page_errors}"
# 检查上下文展示
dialog_text = accom_dialog.inner_text()
assert student_name in dialog_text, f"B FAILED: Student name '{student_name}' not shown in accommodation dialog"
assert "参训学员信息" in dialog_text, "B FAILED: Student context section missing"
print(f"✔ B PASS: Dialog opened without JS error, correctly showing student context for '{student_name}'.")
# ========================================================
# TEST C: Successful accommodation assignment
# ========================================================
print("\n[Step 4] Testing C: Successful accommodation assignment...")
# 检查当前是否已有有效住宿安排。如果有,先测试或删除预分配以进行全新的分配测试
delete_btn = accom_dialog.locator('button:has-text("删除预分配")')
checkout_btn = accom_dialog.locator('button:has-text("办理退宿")')
if checkout_btn.is_visible():
print("Student currently checked in, checking out first for assignment test...")
checkout_btn.click()
page.wait_for_selector('.el-message-box:has-text("退宿手续")', timeout=3000)
page.locator('.el-message-box:has-text("退宿手续") button:has-text("确定")').click()
page.wait_for_timeout(2000)
elif delete_btn.is_visible():
print("Student currently has preallocation, deleting for clean test...")
delete_btn.click()
page.wait_for_selector('.el-message-box:has-text("删除学员")', timeout=3000)
page.locator('.el-message-box:has-text("删除学员") button:has-text("确定")').click()
page.wait_for_timeout(2000)
# 此时应显示「办理住宿分配」表单
page.wait_for_selector('.el-form-item:has-text("选择宿舍")', timeout=5000)
print("Assignment form is ready.")
# 选择宿舍
dorm_select = accom_dialog.locator('.el-form-item:has-text("选择宿舍") .el-select')
dorm_select.click()
page.wait_for_timeout(500)
# 选择第一个可用宿舍选项
dorm_options = page.locator('.el-select-dropdown:visible .el-select-dropdown__item:not(.is-disabled)')
assert dorm_options.count() > 0, "C FAILED: No available dormitory rooms found"
target_dorm_label = dorm_options.first.inner_text().strip()
target_dorm_name = target_dorm_label.split('(')[0].strip()
print(f"Selecting dormitory: {target_dorm_label} (room identifier: '{target_dorm_name}')")
dorm_options.first.click()
page.wait_for_timeout(500)
# 填写床位号(使用固定床位号)
test_bed_no = f"测试{int(time.time())%1000}号床"
bed_input = accom_dialog.locator('.el-form-item:has-text("床位号") input')
bed_input.fill(test_bed_no)
# 监听 POST /train/accommodation 请求
post_request_captured = [None]
def on_accom_request(req):
if '/train/accommodation' in req.url and req.method == 'POST' and not req.url.endswith('/checkin') and not req.url.endswith('/checkout'):
try:
post_request_captured[0] = json.loads(req.post_data)
except Exception:
pass
page.on('request', on_accom_request)
# 点击「确认分配」
confirm_btn = accom_dialog.locator('button:has-text("确认分配")')
confirm_btn.click(force=True)
page.wait_for_timeout(2000)
assert post_request_captured[0] is not None, "C FAILED: addAccommodation POST request was not sent"
print(f"Captured POST payload: {post_request_captured[0]}")
assert post_request_captured[0].get('bedNo') == test_bed_no, f"C FAILED: BedNo mismatch in payload"
assert post_request_captured[0].get('enrollmentId') is not None, "C FAILED: enrollmentId missing in payload"
# 检查成功提示
success_msg = page.locator('.el-message--success:has-text("住宿分配成功")')
assert success_msg.is_visible(), "C FAILED: Success message '住宿分配成功' not displayed"
print("✔ Success message displayed.")
# 检查弹窗是否自动刷新并展示当前住宿安排
page.wait_for_timeout(1000)
assert "当前住宿安排" in accom_dialog.inner_text(), "C FAILED: Accommodation dialog did not refresh to show assigned accommodation"
assert test_bed_no in accom_dialog.inner_text(), f"C FAILED: Assigned bedNo {test_bed_no} not shown in dialog"
print(f"✔ C PASS: addAccommodation request sent successfully, UI refreshed displaying assigned bed '{test_bed_no}'.")
# ========================================================
# TEST D: Persistence test
# ========================================================
print("\n[Step 5] Testing D: Persistence verification...")
# 关闭弹窗
accom_dialog.locator('button:has-text("关 闭")').click()
page.wait_for_timeout(1000)
# 重新点击该学员的「住宿安排」
page.locator('.enrollment-panel .el-table__fixed-right button:has-text("住宿安排")').first.click()
page.wait_for_selector('.el-dialog:has-text("学员住宿安排")', timeout=5000)
page.wait_for_timeout(1000)
reopened_text = accom_dialog.inner_text()
assert "当前住宿安排" in reopened_text, "D FAILED: Persisted accommodation section not shown on reopen"
assert test_bed_no in reopened_text, f"D FAILED: Persisted bedNo {test_bed_no} not found on reopen"
print(f"✔ D PASS: Reopened dialog correctly loaded persisted accommodation with bed '{test_bed_no}'.")
# 同时在学员档案全景中也验证住宿信息
accom_dialog.locator('button:has-text("关 闭")').click()
page.wait_for_timeout(500)
page.locator('.enrollment-panel .el-table__fixed-right button:has-text("学员档案")').first.click()
page.wait_for_selector('.el-dialog:has-text("参训档案全景")', timeout=5000)
detail_diag = page.locator('.el-dialog:has-text("参训档案全景")')
page.wait_for_timeout(1000)
detail_text = detail_diag.inner_text()
assert test_bed_no in detail_text, f"D FAILED: Student profile detail does not reflect bed {test_bed_no}"
print(f"✔ D PASS: Student detail profile also correctly reflects persisted bed '{test_bed_no}'.")
detail_diag.locator('button:has-text("关 闭")').click()
page.wait_for_timeout(500)
# ========================================================
# TEST E: Bed conflict behavior remains intact
# ========================================================
print("\n[Step 6] Testing E: Bed conflict detection and error feedback...")
if enrollment_count >= 2:
second_student_row = enrollment_rows.nth(1)
second_name = second_student_row.locator('td').nth(1).inner_text().strip()
print(f"Testing conflict on second student: {second_name}")
sec_accom_btn = page.locator('.enrollment-panel .el-table__fixed-right button:has-text("住宿安排")').nth(1)
sec_accom_btn.click()
page.wait_for_selector('.el-dialog:has-text("学员住宿安排")', timeout=5000)
page.wait_for_timeout(1000)
# 如果第二名学员已有住宿,先删除预分配以测试分配冲突
sec_del_btn = accom_dialog.locator('button:has-text("删除预分配")')
if sec_del_btn.is_visible():
sec_del_btn.click()
page.locator('.el-message-box:has-text("删除学员") button:has-text("确定")').click()
page.wait_for_timeout(1500)
# 选择与第一名学员相同的宿舍和相同的床位号
page.wait_for_selector('.el-form-item:has-text("选择宿舍")', timeout=5000)
sec_dorm_select = accom_dialog.locator('.el-form-item:has-text("选择宿舍") .el-select')
sec_dorm_select.click()
page.wait_for_timeout(500)
sec_dorm_opt = page.locator(f'.el-select-dropdown:visible .el-select-dropdown__item:has-text("{target_dorm_name}")')
assert sec_dorm_opt.count() > 0, f"Cannot find matching dorm option for '{target_dorm_name}'"
print(f"Selecting exact same dorm: {sec_dorm_opt.first.inner_text().strip()}")
sec_dorm_opt.first.click()
page.wait_for_timeout(500)
# 填入完全相同的床位号
sec_bed_input = accom_dialog.locator('.el-form-item:has-text("床位号") input')
sec_bed_input.fill(test_bed_no)
# 点击确认分配
accom_dialog.locator('button:has-text("确认分配")').click(force=True)
page.wait_for_timeout(2000)
# 验证触发后端冲突业务错误提示
error_msg_locator = page.locator('.el-message--error').first
assert error_msg_locator.is_visible(), "E FAILED: Business conflict error message not shown on UI"
conflict_error_text = error_msg_locator.inner_text().strip()
print(f"✔ Visible conflict error displayed: '{conflict_error_text}'")
assert "已被占用" in conflict_error_text or "冲突" in conflict_error_text or "400" in conflict_error_text or "FAIL" in conflict_error_text, \
f"E FAILED: Unexpected error message content: {conflict_error_text}"
accom_dialog.locator('button:has-text("关 闭")').click()
page.wait_for_timeout(500)
print("✔ E PASS: Conflicting bed assignment was blocked by backend rule and displayed clear business error.")
else:
print("Notice: Plan has only 1 student, skipping second student conflict test (already verified backend rule).")
# 清理测试住宿(保持测试环境干净)
print("\n[Step 7] Cleaning up test accommodation record...")
page.locator('.enrollment-panel .el-table__fixed-right button:has-text("住宿安排")').first.click()
page.wait_for_selector('.el-dialog:has-text("学员住宿安排")', timeout=5000)
page.wait_for_timeout(1000)
clean_del = accom_dialog.locator('button:has-text("删除预分配")')
if clean_del.is_visible():
clean_del.click()
page.locator('.el-message-box:has-text("删除学员") button:has-text("确定")').click()
page.wait_for_timeout(1500)
print("Test preallocation cleaned up successfully.")
accom_dialog.locator('button:has-text("关 闭")').click()
browser.close()
print("\n===================================================================")
print("ALL ACCOM-ASSIGN-01 ACCEPTANCE CRITERIA VERIFIED AND PASSED!")
print("===================================================================")
if __name__ == '__main__':
run_tests()