Some checks failed
Deploy / deploy (push) Has been cancelled
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>
622 lines
17 KiB
Vue
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> |