提交 26cc89c5 authored 作者: 陈泽健's avatar 陈泽健

fix(executor): 登录检测多信号优化与设备模拟按类型批量启停

- playwright_executor: 登录检测改为多信号(.block/.home_nav_left/登录表单消失),Phase 4 URL直达增加跳过列表兜底,登录点击后同步检测登录态
- 设备模拟: 新增按设备类型批量启动/停止(start_all_by_device_type/stop_all_by_device_type),含路由/schema/API封装/前端联动
- 前端: 安全测试提升为独立一级菜单
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 bb6311cf
......@@ -533,18 +533,29 @@ class PlaywrightExecutor:
检测是否已登录
用于手动登录步骤(auto_login=False)执行后的状态同步。
判断依据:URL 不含 login,且页面出现常用功能卡片(.block)或主页特征
多信号检测:.block 卡片 / .home_nav_left 导航栏 / 登录表单消失 + URL 离开登录路由
如果检测到已登录,设置 _is_logged_in=True,
确保下一个用例(auto_login=True)复用登录态而非重新登录。
"""
try:
current_url = self._page.url
# 不在登录页 + 页面有 .block(常用功能卡片)= 已登录进入主页
if "login" not in current_url.lower():
# 多信号检测
has_block = self._page.query_selector(".block")
if has_block:
has_nav = self._page.query_selector(".home_nav_left")
login_form = self._page.query_selector('input[placeholder*="手机号"]')
on_login_route = (
"platform%2Flogin" in current_url
or current_url.rstrip("/").endswith("#/login")
)
if has_block or has_nav or (not login_form and not on_login_route):
self._is_logged_in = True
logger.info("检测到已登录进入主页,设置登录态标记")
logger.info(
f"检测到已登录进入主页,设置登录态标记 "
f"(block={bool(has_block)}, nav={bool(has_nav)}, "
f"login_form={bool(login_form)}, login_route={on_login_route})"
)
except Exception:
pass
......@@ -921,6 +932,23 @@ class PlaywrightExecutor:
# 如果是被标记为跳过的中间导航步骤,跳过执行
if order in skip_orders:
# 兜底:Phase 4 未完成 URL 直达(登录检测失败等),
# 且当前 URL 既非目标页面也非登录页 → 强制直达目标 URL
if not navigated_to_target and page_target is not None:
current_url = self._page.url
target_url = page_target["url"]
on_login_route = (
"login" in current_url.lower()
or "platform%2Flogin" in current_url
or current_url.rstrip("/").endswith("#/login")
)
if not on_login_route and target_url not in current_url:
logger.warning(
f"[页面直达] 兜底: 步骤 {order} 处于跳过列表但未完成直达导航,"
f"强制导航到 {target_url} (当前 URL: {current_url})"
)
self._navigate_to_target_page(page_target)
navigated_to_target = True
step_result = StepResult(
order=order,
name=step.get("name", ""),
......@@ -941,6 +969,15 @@ class PlaywrightExecutor:
step_result = self.execute_step(step, callback=callback)
result.steps_result.append(step_result)
# 同步登录态:登录点击/导航后可能已进入主页,
# 多信号检测确保 _is_logged_in 及时置位,触发 Phase 4 URL 直达
if not self._is_logged_in and step_result.status == "passed":
# 登录类点击步骤多等待一小段时间,确保 SPA 跳转完成
if "登录" in step_result.name and step_result.action == "click":
self._page.wait_for_timeout(1500)
logger.debug(f"登录点击步骤 {order} 后等待 SPA 跳转并重新检测登录态")
self._detect_login_state()
# Phase 4:用例内登录成功后,若已识别目标页面且尚未直达导航,执行 URL 直达
if (
not navigated_to_target
......
......@@ -24,7 +24,7 @@ from app.schemas.device_sim import (
ReportLogResponse, ReportLogListResponse,
ManualReportRequest, TestConnectionResponse,
TopicTemplateResponse, TopicTemplateListResponse,
BatchOperationRequest, BatchOperationResponse,
BatchOperationRequest, BatchAllOperationRequest, BatchOperationResponse,
DeviceImportResponse,
)
from app.services.device_sim_service import DeviceSimService
......@@ -354,6 +354,42 @@ async def import_devices(
raise HTTPException(status_code=500, detail=f"批量导入设备失败: {str(e)}")
@router.post("/devices/batch/start-all", response_model=BatchOperationResponse)
async def start_all_simulators(
data: BatchAllOperationRequest,
service: DeviceSimService = Depends(get_device_sim_service)
):
"""
按设备类型全部启动(不分页,全量)
启动该类型下所有设备,可选按环境配置筛选。
"""
try:
result = await service.start_all_by_device_type(data.device_type, data.env_config_id)
return BatchOperationResponse(**result)
except Exception as e:
logger.error(f"全部启动失败: {e}")
raise HTTPException(status_code=500, detail=f"全部启动失败: {str(e)}")
@router.post("/devices/batch/stop-all", response_model=BatchOperationResponse)
async def stop_all_simulators(
data: BatchAllOperationRequest,
service: DeviceSimService = Depends(get_device_sim_service)
):
"""
按设备类型全部停止(不分页,全量)
停止该类型下所有设备,可选按环境配置筛选。
"""
try:
result = await service.stop_all_by_device_type(data.device_type, data.env_config_id)
return BatchOperationResponse(**result)
except Exception as e:
logger.error(f"全部停止失败: {e}")
raise HTTPException(status_code=500, detail=f"全部停止失败: {str(e)}")
@router.post("/devices/batch/start", response_model=BatchOperationResponse)
async def batch_start_simulators(
data: BatchOperationRequest,
......
......@@ -289,6 +289,18 @@ class BatchOperationRequest(BaseModel):
)
class BatchAllOperationRequest(BaseModel):
"""按设备类型全量操作请求"""
device_type: str = Field(..., pattern="^(door|paperless|central|client)$", description="设备类型")
env_config_id: Optional[str] = Field(default=None, description="环境配置筛选(可选)")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class BatchOperationResponse(BaseModel):
"""批量操作响应"""
......
......@@ -1039,6 +1039,66 @@ class DeviceSimService:
"message": f"批量停止完成: 成功 {success_count}, 失败 {len(failed_ids)}"
}
async def start_all_by_device_type(self, device_type: str, env_config_id: Optional[str] = None) -> dict:
"""
按设备类型批量启动所有设备(不分页,全量)
Args:
device_type: 设备类型筛选
env_config_id: 可选,环境配置筛选
Returns:
dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str}
"""
# 查询该类型所有设备 ID
conditions = [DeviceSimulator.device_type == device_type]
if env_config_id:
conditions.append(DeviceSimulator.env_config_id == env_config_id)
query = select(DeviceSimulator.id).where(and_(*conditions))
result = await self.db.execute(query)
all_ids = [row[0] for row in result.all()]
if not all_ids:
return {
"success_count": 0,
"failed_count": 0,
"failed_ids": [],
"message": f"没有找到 {device_type} 类型的设备"
}
return await self.batch_start_simulators(all_ids)
async def stop_all_by_device_type(self, device_type: str, env_config_id: Optional[str] = None) -> dict:
"""
按设备类型批量停止所有设备(不分页,全量)
Args:
device_type: 设备类型筛选
env_config_id: 可选,环境配置筛选
Returns:
dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str}
"""
# 查询该类型所有设备 ID
conditions = [DeviceSimulator.device_type == device_type]
if env_config_id:
conditions.append(DeviceSimulator.env_config_id == env_config_id)
query = select(DeviceSimulator.id).where(and_(*conditions))
result = await self.db.execute(query)
all_ids = [row[0] for row in result.all()]
if not all_ids:
return {
"success_count": 0,
"failed_count": 0,
"failed_ids": [],
"message": f"没有找到 {device_type} 类型的设备"
}
return await self.batch_stop_simulators(all_ids)
async def batch_delete_simulators(self, device_ids: List[str]) -> dict:
"""
批量删除模拟设备
......
......@@ -37,7 +37,7 @@
<el-menu-item index="/modules/custom">项目定制模块</el-menu-item>
</el-sub-menu>
<!-- 自动化测试(父菜单,UI/接口/安全 三类) -->
<!-- 自动化测试(父菜单,UI/接口类) -->
<el-sub-menu index="/auto">
<template #title>
<el-icon><VideoPlay /></el-icon>
......@@ -52,7 +52,6 @@
</template>
<el-menu-item index="/cases/ui">UI自动化</el-menu-item>
<el-menu-item index="/cases/api">接口自动化</el-menu-item>
<el-menu-item index="/cases/security">安全自动化测试</el-menu-item>
</el-sub-menu>
<!-- 执行中心 -->
......@@ -63,7 +62,6 @@
</template>
<el-menu-item index="/execution/ui">UI自动化</el-menu-item>
<el-menu-item index="/execution/api">接口自动化</el-menu-item>
<el-menu-item index="/execution/security">安全自动化测试</el-menu-item>
</el-sub-menu>
<!-- 报告中心 -->
......@@ -74,10 +72,20 @@
</template>
<el-menu-item index="/reports/ui">UI自动化测试</el-menu-item>
<el-menu-item index="/reports/api">接口测试</el-menu-item>
<el-menu-item index="/reports/security">安全测试</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<!-- 安全测试(一级菜单,含用例/执行/报告) -->
<el-sub-menu index="/security">
<template #title>
<el-icon><Lock /></el-icon>
<span>安全测试</span>
</template>
<el-menu-item index="/cases/security">用例管理</el-menu-item>
<el-menu-item index="/execution/security">执行中心</el-menu-item>
<el-menu-item index="/reports/security">报告中心</el-menu-item>
</el-sub-menu>
<!-- 性能测试(父菜单,含三个子页面) -->
<el-sub-menu index="/performance">
<template #title>
......@@ -196,6 +204,7 @@ import {
Promotion,
Iphone,
Tools,
Lock,
Setting
} from '@element-plus/icons-vue'
......@@ -270,8 +279,8 @@ const currentRoute = computed(() => {
const seg = route.path.split('/').filter(Boolean)
const root = '/' + (seg[0] || '')
// 无 type 参数时回退到默认子项,保证菜单高亮
if (seg.length === 1 && ['/modules', '/cases', '/execution', '/reports', '/system', '/deploy', '/device-sim', '/performance'].includes(root)) {
const defaultType = root === '/modules' ? 'standard' : root === '/system' ? 'settings' : root === '/deploy' ? 'servers' : root === '/device-sim' ? 'settings' : root === '/performance' ? 'tasks' : 'ui'
if (seg.length === 1 && ['/modules', '/cases', '/execution', '/reports', '/system', '/deploy', '/device-sim', '/performance', '/security'].includes(root)) {
const defaultType = root === '/modules' ? 'standard' : root === '/system' ? 'settings' : root === '/deploy' ? 'servers' : root === '/device-sim' ? 'settings' : root === '/performance' ? 'tasks' : root === '/security' ? 'security' : 'ui'
return `${root}/${defaultType}`
}
return route.path
......@@ -290,6 +299,7 @@ const pageTitle = computed(() => {
'/execution': '执行中心',
'/reports': '报告中心',
'/performance': '性能测试',
'/security': '安全测试',
'/deploy': '自动化部署测试',
'/tools': '辅助工具',
'/device-sim': '设备模拟',
......
......@@ -156,6 +156,16 @@ export function batchStartDevices(deviceIds: string[]): Promise<{successCount: n
return request.post(`${BASE}/devices/batch/start`, { deviceIds })
}
/** 全量启动:按设备类型启动所有设备(不分页) */
export function startAllDevices(deviceType: string, envConfigId?: string): Promise<{successCount: number, failedCount: number, failedIds: string[], message: string}> {
return request.post(`${BASE}/devices/batch/start-all`, { deviceType, envConfigId })
}
/** 全量停止:按设备类型停止所有设备(不分页) */
export function stopAllDevices(deviceType: string, envConfigId?: string): Promise<{successCount: number, failedCount: number, failedIds: string[], message: string}> {
return request.post(`${BASE}/devices/batch/stop-all`, { deviceType, envConfigId })
}
/** 批量停止设备 */
export function batchStopDevices(deviceIds: string[]): Promise<{successCount: number, failedCount: number, failedIds: string[], message: string}> {
return request.post(`${BASE}/devices/batch/stop`, { deviceIds })
......
......@@ -410,6 +410,8 @@ import {
importDevices,
downloadImportTemplate,
switchDeviceType,
startAllDevices,
stopAllDevices,
} from '@/api/deviceSim'
import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate, DeviceImportResponse } from '@/types/device'
import { DEVICE_TYPE_LABELS, CENTRAL_DEVICE_TYPES } from '@/types/device'
......@@ -833,16 +835,11 @@ async function handleBatchDelete() {
}
}
/** 全部启动 */
/** 全部启动(作用于当前模块类型下所有设备,不受分页限制) */
async function handleStartAll() {
batchOperating.value = true
try {
const allIds = devices.value.map(d => d.id)
if (allIds.length === 0) {
ElMessage.warning('没有可启动的设备')
return
}
const res = await batchStartDevices(allIds)
const res = await startAllDevices(props.deviceType, filterEnvConfigId.value || undefined)
ElMessage.success(res.message)
await loadDevices()
emit('device-changed')
......@@ -853,16 +850,11 @@ async function handleStartAll() {
}
}
/** 全部停止 */
/** 全部停止(作用于当前模块类型下所有设备,不受分页限制) */
async function handleStopAll() {
batchOperating.value = true
try {
const allIds = devices.value.map(d => d.id)
if (allIds.length === 0) {
ElMessage.warning('没有可停止的设备')
return
}
const res = await batchStopDevices(allIds)
const res = await stopAllDevices(props.deviceType, filterEnvConfigId.value || undefined)
ElMessage.success(res.message)
await loadDevices()
emit('device-changed')
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论