<template>
|
<div class="result-analysis">
|
<div class="panel-header">
|
<div>
|
<h3>测试结果分析</h3>
|
<p v-if="task">任务编号:{{ task.taskId }}</p>
|
<p v-else class="warning">请选择一个任务查看分析结果</p>
|
</div>
|
<div class="action-buttons">
|
<el-button :loading="loading" @click="handleRefresh">
|
<el-icon><Refresh /></el-icon>
|
刷新
|
</el-button>
|
<el-button type="primary" :disabled="!task" @click="handleExport('json')">
|
<el-icon><Download /></el-icon>
|
导出JSON
|
</el-button>
|
<el-button type="success" :disabled="!task" @click="handleExport('excel')">
|
<el-icon><Document /></el-icon>
|
导出Excel
|
</el-button>
|
</div>
|
</div>
|
|
<div v-if="!task" class="empty-state">
|
<el-empty description="暂无任务数据" />
|
</div>
|
|
<div v-else class="analysis-content">
|
<!-- 总体结果 -->
|
<el-card class="overall-result-card" shadow="never">
|
<template #header>
|
<div class="card-header">
|
<span>总体结果</span>
|
<el-tag :type="getOverallStatusType()" size="large">
|
{{ getOverallStatusLabel() }}
|
</el-tag>
|
</div>
|
</template>
|
<div class="result-stats">
|
<div class="stat-item">
|
<div class="stat-label">执行时间</div>
|
<div class="stat-value">{{ formatDuration(taskDuration) }}</div>
|
</div>
|
<div class="stat-item">
|
<div class="stat-label">总步骤数</div>
|
<div class="stat-value">{{ task.totalSteps || 0 }}</div>
|
</div>
|
<div class="stat-item">
|
<div class="stat-label">完成步骤</div>
|
<div class="stat-value success">{{ completedSteps }}</div>
|
</div>
|
<div class="stat-item">
|
<div class="stat-label">失败步骤</div>
|
<div class="stat-value danger">{{ failedSteps }}</div>
|
</div>
|
<div class="stat-item">
|
<div class="stat-label">成功率</div>
|
<div class="stat-value" :class="successRateClass">
|
{{ successRate }}%
|
</div>
|
</div>
|
</div>
|
</el-card>
|
|
<!-- 进度条 -->
|
<el-card class="progress-card" shadow="never">
|
<template #header>
|
<span>执行进度</span>
|
</template>
|
<el-progress
|
:percentage="progressPercentage"
|
:status="progressStatus"
|
:stroke-width="20"
|
:format="() => `${completedSteps}/${task.totalSteps || 0}`"
|
/>
|
</el-card>
|
|
<!-- 步骤详情 -->
|
<el-card class="steps-card" shadow="never">
|
<template #header>
|
<span>步骤执行详情</span>
|
</template>
|
<el-table
|
v-loading="stepsLoading"
|
:data="steps"
|
stripe
|
style="width: 100%"
|
>
|
<el-table-column type="index" label="序号" width="60" />
|
<el-table-column prop="stepName" label="步骤名称" min-width="150" />
|
<el-table-column prop="deviceId" label="设备ID" width="120" />
|
<el-table-column prop="status" label="状态" width="100">
|
<template #default="{ row }">
|
<el-tag :type="getStepStatusType(row.status)">
|
{{ getStepStatusLabel(row.status) }}
|
</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column label="耗时" width="100">
|
<template #default="{ row }">
|
{{ formatDuration(row.durationMs) }}
|
</template>
|
</el-table-column>
|
<el-table-column label="重试次数" width="100">
|
<template #default="{ row }">
|
{{ row.retryCount || 0 }}
|
</template>
|
</el-table-column>
|
<el-table-column label="开始时间" min-width="160">
|
<template #default="{ row }">
|
{{ formatDateTime(row.startTime) }}
|
</template>
|
</el-table-column>
|
<el-table-column label="结束时间" min-width="160">
|
<template #default="{ row }">
|
{{ formatDateTime(row.endTime) }}
|
</template>
|
</el-table-column>
|
<el-table-column prop="errorMessage" label="错误信息" min-width="200" show-overflow-tooltip />
|
<el-table-column label="操作" width="120" fixed="right">
|
<template #default="{ row }">
|
<el-button
|
link
|
type="primary"
|
size="small"
|
@click="viewStepDetail(row)"
|
>
|
查看详情
|
</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-card>
|
|
<!-- 数据统计图表 -->
|
<el-card class="chart-card" shadow="never" v-if="steps.length > 0">
|
<template #header>
|
<span>执行时间分布</span>
|
</template>
|
<div class="chart-container">
|
<div class="chart-item" v-for="(step, index) in steps" :key="step.id">
|
<div class="chart-bar">
|
<div
|
class="bar-fill"
|
:class="getStepStatusClass(step.status)"
|
:style="{ width: getBarWidth(step.durationMs) + '%' }"
|
>
|
<span class="bar-label">{{ formatDuration(step.durationMs) }}</span>
|
</div>
|
</div>
|
<div class="chart-label">{{ step.stepName }}</div>
|
</div>
|
</div>
|
</el-card>
|
</div>
|
|
<!-- 步骤详情对话框 -->
|
<el-dialog
|
v-model="detailDialogVisible"
|
:title="`步骤详情 - ${selectedStep?.stepName || ''}`"
|
width="60%"
|
>
|
<div v-if="selectedStep" class="step-detail-content">
|
<el-descriptions :column="2" border>
|
<el-descriptions-item label="步骤名称">
|
{{ selectedStep.stepName }}
|
</el-descriptions-item>
|
<el-descriptions-item label="设备ID">
|
{{ selectedStep.deviceId }}
|
</el-descriptions-item>
|
<el-descriptions-item label="状态">
|
<el-tag :type="getStepStatusType(selectedStep.status)">
|
{{ getStepStatusLabel(selectedStep.status) }}
|
</el-tag>
|
</el-descriptions-item>
|
<el-descriptions-item label="重试次数">
|
{{ selectedStep.retryCount || 0 }}
|
</el-descriptions-item>
|
<el-descriptions-item label="开始时间">
|
{{ formatDateTime(selectedStep.startTime) }}
|
</el-descriptions-item>
|
<el-descriptions-item label="结束时间">
|
{{ formatDateTime(selectedStep.endTime) }}
|
</el-descriptions-item>
|
<el-descriptions-item label="耗时">
|
{{ formatDuration(selectedStep.durationMs) }}
|
</el-descriptions-item>
|
<el-descriptions-item label="错误信息" v-if="selectedStep.errorMessage">
|
{{ selectedStep.errorMessage }}
|
</el-descriptions-item>
|
</el-descriptions>
|
|
<el-divider>输入数据</el-divider>
|
<el-input
|
v-model="selectedStepInputData"
|
type="textarea"
|
:rows="6"
|
readonly
|
/>
|
|
<el-divider>输出数据</el-divider>
|
<el-input
|
v-model="selectedStepOutputData"
|
type="textarea"
|
:rows="6"
|
readonly
|
/>
|
</div>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { computed, ref, watch } from 'vue'
|
import { ElMessage } from 'element-plus'
|
import { Refresh, Download, Document } from '@element-plus/icons-vue'
|
import { multiDeviceTaskApi } from '@/api/device/multiDeviceTask'
|
|
const props = defineProps({
|
task: {
|
type: Object,
|
default: null
|
}
|
})
|
|
const loading = ref(false)
|
const stepsLoading = ref(false)
|
const steps = ref([])
|
const detailDialogVisible = ref(false)
|
const selectedStep = ref(null)
|
|
const selectedStepInputData = computed(() => {
|
if (!selectedStep.value?.inputData) return ''
|
try {
|
return JSON.stringify(JSON.parse(selectedStep.value.inputData), null, 2)
|
} catch {
|
return selectedStep.value.inputData
|
}
|
})
|
|
const selectedStepOutputData = computed(() => {
|
if (!selectedStep.value?.outputData) return ''
|
try {
|
return JSON.stringify(JSON.parse(selectedStep.value.outputData), null, 2)
|
} catch {
|
return selectedStep.value.outputData
|
}
|
})
|
|
const taskDuration = computed(() => {
|
if (!props.task?.startTime || !props.task?.endTime) return 0
|
try {
|
const start = new Date(props.task.startTime)
|
const end = new Date(props.task.endTime)
|
return end.getTime() - start.getTime()
|
} catch {
|
return 0
|
}
|
})
|
|
const completedSteps = computed(() => {
|
return steps.value.filter(s => s.status === 'COMPLETED').length
|
})
|
|
const failedSteps = computed(() => {
|
return steps.value.filter(s => s.status === 'FAILED').length
|
})
|
|
const successRate = computed(() => {
|
if (steps.value.length === 0) return 0
|
return Math.round((completedSteps.value / steps.value.length) * 100)
|
})
|
|
const successRateClass = computed(() => {
|
if (successRate.value >= 90) return 'success'
|
if (successRate.value >= 70) return 'warning'
|
return 'danger'
|
})
|
|
const progressPercentage = computed(() => {
|
if (!props.task?.totalSteps || props.task.totalSteps === 0) return 0
|
return Math.round((completedSteps.value / props.task.totalSteps) * 100)
|
})
|
|
const progressStatus = computed(() => {
|
if (props.task?.status === 'COMPLETED') return 'success'
|
if (props.task?.status === 'FAILED') return 'exception'
|
return 'active'
|
})
|
|
const fetchSteps = async () => {
|
if (!props.task?.taskId) {
|
steps.value = []
|
return
|
}
|
try {
|
stepsLoading.value = true
|
const { data } = await multiDeviceTaskApi.getTaskSteps(props.task.taskId)
|
steps.value = Array.isArray(data) ? data : (data?.data || [])
|
} catch (error) {
|
ElMessage.error(error?.message || '加载步骤详情失败')
|
steps.value = []
|
} finally {
|
stepsLoading.value = false
|
}
|
}
|
|
const handleRefresh = () => {
|
fetchSteps()
|
}
|
|
const viewStepDetail = (step) => {
|
selectedStep.value = step
|
detailDialogVisible.value = true
|
}
|
|
const handleExport = async (format) => {
|
if (!props.task) {
|
ElMessage.warning('请先选择任务')
|
return
|
}
|
try {
|
loading.value = true
|
// 构建导出数据
|
const exportData = {
|
task: props.task,
|
steps: steps.value,
|
statistics: {
|
totalSteps: props.task.totalSteps || 0,
|
completedSteps: completedSteps.value,
|
failedSteps: failedSteps.value,
|
successRate: successRate.value,
|
duration: taskDuration.value
|
}
|
}
|
|
if (format === 'json') {
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
type: 'application/json'
|
})
|
const url = URL.createObjectURL(blob)
|
const a = document.createElement('a')
|
a.href = url
|
a.download = `task_${props.task.taskId}_${Date.now()}.json`
|
a.click()
|
URL.revokeObjectURL(url)
|
ElMessage.success('导出成功')
|
} else if (format === 'excel') {
|
// TODO: 实现Excel导出
|
ElMessage.info('Excel导出功能开发中')
|
}
|
} catch (error) {
|
ElMessage.error('导出失败: ' + error.message)
|
} finally {
|
loading.value = false
|
}
|
}
|
|
const getOverallStatusType = () => {
|
const status = props.task?.status?.toUpperCase()
|
if (status === 'COMPLETED') return 'success'
|
if (status === 'FAILED') return 'danger'
|
if (status === 'RUNNING') return 'warning'
|
return 'info'
|
}
|
|
const getOverallStatusLabel = () => {
|
const status = props.task?.status?.toUpperCase()
|
const statusMap = {
|
'COMPLETED': '已完成',
|
'FAILED': '失败',
|
'RUNNING': '执行中',
|
'PENDING': '等待中',
|
'CANCELLED': '已取消'
|
}
|
return statusMap[status] || status || '未知'
|
}
|
|
const getStepStatusType = (status) => {
|
const s = (status || '').toUpperCase()
|
if (s === 'COMPLETED') return 'success'
|
if (s === 'FAILED') return 'danger'
|
if (s === 'RUNNING') return 'warning'
|
if (s === 'PENDING') return 'info'
|
return 'default'
|
}
|
|
const getStepStatusLabel = (status) => {
|
const s = (status || '').toUpperCase()
|
const statusMap = {
|
'COMPLETED': '已完成',
|
'FAILED': '失败',
|
'RUNNING': '执行中',
|
'PENDING': '等待中',
|
'SKIPPED': '已跳过'
|
}
|
return statusMap[s] || s || '未知'
|
}
|
|
const getStepStatusClass = (status) => {
|
const s = (status || '').toUpperCase()
|
if (s === 'COMPLETED') return 'status-success'
|
if (s === 'FAILED') return 'status-danger'
|
if (s === 'RUNNING') return 'status-warning'
|
return 'status-info'
|
}
|
|
const getBarWidth = (durationMs) => {
|
if (!durationMs || steps.value.length === 0) return 0
|
const maxDuration = Math.max(...steps.value.map(s => s.durationMs || 0))
|
if (maxDuration === 0) return 0
|
return Math.min((durationMs / maxDuration) * 100, 100)
|
}
|
|
const formatDuration = (ms) => {
|
if (!ms) return '-'
|
if (ms < 1000) return `${ms} ms`
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`
|
const minutes = Math.floor(ms / 60000)
|
const seconds = ((ms % 60000) / 1000).toFixed(0)
|
return `${minutes}分${seconds}秒`
|
}
|
|
const formatDateTime = (dateTime) => {
|
if (!dateTime) return '-'
|
try {
|
const date = new Date(dateTime)
|
if (isNaN(date.getTime())) return dateTime
|
const year = date.getFullYear()
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
const day = String(date.getDate()).padStart(2, '0')
|
const hours = String(date.getHours()).padStart(2, '0')
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
} catch {
|
return dateTime
|
}
|
}
|
|
watch(
|
() => props.task,
|
() => {
|
fetchSteps()
|
},
|
{ immediate: true }
|
)
|
|
defineExpose({
|
fetchSteps
|
})
|
</script>
|
|
<style scoped>
|
.result-analysis {
|
background: #fff;
|
border-radius: 12px;
|
padding: 20px;
|
box-shadow: 0 8px 32px rgba(15, 18, 63, 0.08);
|
}
|
|
.panel-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 20px;
|
}
|
|
.panel-header h3 {
|
margin: 0;
|
}
|
|
.panel-header p {
|
margin: 4px 0 0;
|
color: #909399;
|
font-size: 13px;
|
}
|
|
.panel-header .warning {
|
color: #f56c6c;
|
}
|
|
.action-buttons {
|
display: flex;
|
gap: 12px;
|
}
|
|
.empty-state {
|
padding: 60px 0;
|
}
|
|
.analysis-content {
|
display: flex;
|
flex-direction: column;
|
gap: 20px;
|
}
|
|
.overall-result-card .card-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
.result-stats {
|
display: grid;
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
gap: 20px;
|
}
|
|
.stat-item {
|
text-align: center;
|
}
|
|
.stat-label {
|
font-size: 13px;
|
color: #909399;
|
margin-bottom: 8px;
|
}
|
|
.stat-value {
|
font-size: 24px;
|
font-weight: 600;
|
color: #303133;
|
}
|
|
.stat-value.success {
|
color: #67c23a;
|
}
|
|
.stat-value.danger {
|
color: #f56c6c;
|
}
|
|
.stat-value.warning {
|
color: #e6a23c;
|
}
|
|
.progress-card {
|
margin-top: 0;
|
}
|
|
.steps-card {
|
margin-top: 0;
|
}
|
|
.chart-card {
|
margin-top: 0;
|
}
|
|
.chart-container {
|
display: flex;
|
flex-direction: column;
|
gap: 12px;
|
padding: 20px 0;
|
}
|
|
.chart-item {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
}
|
|
.chart-bar {
|
flex: 1;
|
height: 32px;
|
background: #f0f2f5;
|
border-radius: 4px;
|
position: relative;
|
overflow: hidden;
|
}
|
|
.bar-fill {
|
height: 100%;
|
border-radius: 4px;
|
display: flex;
|
align-items: center;
|
justify-content: flex-end;
|
padding: 0 8px;
|
transition: width 0.3s;
|
min-width: 60px;
|
}
|
|
.bar-fill.status-success {
|
background: linear-gradient(90deg, #67c23a 0%, #85ce61 100%);
|
}
|
|
.bar-fill.status-danger {
|
background: linear-gradient(90deg, #f56c6c 0%, #f78989 100%);
|
}
|
|
.bar-fill.status-warning {
|
background: linear-gradient(90deg, #e6a23c 0%, #ebb563 100%);
|
}
|
|
.bar-fill.status-info {
|
background: linear-gradient(90deg, #909399 0%, #b1b3b8 100%);
|
}
|
|
.bar-label {
|
color: #fff;
|
font-size: 12px;
|
font-weight: 500;
|
white-space: nowrap;
|
}
|
|
.chart-label {
|
width: 150px;
|
font-size: 13px;
|
color: #606266;
|
text-align: right;
|
flex-shrink: 0;
|
}
|
|
.step-detail-content {
|
display: flex;
|
flex-direction: column;
|
gap: 20px;
|
}
|
|
@media (max-width: 768px) {
|
.panel-header {
|
flex-direction: column;
|
align-items: flex-start;
|
gap: 12px;
|
}
|
|
.action-buttons {
|
width: 100%;
|
flex-wrap: wrap;
|
}
|
|
.result-stats {
|
grid-template-columns: repeat(2, 1fr);
|
}
|
}
|
</style>
|