Files
test-repo/docs/.vitepress/theme/components/appendix/data/DataAnalysisDemo.vue
T
sanbuphy 260d17ee8b feat: 添加多个附录交互式组件和文档更新
- 添加浏览器前端组件:无障碍访问、国际化、实时通信
- 添加 Transformer 注意力机制系列组件
- 更新 Canvas、数据追踪等现有组件
- 修复 ESLint 变量名冲突问题
- 完善相关附录文档
2026-02-24 08:34:53 +08:00

1253 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="data-analysis-root">
<div class="data-analysis-header">
<span class="data-analysis-title">数据分析演示</span>
</div>
<div v-if="!props.tab" class="data-analysis-tabs">
<button
v-for="t in tabs"
:key="t.id"
:class="['data-analysis-tab', { active: activeTab === t.id }]"
@click="activeTab = t.id"
>
{{ t.name }}
</button>
</div>
<div class="data-analysis-content">
<!-- 描述性统计 -->
<div v-if="activeTab === 'stats'" class="data-analysis-section">
<div class="stats-demo">
<div class="stats-input-section">
<div class="stats-input-title">数据集</div>
<div class="stats-input-desc">输入一组数字计算统计指标</div>
<div class="stats-input-wrapper">
<input
v-model="dataInput"
type="text"
class="stats-input"
placeholder="用逗号分隔,例如:1, 2, 3, 4, 5"
@input="calculateStats"
/>
<button class="stats-btn" @click="generateRandomData">
随机生成
</button>
</div>
</div>
<div class="stats-results">
<div class="stats-result-title">统计结果</div>
<div class="stats-grid">
<div v-for="stat in stats" :key="stat.key" class="stat-card">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-value" :style="{ color: stat.color }">
{{ stat.value }}
</div>
<div class="stat-desc">{{ stat.desc }}</div>
</div>
</div>
</div>
<div class="stats-visualization">
<div class="stats-viz-title">数据分布</div>
<div class="stats-chart">
<div class="stats-chart-bar">
<div
v-for="(val, i) in sortedData"
:key="i"
class="stats-bar-item"
:style="{
height: getBarHeight(val),
background: getBarColor(i)
}"
>
<div class="stats-bar-value">{{ val }}</div>
</div>
</div>
<div class="stats-chart-mean">
<div
class="stats-mean-line"
:style="{ left: meanLinePosition + '%' }"
>
<div class="stats-mean-label">均值: {{ mean }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 数据聚合 -->
<div
v-else-if="activeTab === 'aggregation'"
class="data-analysis-section"
>
<div class="aggregation-demo">
<div class="aggregation-title">数据聚合操作</div>
<div class="aggregation-table">
<table class="agg-table">
<thead>
<tr>
<th>用户 ID</th>
<th>订单号</th>
<th>金额</th>
<th>日期</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rawOrders" :key="row.id">
<td>{{ row.userId }}</td>
<td>{{ row.orderId }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.date }}</td>
</tr>
</tbody>
</table>
</div>
<div class="aggregation-operations">
<button
v-for="op in aggregationOps"
:key="op.id"
:class="['agg-op-btn', { active: activeAggOp === op.id }]"
@click="activeAggOp = op.id"
>
{{ op.name }}
</button>
</div>
<div class="aggregation-result">
<div class="agg-result-title">{{ currentAggOp.name }}结果</div>
<div class="agg-result-table">
<table class="agg-table">
<thead>
<tr>
<th v-for="col in currentAggResult.columns" :key="col">
{{ col }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in currentAggResult.data" :key="i">
<td v-for="col in currentAggResult.columns" :key="col">
{{ row[col] }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="agg-result-sql">
<div class="agg-sql-title">SQL 示例</div>
<pre class="agg-sql-code">{{ currentAggOp.sql }}</pre>
</div>
</div>
</div>
</div>
<!-- 漏斗分析 -->
<div v-else-if="activeTab === 'funnel'" class="data-analysis-section">
<div class="funnel-demo">
<div class="funnel-title">用户转化漏斗</div>
<div class="funnel-description">
漏斗分析用于追踪用户在一系列步骤中的转化率
</div>
<div class="funnel-visualization">
<div class="funnel-steps">
<div
v-for="(step, i) in funnelSteps"
:key="step.name"
class="funnel-step"
:style="{ width: getFunnelWidth(step.count, i) }"
>
<div class="funnel-step-header">
<span class="funnel-step-name">{{ step.name }}</span>
<span class="funnel-step-count">{{ step.count }} </span>
</div>
<div class="funnel-step-bar"></div>
<div class="funnel-step-conversion">
{{
i === 0
? '100%'
: getConversionRate(step.count, funnelSteps[0].count)
}}
{{
i > 0
? `(转化率 ${getConversionRate(step.count, funnelSteps[i - 1].count)})`
: ''
}}
</div>
</div>
</div>
</div>
<div class="funnel-insights">
<div class="funnel-insights-title">洞察与建议</div>
<div class="funnel-insights-list">
<div class="funnel-insight">
<span class="funnel-insight-text">
最低转化步骤<strong>{{ worstStep.name }}</strong> ({{
worstStep.rate
}})
</span>
</div>
<div class="funnel-insight">
<span class="funnel-insight-text">
整体转化率<strong>{{ overallConversion }}</strong>
</span>
</div>
<div class="funnel-insight">
<span class="funnel-insight-text">
建议优化
<strong>{{ worstStep.name }}</strong>
页面减少表单字段或简化流程
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 留存分析 -->
<div v-else-if="activeTab === 'retention'" class="data-analysis-section">
<div class="retention-demo">
<div class="retention-title">用户留存分析</div>
<div class="retention-description">
留存分析衡量用户在首次使用后持续使用产品的情况
</div>
<div class="retention-table">
<table class="retention-table-el">
<thead>
<tr>
<th>注册日期</th>
<th>注册用户数</th>
<th>次日留存</th>
<th>7日留存</th>
<th>30日留存</th>
</tr>
</thead>
<tbody>
<tr v-for="row in retentionData" :key="row.date">
<td>{{ row.date }}</td>
<td>{{ row.users }}</td>
<td :class="getRetentionClass(row.day1)">{{ row.day1 }}%</td>
<td :class="getRetentionClass(row.day7)">{{ row.day7 }}%</td>
<td :class="getRetentionClass(row.day30)">
{{ row.day30 }}%
</td>
</tr>
</tbody>
</table>
</div>
<div class="retention-chart">
<div class="retention-chart-title">留存曲线</div>
<div class="retention-curve">
<svg viewBox="0 0 400 200" class="retention-svg">
<!-- 坐标轴 -->
<line
x1="40"
y1="180"
x2="380"
y2="180"
stroke="#666"
stroke-width="1"
/>
<line
x1="40"
y1="20"
x2="40"
y2="180"
stroke="#666"
stroke-width="1"
/>
<!-- Y轴标签 -->
<text x="10" y="30" font-size="10" fill="#666">100%</text>
<text x="15" y="100" font-size="10" fill="#666">50%</text>
<text x="20" y="180" font-size="10" fill="#666">0%</text>
<!-- 次日留存曲线 -->
<polyline
:points="
getRetentionCurvePoints([45, 42, 40, 38, 41, 43, 40])
"
fill="none"
stroke="#3b82f6"
stroke-width="2"
/>
<circle
v-for="(i, idx) in 7"
:key="'d1-' + idx"
:cx="60 + idx * 50"
:cy="180 - 45 * 1.6"
r="4"
fill="#3b82f6"
/>
<!-- 7日留存曲线 -->
<polyline
:points="
getRetentionCurvePoints([45, 32, 28, 25, 30, 33, 29])
"
fill="none"
stroke="#22c55e"
stroke-width="2"
/>
<circle
v-for="(i, idx) in 7"
:key="'d7-' + idx"
:cx="60 + idx * 50"
:cy="180 - 32 * 1.6"
r="4"
fill="#22c55e"
/>
<!-- 30日留存曲线 -->
<polyline
:points="
getRetentionCurvePoints([45, 18, 15, 12, 14, 16, 13])
"
fill="none"
stroke="#f59e0b"
stroke-width="2"
/>
<circle
v-for="(i, idx) in 7"
:key="'d30-' + idx"
:cx="60 + idx * 50"
:cy="180 - 18 * 1.6"
r="4"
fill="#f59e0b"
/>
<!-- X轴标签 -->
<text
v-for="(day, idx) in [
'D1',
'D2',
'D3',
'D4',
'D5',
'D6',
'D7'
]"
:key="day"
:x="60 + idx * 50"
y="195"
font-size="10"
fill="#666"
text-anchor="middle"
>
{{ day }}
</text>
</svg>
<div class="retention-legend">
<div class="retention-legend-item">
<span
class="retention-legend-color"
style="background: #3b82f6"
></span>
<span class="retention-legend-text">次日留存</span>
</div>
<div class="retention-legend-item">
<span
class="retention-legend-color"
style="background: #22c55e"
></span>
<span class="retention-legend-text">7日留存</span>
</div>
<div class="retention-legend-item">
<span
class="retention-legend-color"
style="background: #f59e0b"
></span>
<span class="retention-legend-text">30日留存</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
tab: {
type: String,
default: null
}
})
const activeTab = ref(props.tab || 'stats')
const activeAggOp = ref('groupBy')
const tabs = [
{ id: 'stats', name: '描述性统计' },
{ id: 'aggregation', name: '数据聚合' },
{ id: 'funnel', name: '漏斗分析' },
{ id: 'retention', name: '留存分析' }
]
// 描述性统计
const dataInput = ref('23, 45, 67, 89, 12, 34, 56, 78, 90, 21')
const rawData = computed(() => {
return dataInput.value
.split(',')
.map((s) => parseFloat(s.trim()))
.filter((n) => !isNaN(n))
})
const sortedData = computed(() => {
return [...rawData.value].sort((a, b) => a - b)
})
const count = computed(() => rawData.value.length)
const mean = computed(() => {
if (count.value === 0) return 0
const sum = rawData.value.reduce((a, b) => a + b, 0)
return (sum / count.value).toFixed(2)
})
const median = computed(() => {
const sorted = sortedData.value
const n = sorted.length
if (n === 0) return 0
if (n % 2 === 0) {
return ((sorted[n / 2 - 1] + sorted[n / 2]) / 2).toFixed(2)
} else {
return sorted[Math.floor(n / 2)].toFixed(2)
}
})
const mode = computed(() => {
const frequency = {}
let maxFreq = 0
let modes = []
rawData.value.forEach((num) => {
frequency[num] = (frequency[num] || 0) + 1
if (frequency[num] > maxFreq) {
maxFreq = frequency[num]
}
})
Object.keys(frequency).forEach((num) => {
if (frequency[num] === maxFreq) {
modes.push(num)
}
})
return maxFreq === 1 ? '无' : modes.join(', ')
})
const variance = computed(() => {
if (count.value === 0) return 0
const squaredDiffs = rawData.value.map((num) =>
Math.pow(num - parseFloat(mean.value), 2)
)
return (squaredDiffs.reduce((a, b) => a + b, 0) / count.value).toFixed(2)
})
const stdDev = computed(() => {
return Math.sqrt(parseFloat(variance.value)).toFixed(2)
})
const stats = computed(() => [
{
key: 'count',
label: '样本数',
value: count.value,
desc: '数据点的总数',
color: '#3b82f6'
},
{
key: 'mean',
label: '均值',
value: mean.value,
desc: '所有数值的平均值',
color: '#22c55e'
},
{
key: 'median',
label: '中位数',
value: median.value,
desc: '排序后位于中间的值',
color: '#f59e0b'
},
{
key: 'mode',
label: '众数',
value: mode.value,
desc: '出现次数最多的值',
color: '#8b5cf6'
},
{
key: 'variance',
label: '方差',
value: variance.value,
desc: '数据分散程度的度量',
color: '#ec4899'
},
{
key: 'stdDev',
label: '标准差',
value: stdDev.value,
desc: '方差的平方根',
color: '#06b6d4'
}
])
function generateRandomData() {
const data = Array.from(
{ length: 10 },
() => Math.floor(Math.random() * 100) + 1
)
dataInput.value = data.join(', ')
}
function getBarHeight(value) {
const max = Math.max(...sortedData.value)
const min = Math.min(...sortedData.value)
const range = max - min || 1
return ((value - min) / range) * 80 + 20 + '%'
}
function getBarColor(index) {
const colors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
return colors[index % colors.length]
}
const meanLinePosition = computed(() => {
const sorted = sortedData.value
const min = Math.min(...sorted)
const max = Math.max(...sorted)
const range = max - min || 1
return ((parseFloat(mean.value) - min) / range) * 100
})
function calculateStats() {
// Triggered by input
}
// 数据聚合
const rawOrders = [
{ id: 1, userId: 'U001', orderId: 'ORD001', amount: 100, date: '2024-01-01' },
{ id: 2, userId: 'U001', orderId: 'ORD002', amount: 200, date: '2024-01-02' },
{ id: 3, userId: 'U002', orderId: 'ORD003', amount: 150, date: '2024-01-01' },
{ id: 4, userId: 'U002', orderId: 'ORD004', amount: 300, date: '2024-01-03' },
{ id: 5, userId: 'U003', orderId: 'ORD005', amount: 250, date: '2024-01-02' },
{ id: 6, userId: 'U001', orderId: 'ORD006', amount: 180, date: '2024-01-04' }
]
const aggregationOps = {
groupBy: {
id: 'groupBy',
name: '按用户分组',
sql: `SELECT user_id, COUNT(*) as order_count, SUM(amount) as total_amount
FROM orders
GROUP BY user_id;`,
columns: ['用户 ID', '订单数', '总金额'],
data: [
{ '用户 ID': 'U001', 订单数: 3, 总金额: 480 },
{ '用户 ID': 'U002', 订单数: 2, 总金额: 450 },
{ '用户 ID': 'U003', 订单数: 1, 总金额: 250 }
]
},
sum: {
id: 'sum',
name: '总销售额',
sql: `SELECT SUM(amount) as total_sales
FROM orders;`,
columns: ['总销售额'],
data: [{ 总销售额: 1180 }]
},
avg: {
id: 'avg',
name: '平均订单额',
sql: `SELECT AVG(amount) as avg_amount
FROM orders;`,
columns: ['平均订单额'],
data: [{ 平均订单额: 196.67 }]
},
max: {
id: 'max',
name: '最大订单额',
sql: `SELECT MAX(amount) as max_amount
FROM orders;`,
columns: ['最大订单额'],
data: [{ 最大订单额: 300 }]
}
}
const currentAggOp = computed(() => aggregationOps[activeAggOp.value])
const currentAggResult = computed(() => ({
columns: currentAggOp.value.columns,
data: currentAggOp.value.data
}))
// 漏斗分析
const funnelSteps = [
{ name: '访问商品页', count: 10000 },
{ name: '加入购物车', count: 6000 },
{ name: '进入结算页', count: 4000 },
{ name: '完成支付', count: 2500 }
]
function getFunnelWidth(count, index) {
const maxWidth = 100
const minWidth = 30
const percentage = (count / funnelSteps[0].count) * 100
return Math.max(minWidth, (percentage / 100) * maxWidth) + '%'
}
function getConversionRate(current, previous) {
return ((current / previous) * 100).toFixed(1) + '%'
}
const worstStep = computed(() => {
let worst = { name: '', rate: '100%' }
let minRate = 100
for (let i = 1; i < funnelSteps.length; i++) {
const rate = (funnelSteps[i].count / funnelSteps[i - 1].count) * 100
if (rate < minRate) {
minRate = rate
worst = {
name: funnelSteps[i].name,
rate: rate.toFixed(1) + '%'
}
}
}
return worst
})
const overallConversion = computed(() => {
return (
(
(funnelSteps[funnelSteps.length - 1].count / funnelSteps[0].count) *
100
).toFixed(1) + '%'
)
})
// 留存分析
const retentionData = [
{ date: '2024-01-01', users: 1000, day1: 45, day7: 32, day30: 18 },
{ date: '2024-01-02', users: 1200, day1: 42, day7: 28, day30: 15 },
{ date: '2024-01-03', users: 950, day1: 40, day7: 25, day30: 12 },
{ date: '2024-01-04', users: 1100, day1: 38, day7: 30, day30: 14 },
{ date: '2024-01-05', users: 1050, day1: 41, day7: 33, day30: 16 },
{ date: '2024-01-06', users: 1300, day1: 43, day7: 29, day30: 13 },
{ date: '2024-01-07', users: 1150, day1: 40, day7: 31, day30: 15 }
]
function getRetentionClass(rate) {
if (rate >= 40) return 'retention-high'
if (rate >= 25) return 'retention-medium'
return 'retention-low'
}
function getRetentionCurvePoints(data) {
return data
.map((val, idx) => {
const x = 60 + idx * 50
const y = 180 - val * 1.6
return `${x},${y}`
})
.join(' ')
}
</script>
<style scoped>
.data-analysis-root {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
margin: 24px 0;
overflow: hidden;
}
.data-analysis-header {
padding: 14px 20px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 10px;
}
.data-analysis-icon {
font-size: 20px;
}
.data-analysis-title {
font-weight: 600;
font-size: 15px;
}
.data-analysis-tabs {
display: flex;
gap: 6px;
padding: 12px 16px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
overflow-x: auto;
}
.data-analysis-tab {
padding: 8px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg-soft);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.data-analysis-tab:hover {
border-color: var(--vp-c-brand);
}
.data-analysis-tab.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.data-analysis-content {
padding: 20px;
}
/* 描述性统计 */
.stats-demo {
display: flex;
flex-direction: column;
gap: 20px;
}
.stats-input-section {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
background: var(--vp-c-bg);
}
.stats-input-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 6px;
}
.stats-input-desc {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 12px;
}
.stats-input-wrapper {
display: flex;
gap: 10px;
}
.stats-input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg-soft);
font-size: 13px;
font-family: 'Menlo', 'Monaco', monospace;
}
.stats-btn {
padding: 10px 16px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-brand);
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.stats-btn:hover {
opacity: 0.9;
}
.stats-results {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
background: var(--vp-c-bg);
}
.stats-result-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.stat-card {
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 12px;
background: var(--vp-c-bg-soft);
}
.stat-label {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 6px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-desc {
font-size: 11px;
color: var(--vp-c-text-3);
}
.stats-visualization {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
background: var(--vp-c-bg);
}
.stats-viz-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 12px;
}
.stats-chart {
position: relative;
height: 200px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 20px;
}
.stats-chart-bar {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 160px;
gap: 8px;
}
.stats-bar-item {
flex: 1;
max-width: 60px;
background: var(--vp-c-brand);
border-radius: 4px 4px 0 0;
position: relative;
transition: all 0.3s;
display: flex;
align-items: flex-end;
justify-content: center;
}
.stats-bar-value {
position: absolute;
top: -20px;
font-size: 11px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.stats-chart-mean {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
height: 160px;
}
.stats-mean-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #ef4444;
border-left: 2px dashed #ef4444;
}
.stats-mean-label {
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
background: #ef4444;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
/* 数据聚合 */
.aggregation-demo {
display: flex;
flex-direction: column;
gap: 16px;
}
.aggregation-title {
font-weight: 600;
font-size: 14px;
}
.aggregation-table {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
background: var(--vp-c-bg);
}
.agg-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.agg-table th,
.agg-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--vp-c-divider);
}
.agg-table th {
background: var(--vp-c-bg-alt);
font-weight: 600;
color: var(--vp-c-text-1);
}
.agg-table tbody tr:hover {
background: var(--vp-c-bg-soft);
}
.aggregation-operations {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agg-op-btn {
padding: 8px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.agg-op-btn:hover {
border-color: var(--vp-c-brand);
}
.agg-op-btn.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.aggregation-result {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
background: var(--vp-c-bg);
}
.agg-result-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 12px;
}
.agg-result-table {
margin-bottom: 12px;
}
.agg-result-sql {
border-top: 1px solid var(--vp-c-divider);
padding-top: 12px;
}
.agg-sql-title {
font-weight: 600;
font-size: 12px;
margin-bottom: 8px;
color: var(--vp-c-text-3);
}
.agg-sql-code {
margin: 0;
padding: 10px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
font-family: 'Menlo', 'Monaco', monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
}
/* 漏斗分析 */
.funnel-demo {
display: flex;
flex-direction: column;
gap: 16px;
}
.funnel-title {
font-weight: 600;
font-size: 14px;
}
.funnel-description {
font-size: 13px;
color: var(--vp-c-text-3);
}
.funnel-visualization {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg);
}
.funnel-steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.funnel-step {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 14px 18px;
position: relative;
transition: all 0.3s;
}
.funnel-step-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.funnel-step-name {
font-weight: 600;
font-size: 13px;
}
.funnel-step-count {
font-size: 12px;
color: var(--vp-c-text-3);
}
.funnel-step-bar {
height: 24px;
background: linear-gradient(90deg, var(--vp-c-brand), #60a5fa);
border-radius: 4px;
margin-bottom: 8px;
}
.funnel-step-conversion {
font-size: 12px;
color: var(--vp-c-text-3);
}
.funnel-insights {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
background: var(--vp-c-bg);
}
.funnel-insights-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 12px;
}
.funnel-insights-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.funnel-insight {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 13px;
color: var(--vp-c-text-2);
}
.funnel-insight-icon {
font-size: 16px;
}
/* 留存分析 */
.retention-demo {
display: flex;
flex-direction: column;
gap: 16px;
}
.retention-title {
font-weight: 600;
font-size: 14px;
}
.retention-description {
font-size: 13px;
color: var(--vp-c-text-3);
}
.retention-table {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
background: var(--vp-c-bg);
}
.retention-table-el {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.retention-table-el th,
.retention-table-el td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--vp-c-divider);
}
.retention-table-el th {
background: var(--vp-c-bg-alt);
font-weight: 600;
color: var(--vp-c-text-1);
}
.retention-table-el tbody tr:hover {
background: var(--vp-c-bg-soft);
}
.retention-high {
color: #22c55e;
font-weight: 600;
}
.retention-medium {
color: #f59e0b;
font-weight: 600;
}
.retention-low {
color: #ef4444;
font-weight: 600;
}
.retention-chart {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
background: var(--vp-c-bg);
}
.retention-chart-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 12px;
}
.retention-curve {
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 16px;
}
.retention-svg {
width: 100%;
height: auto;
}
.retention-legend {
display: flex;
gap: 16px;
margin-top: 12px;
justify-content: center;
}
.retention-legend-item {
display: flex;
gap: 6px;
align-items: center;
font-size: 12px;
}
.retention-legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
.retention-legend-text {
color: var(--vp-c-text-2);
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.aggregation-operations {
flex-direction: column;
}
.funnel-steps {
gap: 8px;
}
.stats-chart-bar {
gap: 4px;
}
.stats-bar-value {
font-size: 9px;
}
}
</style>