Files

902 lines
21 KiB
Vue
Raw Permalink Normal View History

<template>
<div class="journey-demo">
2026-02-24 00:18:09 +08:00
<!-- Header -->
<div class="demo-header">
<span class="title">📸 照片上传的完整旅程</span>
<span class="subtitle">从按下快门到云端备份数据经历了什么</span>
</div>
<!-- Progress Steps -->
<div class="progress-steps">
<div
v-for="(step, i) in steps"
:key="i"
2026-02-24 00:18:09 +08:00
:class="['step-item', {
completed: currentStep > i,
active: currentStep === i,
pending: currentStep < i
}]"
>
2026-02-24 00:18:09 +08:00
<div class="step-circle">
<span v-if="currentStep > i"></span>
<span v-else>{{ i + 1 }}</span>
</div>
<span class="step-label">{{ step.label }}</span>
<div v-if="i < steps.length - 1" class="step-line"></div>
</div>
</div>
2026-02-24 00:18:09 +08:00
<!-- Main Visualization Area -->
<div class="visualization-area" :style="{ borderColor: currentStepData.color + '40' }">
<!-- Stage Title -->
<div class="stage-title-bar" :style="{ background: currentStepData.color + '15' }">
<span class="stage-icon">{{ currentStepData.icon }}</span>
<span class="stage-name">{{ currentStepData.stageName }}</span>
<span class="stage-status" :style="{ color: currentStepData.color }">{{ stageStatus }}</span>
</div>
<!-- Flow Visualization -->
<div class="flow-visualization">
<div class="flow-container">
<div
v-for="(actor, i) in currentStepData.actors"
:key="i"
2026-02-24 00:18:09 +08:00
class="flow-node"
:class="{
active: isNodeActive(i),
completed: isNodeCompleted(i),
processing: isNodeProcessing(i)
}"
>
2026-02-24 00:18:09 +08:00
<div class="node-icon">{{ actor.icon }}</div>
<div class="node-content">
<div class="node-name">{{ actor.name }}</div>
<div v-if="actor.value" class="node-value">{{ actor.value }}</div>
</div>
<div v-if="i < currentStepData.actors.length - 1" class="node-arrow">
<span class="arrow-line" :class="{ animated: isArrowActive(i) }"></span>
<span class="arrow-head" :class="{ animated: isArrowActive(i) }"></span>
</div>
</div>
2026-02-24 00:18:09 +08:00
</div>
</div>
2026-02-24 00:18:09 +08:00
<!-- Detail Panel -->
<div class="detail-panel">
<div class="detail-header">
<span class="detail-title">{{ currentStepData.title }}</span>
</div>
<div class="detail-content">
<div
2026-02-24 00:18:09 +08:00
v-for="(point, i) in currentStepData.points"
:key="i"
class="detail-point"
:class="{ visible: isPointVisible(i), highlight: isPointHighlight(i) }"
>
2026-02-24 00:18:09 +08:00
<span class="point-bullet" :style="{ background: currentStepData.color }">{{ i + 1 }}</span>
<span class="point-text">{{ point }}</span>
</div>
</div>
2026-02-24 00:18:09 +08:00
<div
v-if="currentInsight"
class="insight-box"
:class="{ visible: showInsight }"
:style="{ borderLeftColor: currentStepData.color }"
>
<span class="insight-icon">💡</span>
<span class="insight-text">{{ currentInsight }}</span>
</div>
</div>
</div>
2026-02-24 00:18:09 +08:00
<!-- Control Panel -->
<div class="control-panel">
<button
class="ctrl-btn secondary"
:disabled="currentStep === 0 && stepPhase === 'idle'"
@click="handlePrev"
>
上一步
</button>
<button
class="ctrl-btn primary"
:disabled="isAnimating"
@click="handleMainAction"
>
<span v-if="isAnimating" class="btn-loading">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</span>
<span v-else>{{ mainButtonText }}</span>
</button>
<button
class="ctrl-btn secondary"
:disabled="currentStep >= steps.length - 1 && stepPhase === 'completed'"
@click="handleNext"
>
{{ currentStep >= steps.length - 1 && stepPhase === 'completed' ? '完成 ✓' : '下一步 →' }}
</button>
</div>
2026-02-24 00:18:09 +08:00
<!-- Summary Panel (shown when all completed) -->
<div v-if="allCompleted" class="summary-panel">
<div class="summary-title">🎯 三步协同完成数据旅程</div>
<div class="summary-grid">
<div class="summary-item">
<span class="summary-icon">🔢</span>
<span class="summary-label">编码</span>
<span class="summary-desc">把光信号翻译成数字</span>
</div>
<div class="summary-item">
<span class="summary-icon">💾</span>
<span class="summary-label">存储</span>
<span class="summary-desc">先内存缓冲再持久写入</span>
</div>
<div class="summary-item">
<span class="summary-icon">📡</span>
<span class="summary-label">传输</span>
<span class="summary-desc">分包加密可靠送达</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
2026-02-24 00:18:09 +08:00
import { ref, computed, watch } from 'vue'
const currentStep = ref(0)
2026-02-24 00:18:09 +08:00
const stepPhase = ref('idle') // idle, animating, completed
const visiblePoints = ref([])
2026-02-24 00:18:09 +08:00
const showInsight = ref(false)
const allCompleted = ref(false)
const steps = [
{
label: '编码',
2026-02-24 00:18:09 +08:00
stageName: '编码阶段',
icon: '🔢',
title: '第一步:编码 — 把光变成数字',
color: '#7c3aed',
actors: [
2026-02-24 00:18:09 +08:00
{ icon: '☀️', name: '光线', value: '物理信号' },
{ icon: '📷', name: '传感器', value: 'CMOS/CCD' },
{ icon: '📊', name: 'RAW 数据', value: '24MB / 4860万像素' },
2026-02-24 00:18:09 +08:00
{ icon: '🗜️', name: 'JPEG 压缩', value: '有损压缩' },
{ icon: '📄', name: 'JPEG 文件', value: '3.2MB' }
],
points: [
2026-02-24 00:18:09 +08:00
'相机传感器把光信号转换成 RGB 数值(每个像素 3 × 8 bit = 24 bit',
'整张照片 4860 万像素 × 24 bit ≈ 140 MB 的原始数据',
'JPEG 算法分析像素相似性,去掉人眼不敏感的信息,压缩到 3 MB'
],
insight: '压缩 ≠ 降质,好的压缩算法让你几乎看不出差别,但文件小了 97%。'
},
{
label: '存储',
2026-02-24 00:18:09 +08:00
stageName: '存储阶段',
icon: '💾',
2026-02-24 00:18:09 +08:00
title: '第二步:存储 — 先内存后闪存',
color: '#059669',
actors: [
{ icon: '📄', name: 'JPEG(已编码)', value: '3.2 MB' },
2026-02-24 00:18:09 +08:00
{ icon: '🧠', name: 'RAM(内存)', value: '写入 ~1 ms' },
{ icon: '💾', name: '闪存(Flash', value: '写入 ~10 ms' }
],
points: [
'⚡ 图像先写进内存(RAM)——速度极快,但断电消失',
'💾 内存中的数据再异步写入闪存(手机存储)——速度慢一些,但永久保存',
'🔒 写完后操作系统标记文件"安全",你才能看到相册里的新照片'
],
insight: '为什么拍完不能马上拔电池?因为数据可能还在内存里,还没写进闪存!'
},
{
label: '传输',
2026-02-24 00:18:09 +08:00
stageName: '传输阶段',
icon: '📡',
title: '第三步:传输 — 数据"旅行"到云端',
color: '#d97706',
actors: [
{ icon: '💾', name: '闪存(JPEG', value: '3.2 MB' },
2026-02-24 00:18:09 +08:00
{ icon: '📶', name: 'Wi-Fi / 4G', value: 'TCP 分包传输' },
{ icon: '☁️', name: '云端服务器', value: '写入云存储' }
],
points: [
'📦 3.2 MB 的 JPEG 文件被 TCP 协议切成数千个小"数据包"',
'🔐 每个包都有序号和校验码,丢了会自动重传——所以传输是可靠的',
'☁️ 云端收齐所有包,重新拼成完整 JPEG,写入对象存储(如 OSS/S3'
],
insight: '上传时你以为数据是"整个发过去"的,其实是"切碎了一片片送过去"。'
}
]
const currentStepData = computed(() => steps[currentStep.value])
2026-02-24 00:18:09 +08:00
const isAnimating = computed(() => stepPhase.value === 'animating')
const stageStatus = computed(() => {
if (stepPhase.value === 'idle') return '等待执行'
if (stepPhase.value === 'animating') return '执行中...'
return '已完成'
})
const mainButtonText = computed(() => {
if (allCompleted.value) return '🔄 重新演示'
if (stepPhase.value === 'completed') return '✓ 已完成,点击下一步'
return '▶ 执行这一步'
})
const currentInsight = computed(() => {
if (stepPhase.value === 'completed') {
return currentStepData.value.insight
}
return ''
})
// Node state helpers
function isNodeActive(index) {
if (stepPhase.value === 'idle') return index === 0
if (stepPhase.value === 'animating') {
const progress = visiblePoints.value.length / currentStepData.value.points.length
const nodeProgress = (index + 1) / currentStepData.value.actors.length
return nodeProgress <= progress + 0.2
}
return true
}
2026-02-24 00:18:09 +08:00
function isNodeCompleted(index) {
if (stepPhase.value === 'completed') return true
if (stepPhase.value === 'animating') {
const progress = visiblePoints.value.length / currentStepData.value.points.length
const nodeProgress = (index + 1) / currentStepData.value.actors.length
return nodeProgress < progress
}
return false
}
function isNodeProcessing(index) {
if (stepPhase.value !== 'animating') return false
const progress = visiblePoints.value.length / currentStepData.value.points.length
const nodeProgress = (index + 1) / currentStepData.value.actors.length
return Math.abs(nodeProgress - progress) < 0.3
}
function isArrowActive(index) {
if (stepPhase.value === 'idle') return false
if (stepPhase.value === 'completed') return true
const progress = visiblePoints.value.length / currentStepData.value.points.length
const arrowProgress = (index + 1) / (currentStepData.value.actors.length - 1)
return arrowProgress <= progress
}
function isPointVisible(index) {
return visiblePoints.value.includes(index)
}
function isPointHighlight(index) {
if (stepPhase.value !== 'animating') return false
return visiblePoints.value.length === index + 1
}
// Actions
async function handleMainAction() {
if (allCompleted.value) {
resetDemo()
return
}
2026-02-24 00:18:09 +08:00
if (stepPhase.value === 'completed') {
// If already completed, move to next step
if (currentStep.value < steps.length - 1) {
goToStep(currentStep.value + 1)
}
return
}
// Start animation
await runCurrentStep()
}
async function runCurrentStep() {
stepPhase.value = 'animating'
visiblePoints.value = []
2026-02-24 00:18:09 +08:00
showInsight.value = false
const pts = currentStepData.value.points
for (let i = 0; i < pts.length; i++) {
2026-02-24 00:18:09 +08:00
await new Promise(r => setTimeout(r, 800))
visiblePoints.value.push(i)
}
2026-02-24 00:18:09 +08:00
// Show insight after all points
await new Promise(r => setTimeout(r, 400))
showInsight.value = true
stepPhase.value = 'completed'
// Check if all steps completed
if (currentStep.value === steps.length - 1) {
allCompleted.value = true
}
}
function handlePrev() {
if (stepPhase.value === 'idle' && currentStep.value > 0) {
goToStep(currentStep.value - 1)
} else {
// Reset current step
stepPhase.value = 'idle'
visiblePoints.value = []
2026-02-24 00:18:09 +08:00
showInsight.value = false
}
}
function handleNext() {
if (currentStep.value < steps.length - 1) {
goToStep(currentStep.value + 1)
}
}
function goToStep(index) {
currentStep.value = index
stepPhase.value = 'idle'
visiblePoints.value = []
showInsight.value = false
if (index < steps.length - 1) {
allCompleted.value = false
}
}
2026-02-24 00:18:09 +08:00
function resetDemo() {
currentStep.value = 0
stepPhase.value = 'idle'
visiblePoints.value = []
showInsight.value = false
allCompleted.value = false
}
// Watch for step changes to reset state
watch(currentStep, () => {
stepPhase.value = 'idle'
visiblePoints.value = []
showInsight.value = false
})
</script>
<style scoped>
.journey-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
2026-02-24 00:18:09 +08:00
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
}
2026-02-24 00:18:09 +08:00
/* Header */
.demo-header {
margin-bottom: 1.5rem;
}
2026-02-24 00:18:09 +08:00
.demo-header .title {
font-weight: 700;
font-size: 1.1rem;
display: block;
margin-bottom: 0.25rem;
}
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
/* Progress Steps */
.progress-steps {
display: flex;
align-items: center;
2026-02-24 00:18:09 +08:00
justify-content: center;
gap: 0;
margin-bottom: 1.5rem;
padding: 0 1rem;
}
2026-02-24 00:18:09 +08:00
.step-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
max-width: 120px;
}
2026-02-24 00:18:09 +08:00
.step-circle {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
2026-02-24 00:18:09 +08:00
font-size: 0.85rem;
font-weight: 600;
transition: all 0.3s ease;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
}
2026-02-24 00:18:09 +08:00
.step-item.active .step-circle {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
transform: scale(1.1);
}
2026-02-24 00:18:09 +08:00
.step-item.completed .step-circle {
background: var(--vp-c-success);
border-color: var(--vp-c-success);
color: white;
}
.step-label {
font-size: 0.8rem;
margin-top: 0.4rem;
color: var(--vp-c-text-2);
font-weight: 500;
transition: all 0.3s;
}
.step-item.active .step-label {
color: var(--vp-c-brand);
font-weight: 600;
}
.step-item.completed .step-label {
color: var(--vp-c-success);
}
.step-line {
position: absolute;
top: 16px;
right: -50%;
width: 100%;
height: 2px;
background: var(--vp-c-divider);
transform: translateY(-50%);
z-index: 0;
transition: background 0.3s;
}
.step-item.completed .step-line {
background: var(--vp-c-success);
}
/* Visualization Area */
.visualization-area {
background: var(--vp-c-bg);
border: 2px solid;
2026-02-24 00:18:09 +08:00
border-radius: 10px;
overflow: hidden;
margin-bottom: 1rem;
transition: border-color 0.4s ease;
}
.stage-title-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--vp-c-divider);
}
.stage-icon {
font-size: 1.3rem;
}
.stage-name {
font-weight: 600;
font-size: 0.95rem;
flex: 1;
}
2026-02-24 00:18:09 +08:00
.stage-status {
font-size: 0.8rem;
font-weight: 500;
padding: 0.25rem 0.75rem;
border-radius: 12px;
background: var(--vp-c-bg-soft);
}
2026-02-24 00:18:09 +08:00
/* Flow Visualization */
.flow-visualization {
padding: 1.5rem 1rem;
background: var(--vp-c-bg-soft);
}
.flow-container {
display: flex;
align-items: center;
2026-02-24 00:18:09 +08:00
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
}
2026-02-24 00:18:09 +08:00
.flow-node {
display: flex;
flex-direction: column;
align-items: center;
2026-02-24 00:18:09 +08:00
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 0.75rem;
min-width: 90px;
text-align: center;
2026-02-24 00:18:09 +08:00
transition: all 0.4s ease;
position: relative;
}
.flow-node.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.flow-node.completed {
border-color: var(--vp-c-success);
background: var(--vp-c-success-soft);
}
.flow-node.processing {
animation: pulse-node 1.5s ease-in-out infinite;
}
2026-02-24 00:18:09 +08:00
@keyframes pulse-node {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(var(--vp-c-brand-rgb), 0.4);
}
50% {
transform: scale(1.02);
box-shadow: 0 0 0 8px rgba(var(--vp-c-brand-rgb), 0);
}
}
2026-02-24 00:18:09 +08:00
.node-icon {
font-size: 1.8rem;
margin-bottom: 0.25rem;
}
2026-02-24 00:18:09 +08:00
.node-name {
font-size: 0.8rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
2026-02-24 00:18:09 +08:00
.node-value {
font-size: 0.7rem;
color: var(--vp-c-text-2);
margin-top: 0.15rem;
}
.node-arrow {
display: flex;
flex-direction: column;
align-items: center;
2026-02-24 00:18:09 +08:00
margin: 0 0.25rem;
}
2026-02-24 00:18:09 +08:00
.arrow-line {
width: 30px;
height: 2px;
background: var(--vp-c-divider);
transition: background 0.3s;
}
2026-02-24 00:18:09 +08:00
.arrow-line.animated {
background: var(--vp-c-brand);
animation: flow-line 1s ease-in-out infinite;
}
2026-02-24 00:18:09 +08:00
@keyframes flow-line {
0% { opacity: 0.3; }
50% { opacity: 1; }
100% { opacity: 0.3; }
}
.arrow-head {
font-size: 0.7rem;
color: var(--vp-c-divider);
margin-top: -2px;
transition: color 0.3s;
}
.arrow-head.animated {
color: var(--vp-c-brand);
}
2026-02-24 00:18:09 +08:00
/* Detail Panel */
.detail-panel {
padding: 1rem;
border-top: 1px solid var(--vp-c-divider);
}
2026-02-24 00:18:09 +08:00
.detail-header {
margin-bottom: 0.75rem;
}
2026-02-24 00:18:09 +08:00
.detail-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--vp-c-text-1);
2026-02-24 00:18:09 +08:00
}
.detail-content {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.detail-point {
display: flex;
align-items: flex-start;
gap: 0.6rem;
font-size: 0.85rem;
line-height: 1.5;
opacity: 0;
2026-02-24 00:18:09 +08:00
transform: translateX(-10px);
transition: all 0.4s ease;
}
.detail-point.visible {
opacity: 1;
transform: translateX(0);
}
.detail-point.highlight {
background: var(--vp-c-brand-soft);
padding: 0.4rem 0.6rem;
border-radius: 6px;
margin: 0 -0.3rem;
}
.point-bullet {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 600;
color: white;
flex-shrink: 0;
margin-top: 0.1rem;
}
.point-text {
color: var(--vp-c-text-1);
flex: 1;
}
/* Insight Box */
.insight-box {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--vp-c-bg-soft);
border-left: 4px solid;
border-radius: 0 6px 6px 0;
opacity: 0;
transform: translateY(10px);
transition: all 0.4s ease;
}
2026-02-24 00:18:09 +08:00
.insight-box.visible {
opacity: 1;
transform: translateY(0);
}
2026-02-24 00:18:09 +08:00
.insight-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.insight-text {
font-size: 0.85rem;
color: var(--vp-c-text-2);
font-style: italic;
2026-02-24 00:18:09 +08:00
line-height: 1.5;
}
2026-02-24 00:18:09 +08:00
/* Control Panel */
.control-panel {
display: flex;
2026-02-24 00:18:09 +08:00
gap: 0.75rem;
align-items: center;
2026-02-24 00:18:09 +08:00
justify-content: center;
}
.ctrl-btn {
2026-02-24 00:18:09 +08:00
padding: 0.6rem 1.25rem;
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
cursor: pointer;
2026-02-24 00:18:09 +08:00
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 120px;
}
.ctrl-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.ctrl-btn:active:not(:disabled) {
transform: translateY(0);
}
.ctrl-btn.primary {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
flex: 1;
2026-02-24 00:18:09 +08:00
max-width: 200px;
}
2026-02-24 00:18:09 +08:00
.ctrl-btn.primary:hover:not(:disabled) {
background: var(--vp-c-brand-dark);
}
.ctrl-btn.secondary {
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
}
2026-02-24 00:18:09 +08:00
.ctrl-btn.secondary:hover:not(:disabled) {
background: var(--vp-c-bg-alt);
2026-02-24 00:18:09 +08:00
border-color: var(--vp-c-brand);
}
.ctrl-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading Animation */
.btn-loading {
display: flex;
gap: 4px;
}
.loading-dot {
width: 6px;
height: 6px;
background: white;
border-radius: 50%;
animation: loading-bounce 1.4s ease-in-out infinite both;
}
.loading-dot:nth-child(1) { animation-delay: -0.32s; }
.loading-dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes loading-bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Summary Panel */
.summary-panel {
margin-top: 1.5rem;
padding: 1.25rem;
background: var(--vp-c-success-soft);
border: 1px solid var(--vp-c-success);
border-radius: 10px;
animation: fade-in-up 0.5s ease;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.summary-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 1rem;
text-align: center;
color: var(--vp-c-success);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 8px;
}
.summary-icon {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.summary-label {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.15rem;
}
.summary-desc {
font-size: 0.75rem;
color: var(--vp-c-text-2);
}
/* Responsive */
@media (max-width: 640px) {
.journey-demo {
padding: 1rem;
}
.progress-steps {
padding: 0;
}
.step-label {
font-size: 0.7rem;
}
.flow-container {
flex-direction: column;
gap: 0.75rem;
}
.flow-node {
flex-direction: row;
width: 100%;
min-width: auto;
text-align: left;
gap: 0.75rem;
}
.node-arrow {
transform: rotate(90deg);
margin: 0.25rem 0;
}
.summary-grid {
grid-template-columns: 1fr;
}
.control-panel {
flex-direction: column;
}
.ctrl-btn {
width: 100%;
max-width: none;
}
}
</style>