Files
telegram-management-system/marketing-agent/frontend/src/views/ab-testing/ExperimentDetails.vue
你的用户名 237c7802e5
Some checks failed
Deploy / deploy (push) Has been cancelled
Initial commit: Telegram Management System
Full-stack web application for Telegram management
- Frontend: Vue 3 + Vben Admin
- Backend: NestJS
- Features: User management, group broadcast, statistics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 15:37:50 +08:00

622 lines
17 KiB
Vue

<template>
<div class="experiment-details" v-loading="loading">
<div v-if="experiment">
<!-- Header -->
<el-page-header @back="handleBack">
<template #content>
<div class="page-header-content">
<h1>{{ experiment.name }}</h1>
<el-tag :type="getStatusType(experiment.status)" size="large">
{{ experiment.status }}
</el-tag>
</div>
</template>
<template #extra>
<el-button-group>
<el-button
v-if="experiment.status === 'draft'"
type="primary"
@click="handleStart"
>
Start Experiment
</el-button>
<el-button
v-else-if="experiment.status === 'running'"
type="danger"
@click="handleStop"
>
Stop Experiment
</el-button>
<el-button @click="handleRefresh">
<el-icon><Refresh /></el-icon>
Refresh
</el-button>
<el-dropdown trigger="click" class="ml-2">
<el-button>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleEdit" :disabled="experiment.status === 'running'">
Edit
</el-dropdown-item>
<el-dropdown-item @click="handleDuplicate">
Duplicate
</el-dropdown-item>
<el-dropdown-item @click="handleExport">
Export Results
</el-dropdown-item>
<el-dropdown-item
@click="handleDelete"
:disabled="!['draft', 'archived'].includes(experiment.status)"
divided
>
Delete
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-button-group>
</template>
</el-page-header>
<!-- Overview -->
<el-row :gutter="20" class="mt-4">
<el-col :span="16">
<el-card>
<template #header>
<h3>Overview</h3>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="Description">
{{ experiment.description || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Hypothesis">
{{ experiment.hypothesis || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Type">
{{ getExperimentType(experiment.type) }}
</el-descriptions-item>
<el-descriptions-item label="Target Metric">
{{ experiment.targetMetric.name }}
<el-tag size="small" type="info" class="ml-2">
{{ experiment.targetMetric.goalDirection }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Start Date">
{{ formatDate(experiment.startDate) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="End Date">
{{ formatDate(experiment.endDate) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Confidence Level">
{{ (experiment.requirements.confidenceLevel * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="Min Sample Size">
{{ experiment.requirements.minimumSampleSize }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<template #header>
<h3>Status</h3>
</template>
<div v-if="status">
<el-statistic
title="Overall Progress"
:value="status.progress.percentage"
suffix="%"
/>
<el-progress
:percentage="parseFloat(status.progress.percentage)"
:status="parseFloat(status.progress.percentage) === 100 ? 'success' : ''"
class="mt-2"
/>
<el-row :gutter="20" class="mt-4">
<el-col :span="12">
<el-statistic
title="Participants"
:value="status.overall.participants"
/>
</el-col>
<el-col :span="12">
<el-statistic
title="Conversions"
:value="status.overall.conversions"
/>
</el-col>
</el-row>
<el-divider />
<el-statistic
title="Days Running"
:value="status.progress.daysRunning"
v-if="experiment.status === 'running'"
/>
</div>
<el-empty v-else description="No data yet" />
</el-card>
</el-col>
</el-row>
<!-- Variants Performance -->
<el-card class="mt-4">
<template #header>
<div class="card-header">
<h3>Variants Performance</h3>
<el-button
v-if="experiment.status === 'running'"
size="small"
@click="loadStatus"
>
Refresh Data
</el-button>
</div>
</template>
<el-table :data="variantStats" style="width: 100%">
<el-table-column prop="name" label="Variant" width="200">
<template #default="{ row }">
<div>
<strong>{{ row.name }}</strong>
<el-tag
v-if="row.variantId === experiment.control"
size="small"
type="info"
class="ml-2"
>
Control
</el-tag>
<el-tag
v-if="experiment.results?.winner === row.variantId"
size="small"
type="success"
class="ml-2"
>
Winner
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="participants" label="Participants" width="120" align="center" />
<el-table-column prop="conversions" label="Conversions" width="120" align="center" />
<el-table-column label="Conversion Rate" width="150" align="center">
<template #default="{ row }">
<el-statistic
:value="row.conversionRate"
:precision="2"
suffix="%"
/>
</template>
</el-table-column>
<el-table-column label="Improvement" width="150" align="center">
<template #default="{ row }">
<div v-if="row.variantId !== experiment.control && controlVariant">
<el-statistic
:value="calculateImprovement(row, controlVariant)"
:precision="2"
suffix="%"
:value-style="{
color: calculateImprovement(row, controlVariant) > 0 ? '#67c23a' : '#f56c6c'
}"
/>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="Statistical Significance" min-width="200">
<template #default="{ row }">
<div v-if="row.variantId !== experiment.control && results?.analysis">
<el-tag
:type="getSignificanceType(row.variantId)"
>
{{ getSignificanceLabel(row.variantId) }}
</el-tag>
<span class="ml-2 text-small text-gray">
p-value: {{ getPValue(row.variantId) }}
</span>
</div>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<!-- Results Summary -->
<div v-if="experiment.results?.summary" class="results-summary">
<el-alert
:title="experiment.results.summary"
type="success"
show-icon
:closable="false"
/>
<div v-if="experiment.results.recommendations?.length" class="mt-4">
<h4>Recommendations</h4>
<ul>
<li v-for="(rec, index) in experiment.results.recommendations" :key="index">
{{ rec }}
</li>
</ul>
</div>
</div>
</el-card>
<!-- Results Chart -->
<el-card class="mt-4" v-if="chartData">
<template #header>
<h3>Conversion Rate Over Time</h3>
</template>
<div class="chart-container">
<ConversionChart :data="chartData" />
</div>
</el-card>
</div>
<!-- Edit Dialog -->
<EditExperimentDialog
v-model="showEditDialog"
:experiment="experiment"
@updated="handleUpdated"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, More } from '@element-plus/icons-vue'
import { useABTestingStore } from '@/stores/abTesting'
import EditExperimentDialog from './components/EditExperimentDialog.vue'
import ConversionChart from './components/ConversionChart.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const abTestingStore = useABTestingStore()
// State
const loading = ref(false)
const experiment = ref(null)
const status = ref(null)
const results = ref(null)
const showEditDialog = ref(false)
const chartData = ref(null)
// Computed
const experimentId = computed(() => route.params.id)
const variantStats = computed(() => {
if (!status.value?.variants) return []
return status.value.variants.map(v => {
const variant = experiment.value.variants.find(ev => ev.variantId === v.variantId)
return {
...v,
name: variant?.name || v.variantId,
description: variant?.description
}
})
})
const controlVariant = computed(() => {
return variantStats.value.find(v => v.variantId === experiment.value?.control)
})
// Methods
const loadExperiment = async () => {
loading.value = true
try {
experiment.value = await abTestingStore.fetchExperiment(experimentId.value)
if (experiment.value.status !== 'draft') {
await loadStatus()
await loadResults()
}
} catch (error) {
ElMessage.error('Failed to load experiment')
router.push('/ab-testing')
} finally {
loading.value = false
}
}
const loadStatus = async () => {
try {
status.value = await abTestingStore.fetchExperimentStatus(experimentId.value)
} catch (error) {
console.error('Failed to load status:', error)
}
}
const loadResults = async () => {
try {
results.value = await abTestingStore.fetchExperimentResults(experimentId.value)
// Generate chart data if available
if (results.value?.timeSeriesData) {
chartData.value = generateChartData(results.value.timeSeriesData)
}
} catch (error) {
console.error('Failed to load results:', error)
}
}
const handleBack = () => {
router.push('/ab-testing')
}
const handleRefresh = () => {
loadExperiment()
}
const handleStart = async () => {
try {
await ElMessageBox.confirm(
'Are you sure you want to start this experiment?',
'Start Experiment',
{
confirmButtonText: 'Start',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
await abTestingStore.startExperiment(experimentId.value)
ElMessage.success('Experiment started successfully')
await loadExperiment()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to start experiment')
}
}
}
const handleStop = async () => {
try {
const { value: reason } = await ElMessageBox.prompt(
'Why are you stopping this experiment?',
'Stop Experiment',
{
confirmButtonText: 'Stop',
cancelButtonText: 'Cancel',
inputPlaceholder: 'Optional reason...',
type: 'warning'
}
)
await abTestingStore.stopExperiment(experimentId.value, reason)
ElMessage.success('Experiment stopped successfully')
await loadExperiment()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to stop experiment')
}
}
}
const handleEdit = () => {
showEditDialog.value = true
}
const handleUpdated = () => {
showEditDialog.value = false
loadExperiment()
}
const handleDuplicate = () => {
// TODO: Implement duplicate functionality
ElMessage.info('Duplicate functionality coming soon')
}
const handleExport = async () => {
try {
await abTestingStore.exportResults(experimentId.value, 'csv')
ElMessage.success('Results exported successfully')
} catch (error) {
ElMessage.error('Failed to export results')
}
}
const handleDelete = async () => {
try {
await ElMessageBox.confirm(
'Are you sure you want to delete this experiment? This action cannot be undone.',
'Delete Experiment',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning'
}
)
await abTestingStore.deleteExperiment(experimentId.value)
ElMessage.success('Experiment deleted successfully')
router.push('/ab-testing')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete experiment')
}
}
}
// Helpers
const formatDate = (date) => {
return date ? dayjs(date).format('YYYY-MM-DD HH:mm') : null
}
const getStatusType = (status) => {
const types = {
draft: 'info',
running: 'success',
completed: '',
archived: 'info'
}
return types[status] || ''
}
const getExperimentType = (type) => {
const types = {
ab: 'A/B Test',
multivariate: 'Multivariate',
bandit: 'Multi-Armed Bandit'
}
return types[type] || type
}
const calculateImprovement = (variant, control) => {
if (!control || control.conversionRate === 0) return 0
return ((variant.conversionRate - control.conversionRate) / control.conversionRate) * 100
}
const getSignificanceType = (variantId) => {
const analysis = results.value?.analysis?.variants?.[variantId]
if (!analysis?.comparisonToControl) return 'info'
return analysis.comparisonToControl.significant ? 'success' : 'warning'
}
const getSignificanceLabel = (variantId) => {
const analysis = results.value?.analysis?.variants?.[variantId]
if (!analysis?.comparisonToControl) return 'Not tested'
return analysis.comparisonToControl.significant ? 'Significant' : 'Not significant'
}
const getPValue = (variantId) => {
const analysis = results.value?.analysis?.variants?.[variantId]
if (!analysis?.comparisonToControl) return '-'
return analysis.comparisonToControl.pValue.toFixed(4)
}
const generateChartData = (timeSeriesData) => {
// Transform time series data for chart
const labels = timeSeriesData.map(d => dayjs(d.date).format('MMM DD'))
const datasets = []
const variants = Object.keys(timeSeriesData[0]?.variants || {})
variants.forEach((variantId, index) => {
const variant = experiment.value.variants.find(v => v.variantId === variantId)
datasets.push({
label: variant?.name || variantId,
data: timeSeriesData.map(d => d.variants[variantId]?.conversionRate || 0),
borderColor: getChartColor(index),
backgroundColor: getChartColor(index, 0.1)
})
})
return { labels, datasets }
}
const getChartColor = (index, opacity = 1) => {
const colors = [
`rgba(54, 162, 235, ${opacity})`,
`rgba(255, 99, 132, ${opacity})`,
`rgba(255, 206, 86, ${opacity})`,
`rgba(75, 192, 192, ${opacity})`,
`rgba(153, 102, 255, ${opacity})`
]
return colors[index % colors.length]
}
// Auto-refresh for running experiments
let refreshInterval = null
watch(() => experiment.value?.status, (status) => {
if (status === 'running') {
refreshInterval = setInterval(() => {
loadStatus()
loadResults()
}, 30000) // Refresh every 30 seconds
} else if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
})
// Lifecycle
onMounted(() => {
loadExperiment()
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<style lang="scss" scoped>
.experiment-details {
padding: 20px;
.page-header-content {
display: flex;
align-items: center;
gap: 16px;
h1 {
margin: 0;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
}
}
.results-summary {
margin-top: 20px;
padding: 20px;
background: #f5f7fa;
border-radius: 4px;
h4 {
margin-top: 0;
}
ul {
margin: 10px 0;
padding-left: 20px;
}
}
.chart-container {
height: 400px;
}
.mt-4 {
margin-top: 16px;
}
.ml-2 {
margin-left: 8px;
}
.text-small {
font-size: 12px;
}
.text-gray {
color: #909399;
}
}
</style>