feat: add AI and Backend evolution history with interactive demos, and refine Frontend evolution demo
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div class="ai-evolution-demo">
|
||||
<!-- Timeline -->
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-track"></div>
|
||||
<button
|
||||
v-for="(stage, index) in stages"
|
||||
:key="index"
|
||||
class="timeline-node"
|
||||
:class="{ active: currentStage === index, passed: currentStage > index }"
|
||||
@click="currentStage = index"
|
||||
>
|
||||
<div class="node-dot">
|
||||
<div class="inner-dot"></div>
|
||||
</div>
|
||||
<div class="node-content">
|
||||
<span class="year-badge">{{ stage.year }}</span>
|
||||
<span class="node-label">{{ stage.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-wrapper">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div :key="currentStage" class="stage-content">
|
||||
<div class="header-section">
|
||||
<h3>
|
||||
<span class="stage-index">{{ indexToRoman(currentStage + 1) }}.</span>
|
||||
{{ stages[currentStage].title }}
|
||||
</h3>
|
||||
<p>{{ stages[currentStage].desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="visualization-grid">
|
||||
<!-- Concept/Logic View -->
|
||||
<div class="mac-window concept-window">
|
||||
<div class="window-bar">
|
||||
<div class="traffic-lights">
|
||||
<span class="light red"></span>
|
||||
<span class="light yellow"></span>
|
||||
<span class="light green"></span>
|
||||
</div>
|
||||
<div class="window-title">Core Logic</div>
|
||||
</div>
|
||||
<div class="concept-canvas">
|
||||
<!-- Stage 0: Symbolism -->
|
||||
<div v-if="currentStage === 0" class="vis-symbolism">
|
||||
<div class="logic-gate">
|
||||
<div class="input-group">
|
||||
<span class="input-val">A: True</span>
|
||||
<span class="input-val">B: False</span>
|
||||
</div>
|
||||
<div class="gate-box">
|
||||
AND Rule
|
||||
</div>
|
||||
<div class="output-val">Output: False</div>
|
||||
</div>
|
||||
<div class="math-note">If A and B then C</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 1: Expert Systems -->
|
||||
<div v-if="currentStage === 1" class="vis-expert">
|
||||
<div class="decision-tree">
|
||||
<div class="tree-node root">Is it raining?</div>
|
||||
<div class="branches">
|
||||
<div class="branch">
|
||||
<span class="condition">Yes</span>
|
||||
<div class="tree-node leaf">Take Umbrella</div>
|
||||
</div>
|
||||
<div class="branch">
|
||||
<span class="condition">No</span>
|
||||
<div class="tree-node leaf">Go Out</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb-note">Knowledge Base + Inference Engine</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 2: Deep Learning -->
|
||||
<div v-if="currentStage === 2" class="vis-dl">
|
||||
<div class="neural-net">
|
||||
<div class="layer input">
|
||||
<div class="neuron" v-for="n in 3" :key="`i-${n}`"></div>
|
||||
</div>
|
||||
<div class="layer hidden">
|
||||
<div class="neuron" v-for="n in 4" :key="`h-${n}`"></div>
|
||||
</div>
|
||||
<div class="layer output">
|
||||
<div class="neuron" v-for="n in 2" :key="`o-${n}`"></div>
|
||||
</div>
|
||||
<!-- Connections drawn via CSS/SVG ideally, simplified here -->
|
||||
<svg class="connections">
|
||||
<line x1="10" y1="20" x2="60" y2="10" />
|
||||
<line x1="10" y1="20" x2="60" y2="30" />
|
||||
<!-- Abstract lines -->
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dl-note">Feature Extraction (Black Box)</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 3: GenAI -->
|
||||
<div v-if="currentStage === 3" class="vis-genai">
|
||||
<div class="transformer-block">
|
||||
<div class="block-layer attn">Self-Attention</div>
|
||||
<div class="block-layer ff">Feed Forward</div>
|
||||
<div class="block-layer norm">Norm & Add</div>
|
||||
</div>
|
||||
<div class="chat-sim">
|
||||
<div class="msg user">"Draw a cat"</div>
|
||||
<div class="msg ai">Generates 🐱...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application/Impact View -->
|
||||
<div class="mac-window app-window">
|
||||
<div class="window-bar">
|
||||
<div class="window-title">Real-world Impact</div>
|
||||
</div>
|
||||
<div class="app-canvas">
|
||||
<div class="impact-card">
|
||||
<div class="impact-icon">{{ stages[currentStage].icon }}</div>
|
||||
<div class="impact-title">{{ stages[currentStage].appTitle }}</div>
|
||||
<div class="impact-desc">{{ stages[currentStage].appDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStage = ref(0)
|
||||
|
||||
const indexToRoman = (num) => {
|
||||
const map = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV' }
|
||||
return map[num] || num
|
||||
}
|
||||
|
||||
const stages = [
|
||||
{
|
||||
year: '1950s-1970s',
|
||||
label: 'Symbolism',
|
||||
title: 'The Dawn: Logic & Rules',
|
||||
desc: 'AI started as "Symbolic AI". Scientists believed intelligence could be described by formal logic and rules. If we can write down all the rules of the world, a computer can be intelligent.',
|
||||
icon: '♟️',
|
||||
appTitle: 'Chess & Logic',
|
||||
appDesc: 'Programs could solve logic puzzles and play simple chess, but failed at "common sense" or recognizing a cat in a photo.'
|
||||
},
|
||||
{
|
||||
year: '1980s-1990s',
|
||||
label: 'Expert Systems',
|
||||
title: 'Knowledge Engineering',
|
||||
desc: 'The era of "Expert Systems". We tried to hard-code human expertise (e.g., medical diagnosis rules) into databases. Useful for specific domains, but brittle and hard to maintain.',
|
||||
icon: '🏥',
|
||||
appTitle: 'MYCIN / Deep Blue',
|
||||
appDesc: 'Systems that could diagnose blood infections or beat Garry Kasparov at chess (Deep Blue, 1997), but still lacked true learning capability.'
|
||||
},
|
||||
{
|
||||
year: '2010s',
|
||||
label: 'Deep Learning',
|
||||
title: 'Connectionism & Big Data',
|
||||
desc: 'The breakthrough of Neural Networks. Inspired by the human brain, computers learned patterns from massive data instead of being told rules. AlexNet (2012) changed everything.',
|
||||
icon: '🧠',
|
||||
appTitle: 'AlphaGo & FaceID',
|
||||
appDesc: 'AI learned to see (ImageNet), hear (Siri), and play Go (AlphaGo). It surpassed humans in specific perceptual tasks.'
|
||||
},
|
||||
{
|
||||
year: '2020s+',
|
||||
label: 'Generative AI',
|
||||
title: 'Generative Intelligence (LLMs)',
|
||||
desc: 'The Transformer architecture allowed AI to understand context and generate new content. AI moved from "classifying" (is this a cat?) to "creating" (draw a cat).',
|
||||
icon: '✨',
|
||||
appTitle: 'ChatGPT & Midjourney',
|
||||
appDesc: 'AI that can write code, poetry, paint images, and reason across multiple domains. A step towards AGI (General Intelligence).'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-evolution-demo {
|
||||
border-radius: 16px;
|
||||
background: var(--vp-c-bg);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
margin: 2rem 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Reusing Timeline Styles from FrontendEvolutionDemo for consistency */
|
||||
.timeline-container {
|
||||
padding: 2rem 1rem 1rem;
|
||||
background: linear-gradient(to bottom, var(--vp-c-bg-soft), var(--vp-c-bg));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
position: absolute;
|
||||
top: 2.5rem;
|
||||
left: 3rem;
|
||||
right: 3rem;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 25%;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.timeline-node:hover { opacity: 0.9; }
|
||||
.timeline-node.active, .timeline-node.passed { opacity: 1; }
|
||||
|
||||
.node-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-text-3);
|
||||
margin-bottom: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.inner-dot {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.timeline-node.active .node-dot {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 0 4px var(--vp-c-bg-soft);
|
||||
}
|
||||
.timeline-node.active .inner-dot { width: 8px; height: 8px; }
|
||||
.timeline-node.passed .node-dot { border-color: var(--vp-c-brand); background: var(--vp-c-brand); }
|
||||
|
||||
.node-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.content-wrapper { padding: 2rem; min-height: 400px; }
|
||||
|
||||
.header-section {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
.header-section h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(120deg, #10b981, #3b82f6); /* Green to Blue for AI */
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stage-index { color: var(--vp-c-text-3); -webkit-text-fill-color: var(--vp-c-text-3); margin-right: 0.5rem; font-weight: normal; }
|
||||
.header-section p { font-size: 1rem; color: var(--vp-c-text-2); line-height: 1.6; }
|
||||
|
||||
/* Visualization */
|
||||
.visualization-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) { .visualization-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.mac-window {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.mac-window:hover { transform: translateY(-5px); }
|
||||
|
||||
.concept-window { background: #f8fafc; }
|
||||
.app-window { background: white; }
|
||||
|
||||
.window-bar {
|
||||
padding: 0.8rem 1rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.traffic-lights { display: flex; gap: 6px; }
|
||||
.light { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.light.red { background: #ff5f56; }
|
||||
.light.yellow { background: #ffbd2e; }
|
||||
.light.green { background: #27c93f; }
|
||||
|
||||
.window-title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.concept-canvas, .app-canvas {
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
/* Visualizations */
|
||||
/* Symbolism */
|
||||
.logic-gate {
|
||||
border: 2px solid #334155;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
}
|
||||
.input-group { display: flex; gap: 1rem; justify-content: center; margin-bottom: 0.5rem; font-family: monospace; }
|
||||
.gate-box { background: #334155; color: white; padding: 4px 10px; margin: 0.5rem 0; border-radius: 4px; }
|
||||
.math-note { margin-top: 1rem; font-family: monospace; color: #64748b; font-size: 0.8rem; }
|
||||
|
||||
/* Expert Systems */
|
||||
.decision-tree { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
|
||||
.tree-node { border: 1px solid #cbd5e1; padding: 6px 12px; border-radius: 20px; background: white; font-size: 0.8rem; }
|
||||
.tree-node.root { border-color: #3b82f6; color: #3b82f6; font-weight: bold; }
|
||||
.branches { display: flex; gap: 2rem; }
|
||||
.branch { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
|
||||
.condition { font-size: 0.7rem; color: #64748b; background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }
|
||||
.kb-note { margin-top: 1rem; font-size: 0.8rem; color: #64748b; font-style: italic; }
|
||||
|
||||
/* Deep Learning */
|
||||
.neural-net { display: flex; gap: 2rem; align-items: center; position: relative; }
|
||||
.layer { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.neuron { width: 12px; height: 12px; border-radius: 50%; background: #cbd5e1; border: 1px solid #94a3b8; }
|
||||
.layer.input .neuron { background: #93c5fd; }
|
||||
.layer.hidden .neuron { background: #fca5a5; }
|
||||
.layer.output .neuron { background: #86efac; }
|
||||
.connections { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; opacity: 0.2; }
|
||||
.connections line { stroke: #000; stroke-width: 1; }
|
||||
.dl-note { margin-top: 2rem; font-size: 0.8rem; color: #64748b; }
|
||||
|
||||
/* GenAI */
|
||||
.vis-genai { display: flex; flex-direction: column; gap: 1rem; align-items: center; width: 100%; }
|
||||
.transformer-block { border: 2px solid #8b5cf6; border-radius: 8px; padding: 0.5rem; width: 120px; text-align: center; background: #f5f3ff; }
|
||||
.block-layer { border: 1px solid #ddd6fe; background: white; margin: 4px 0; padding: 4px; font-size: 0.7rem; border-radius: 4px; }
|
||||
.chat-sim { width: 100%; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; background: white; font-size: 0.8rem; }
|
||||
.msg { padding: 6px 10px; border-radius: 12px; margin-bottom: 0.5rem; max-width: 80%; }
|
||||
.msg.user { background: #eff6ff; margin-left: auto; color: #1e40af; }
|
||||
.msg.ai { background: #f0fdf4; margin-right: auto; color: #166534; }
|
||||
|
||||
/* Impact Card */
|
||||
.impact-card { text-align: center; }
|
||||
.impact-icon { font-size: 4rem; margin-bottom: 1rem; }
|
||||
.impact-title { font-size: 1.2rem; font-weight: bold; margin-bottom: 0.5rem; color: var(--vp-c-text-1); }
|
||||
.impact-desc { font-size: 0.9rem; color: var(--vp-c-text-2); line-height: 1.5; }
|
||||
|
||||
/* Transitions */
|
||||
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.4s ease; }
|
||||
.fade-slide-enter-from { opacity: 0; transform: translateY(20px); }
|
||||
.fade-slide-leave-to { opacity: 0; transform: translateY(-20px); }
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="perceptron-demo">
|
||||
<div class="neuron-viz">
|
||||
<!-- Inputs -->
|
||||
<div class="inputs-col">
|
||||
<div class="input-node">
|
||||
<span class="label">Input 1 (x₁)</span>
|
||||
<input type="number" v-model="x1" class="val-input">
|
||||
</div>
|
||||
<div class="input-node">
|
||||
<span class="label">Input 2 (x₂)</span>
|
||||
<input type="number" v-model="x2" class="val-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weights (Edges) -->
|
||||
<div class="weights-col">
|
||||
<div class="weight-line" :style="{ width: Math.abs(w1) * 2 + 2 + 'px', opacity: Math.abs(w1)/5 + 0.2 }"></div>
|
||||
<div class="weight-control top">
|
||||
w₁: <input type="range" v-model="w1" min="-5" max="5" step="0.1"> {{ w1 }}
|
||||
</div>
|
||||
|
||||
<div class="weight-line" :style="{ width: Math.abs(w2) * 2 + 2 + 'px', opacity: Math.abs(w2)/5 + 0.2 }"></div>
|
||||
<div class="weight-control bottom">
|
||||
w₂: <input type="range" v-model="w2" min="-5" max="5" step="0.1"> {{ w2 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neuron (Sum & Activation) -->
|
||||
<div class="neuron-body">
|
||||
<div class="sum-part">
|
||||
<div class="math">∑</div>
|
||||
<div class="val">{{ weightedSum.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="bias-control">
|
||||
Bias: <input type="number" v-model="bias" class="bias-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="output-col">
|
||||
<div class="arrow">➔</div>
|
||||
<div class="output-node" :class="{ active: output > 0 }">
|
||||
<span class="label">Output (y)</span>
|
||||
<div class="val">{{ output }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formula-bar">
|
||||
Formula: <code>({{ x1 }} * {{ w1 }}) + ({{ x2 }} * {{ w2 }}) + {{ bias }} = {{ weightedSum.toFixed(1) }}</code>
|
||||
<br>
|
||||
Activation: <code>Step( {{ weightedSum.toFixed(1) }} ) = {{ output }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const x1 = ref(1)
|
||||
const x2 = ref(0)
|
||||
const w1 = ref(2.0)
|
||||
const w2 = ref(-1.0)
|
||||
const bias = ref(0)
|
||||
|
||||
const weightedSum = computed(() => {
|
||||
return (x1.value * w1.value) + (x2.value * w2.value) + bias.value
|
||||
})
|
||||
|
||||
const output = computed(() => {
|
||||
return weightedSum.value > 0 ? 1 : 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.perceptron-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.neuron-viz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 500px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.inputs-col, .output-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-node, .output-node {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #94a3b8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f5f9;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.output-node.active {
|
||||
background: #4ade80;
|
||||
border-color: #16a34a;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.6rem;
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.val-input {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.weights-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.weight-line {
|
||||
height: 2px;
|
||||
background: #475569;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform-origin: left center;
|
||||
}
|
||||
/* Simplified visual lines for CSS only demo - ideally SVG */
|
||||
/* This is a simplified representation */
|
||||
|
||||
.weight-control {
|
||||
font-size: 0.7rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: white;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.neuron-body {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.sum-part {
|
||||
text-align: center;
|
||||
}
|
||||
.math { font-size: 1.5rem; }
|
||||
.val { font-weight: bold; }
|
||||
|
||||
.bias-control {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 0.7rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.bias-input {
|
||||
width: 30px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.formula-bar {
|
||||
margin-top: 2rem;
|
||||
background: #f8fafc;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
border: 1px dashed #cbd5e1;
|
||||
}
|
||||
|
||||
input[type=range] { width: 60px; }
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="rule-learning-demo">
|
||||
<div class="demo-grid">
|
||||
<!-- Rule Based System -->
|
||||
<div class="panel rule-based">
|
||||
<div class="panel-header">
|
||||
<span class="icon">📜</span> Rule-Based System
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="code-block">
|
||||
if (size > <input v-model="ruleThreshold" type="number" class="mini-input">) {<br>
|
||||
return "Big 🍎"<br>
|
||||
} else {<br>
|
||||
return "Small 🍒"<br>
|
||||
}
|
||||
</div>
|
||||
<div class="test-area">
|
||||
Test Input: <input v-model="testInput" type="range" min="1" max="10" class="slider"> {{ testInput }}
|
||||
<div class="result-box" :class="ruleResult === 'Big 🍎' ? 'big' : 'small'">
|
||||
Result: {{ ruleResult }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">You must explicitly program the rule.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Machine Learning System -->
|
||||
<div class="panel learning">
|
||||
<div class="panel-header">
|
||||
<span class="icon">🧠</span> Machine Learning
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="training-data">
|
||||
<div class="data-point" v-for="(p, i) in trainingData" :key="i">
|
||||
{{ p.size }}={{ p.label }}
|
||||
</div>
|
||||
<button class="train-btn" @click="trainModel" :disabled="isTrained">
|
||||
{{ isTrained ? 'Model Trained ✅' : '⚡ Train Model' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-area">
|
||||
Test Input: <input v-model="testInput" type="range" min="1" max="10" class="slider"> {{ testInput }}
|
||||
<div class="result-box" :class="mlResult === 'Big 🍎' ? 'big' : 'small'">
|
||||
Result: {{ mlResult }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">
|
||||
Model "learned" threshold is ~{{ learnedThreshold }}. <br>
|
||||
(Derived from data, not coded)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const testInput = ref(5)
|
||||
|
||||
// Rule Based Logic
|
||||
const ruleThreshold = ref(6)
|
||||
const ruleResult = computed(() => {
|
||||
return testInput.value > ruleThreshold.value ? 'Big 🍎' : 'Small 🍒'
|
||||
})
|
||||
|
||||
// ML Logic
|
||||
const trainingData = [
|
||||
{ size: 2, label: '🍒' },
|
||||
{ size: 3, label: '🍒' },
|
||||
{ size: 8, label: '🍎' },
|
||||
{ size: 9, label: '🍎' }
|
||||
]
|
||||
const isTrained = ref(false)
|
||||
const learnedThreshold = ref(5.5) // Simplified mock learning
|
||||
|
||||
const trainModel = () => {
|
||||
// Simulate training delay
|
||||
setTimeout(() => {
|
||||
isTrained.value = true
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const mlResult = computed(() => {
|
||||
if (!isTrained.value) return '❓ Untrained'
|
||||
return testInput.value > learnedThreshold.value ? 'Big 🍎' : 'Small 🍒'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rule-learning-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.demo-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 0.8rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e1e2e;
|
||||
color: #a6accd;
|
||||
padding: 0.8rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.mini-input {
|
||||
width: 40px;
|
||||
background: #334155;
|
||||
border: 1px solid #475569;
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.test-area {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
margin-top: 0.5rem;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.result-box.big { color: #ef4444; border-color: #fecaca; background: #fef2f2; }
|
||||
.result-box.small { color: #db2777; border-color: #fce7f3; background: #fdf2f8; }
|
||||
|
||||
.note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.training-data {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.data-point {
|
||||
background: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.train-btn {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.train-btn:disabled {
|
||||
background: #10b981;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<div class="backend-evolution-demo">
|
||||
<!-- Timeline -->
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-track"></div>
|
||||
<button
|
||||
v-for="(stage, index) in stages"
|
||||
:key="index"
|
||||
class="timeline-node"
|
||||
:class="{ active: currentStage === index, passed: currentStage > index }"
|
||||
@click="currentStage = index"
|
||||
>
|
||||
<div class="node-dot">
|
||||
<div class="inner-dot"></div>
|
||||
</div>
|
||||
<div class="node-content">
|
||||
<span class="year-badge">{{ stage.year }}</span>
|
||||
<span class="node-label">{{ stage.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-wrapper">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div :key="currentStage" class="stage-content">
|
||||
<div class="header-section">
|
||||
<h3>
|
||||
<span class="stage-index">{{ indexToRoman(currentStage + 1) }}.</span>
|
||||
{{ stages[currentStage].title }}
|
||||
</h3>
|
||||
<p>{{ stages[currentStage].desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="visualization-grid">
|
||||
<!-- Architecture Diagram -->
|
||||
<div class="mac-window arch-window">
|
||||
<div class="window-bar">
|
||||
<div class="traffic-lights">
|
||||
<span class="light red"></span>
|
||||
<span class="light yellow"></span>
|
||||
<span class="light green"></span>
|
||||
</div>
|
||||
<div class="window-title">Server Architecture</div>
|
||||
</div>
|
||||
<div class="arch-canvas">
|
||||
|
||||
<!-- Stage 0: CGI/Static -->
|
||||
<div v-if="currentStage === 0" class="arch-static">
|
||||
<div class="server-box">
|
||||
<div class="server-icon">🖥️ Physical Server</div>
|
||||
<div class="file-system">
|
||||
<div class="file">index.html</div>
|
||||
<div class="file">script.pl</div>
|
||||
<div class="file">image.jpg</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="request-arrow">
|
||||
<span>GET /index.html</span>
|
||||
<span class="arrow">➔</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 1: Monolith -->
|
||||
<div v-if="currentStage === 1" class="arch-monolith">
|
||||
<div class="server-box big">
|
||||
<div class="server-icon">🦍 Monolithic App (Tomcat/Django)</div>
|
||||
<div class="modules-grid">
|
||||
<div class="module">User</div>
|
||||
<div class="module">Order</div>
|
||||
<div class="module">Payment</div>
|
||||
<div class="module">Product</div>
|
||||
</div>
|
||||
<div class="db-connection">
|
||||
<span>⬇ SQL</span>
|
||||
<div class="db-icon">🗄️ Single DB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 2: Microservices -->
|
||||
<div v-if="currentStage === 2" class="arch-micro">
|
||||
<div class="cloud-bg">
|
||||
<div class="service-mesh">
|
||||
<div class="service user">
|
||||
<span>User Svc</span>
|
||||
<small>Redis</small>
|
||||
</div>
|
||||
<div class="service order">
|
||||
<span>Order Svc</span>
|
||||
<small>MySQL</small>
|
||||
</div>
|
||||
<div class="service pay">
|
||||
<span>Pay Svc</span>
|
||||
<small>Postgres</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comm-lines">HTTP/gRPC</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 3: Serverless -->
|
||||
<div v-if="currentStage === 3" class="arch-serverless">
|
||||
<div class="function-cloud">
|
||||
<div class="func-node">λ Login</div>
|
||||
<div class="func-node">λ Checkout</div>
|
||||
<div class="func-node">λ ResizeImg</div>
|
||||
</div>
|
||||
<div class="baas-layer">
|
||||
<span>BaaS (Auth0, Supabase, Stripe)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployment/Ops View -->
|
||||
<div class="mac-window ops-window">
|
||||
<div class="window-bar">
|
||||
<div class="window-title">Deployment & Ops</div>
|
||||
</div>
|
||||
<div class="ops-canvas">
|
||||
<div class="ops-card">
|
||||
<div class="ops-icon">{{ stages[currentStage].opsIcon }}</div>
|
||||
<div class="ops-title">{{ stages[currentStage].opsTitle }}</div>
|
||||
<div class="ops-desc">{{ stages[currentStage].opsDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStage = ref(0)
|
||||
|
||||
const indexToRoman = (num) => {
|
||||
const map = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV' }
|
||||
return map[num] || num
|
||||
}
|
||||
|
||||
const stages = [
|
||||
{
|
||||
year: '1990s',
|
||||
label: 'CGI / Static',
|
||||
title: 'Physical Servers & Scripts',
|
||||
desc: 'In the beginning, servers were physical machines. We uploaded files via FTP. Backend logic was often simple Perl/CGI scripts executing one by one.',
|
||||
opsIcon: '🐌',
|
||||
opsTitle: 'Manual FTP Upload',
|
||||
opsDesc: 'Development was slow. "It works on my machine" was the common nightmare. Scaling meant buying a bigger physical computer.'
|
||||
},
|
||||
{
|
||||
year: '2000s',
|
||||
label: 'Monolith',
|
||||
title: 'The Monolithic Era',
|
||||
desc: 'Frameworks like Java Spring, Rails, Django appeared. All logic (User, Order, Pay) was packed into ONE giant process. Simple to develop, hard to scale.',
|
||||
opsIcon: '🐳',
|
||||
opsTitle: 'Virtual Machines (VM)',
|
||||
opsDesc: 'We started using VMs (AWS EC2). Scaling meant copying the entire giant application to multiple servers behind a Load Balancer.'
|
||||
},
|
||||
{
|
||||
year: '2014+',
|
||||
label: 'Microservices',
|
||||
title: 'Microservices & Containers',
|
||||
desc: 'Breaking the monolith! Each function (User, Order) became a separate tiny server. Docker changed the game by packaging dependencies together.',
|
||||
opsIcon: '☸️',
|
||||
opsTitle: 'Kubernetes (K8s)',
|
||||
opsDesc: 'Orchestrating thousands of containers. Complexity exploded, but teams could work independently and scale specific parts (e.g., just the Payment service).'
|
||||
},
|
||||
{
|
||||
year: '2020s+',
|
||||
label: 'Serverless',
|
||||
title: 'Serverless & Edge',
|
||||
desc: 'No more managing servers. You just write a function (e.g., "resize image") and upload it. The cloud provider runs it only when needed (Pay-per-use).',
|
||||
opsIcon: '⚡',
|
||||
opsTitle: 'GitOps & Edge',
|
||||
opsDesc: 'Push to Git -> Auto Deploy to global Edge nodes (Vercel, Cloudflare). Backend becomes "Functions + Managed Services (BaaS)".'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backend-evolution-demo {
|
||||
border-radius: 16px;
|
||||
background: var(--vp-c-bg);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
margin: 2rem 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Timeline (Reused) */
|
||||
.timeline-container {
|
||||
padding: 2rem 1rem 1rem;
|
||||
background: linear-gradient(to bottom, var(--vp-c-bg-soft), var(--vp-c-bg));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
position: absolute;
|
||||
top: 2.5rem;
|
||||
left: 3rem;
|
||||
right: 3rem;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 25%;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.timeline-node:hover { opacity: 0.9; }
|
||||
.timeline-node.active, .timeline-node.passed { opacity: 1; }
|
||||
|
||||
.node-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-text-3);
|
||||
margin-bottom: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.inner-dot {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.timeline-node.active .node-dot {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 0 4px var(--vp-c-bg-soft);
|
||||
}
|
||||
.timeline-node.active .inner-dot { width: 8px; height: 8px; }
|
||||
.timeline-node.passed .node-dot { border-color: var(--vp-c-brand); background: var(--vp-c-brand); }
|
||||
|
||||
.node-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content-wrapper { padding: 2rem; min-height: 400px; }
|
||||
|
||||
.header-section {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
.header-section h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(120deg, #f59e0b, #ea580c); /* Orange/Amber for Backend */
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stage-index { color: var(--vp-c-text-3); -webkit-text-fill-color: var(--vp-c-text-3); margin-right: 0.5rem; font-weight: normal; }
|
||||
.header-section p { font-size: 1rem; color: var(--vp-c-text-2); line-height: 1.6; }
|
||||
|
||||
/* Visualizations */
|
||||
.visualization-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) { .visualization-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.mac-window {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.mac-window:hover { transform: translateY(-5px); }
|
||||
|
||||
.arch-window { background: #f1f5f9; }
|
||||
.ops-window { background: white; }
|
||||
|
||||
.window-bar {
|
||||
padding: 0.8rem 1rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.traffic-lights { display: flex; gap: 6px; }
|
||||
.light { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.light.red { background: #ff5f56; }
|
||||
.light.yellow { background: #ffbd2e; }
|
||||
.light.green { background: #27c93f; }
|
||||
|
||||
.window-title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.arch-canvas, .ops-canvas {
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
/* Arch Styles */
|
||||
.server-box {
|
||||
background: #cbd5e1;
|
||||
border: 2px solid #94a3b8;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.file-system { margin-top: 1rem; background: white; padding: 0.5rem; border-radius: 4px; font-family: monospace; font-size: 0.8rem; }
|
||||
.request-arrow { margin-top: 1rem; display: flex; flex-direction: column; align-items: center; font-size: 0.8rem; color: #64748b; }
|
||||
|
||||
.server-box.big { background: #dbeafe; border-color: #3b82f6; width: 100%; max-width: 250px; }
|
||||
.modules-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin: 1rem 0; }
|
||||
.module { background: #bfdbfe; padding: 4px; border-radius: 4px; font-size: 0.8rem; color: #1e40af; }
|
||||
.db-connection { font-size: 0.8rem; display: flex; flex-direction: column; align-items: center; }
|
||||
|
||||
.cloud-bg { width: 100%; }
|
||||
.service-mesh { display: flex; gap: 1rem; justify-content: center; }
|
||||
.service { background: white; border: 1px solid #e2e8f0; padding: 0.8rem; border-radius: 6px; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.05); display: flex; flex-direction: column; }
|
||||
.service small { color: #64748b; font-size: 0.7rem; margin-top: 4px; }
|
||||
.comm-lines { margin-top: 1rem; font-size: 0.8rem; color: #94a3b8; text-align: center; border-top: 1px dashed #cbd5e1; width: 80%; padding-top: 4px; }
|
||||
|
||||
.function-cloud { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; margin-bottom: 1.5rem; }
|
||||
.func-node { background: #fef3c7; border: 1px solid #f59e0b; color: #b45309; padding: 6px 12px; border-radius: 20px; font-family: monospace; font-size: 0.8rem; }
|
||||
.baas-layer { width: 100%; background: #e0e7ff; padding: 0.5rem; text-align: center; border-radius: 6px; font-size: 0.8rem; color: #4338ca; font-weight: bold; }
|
||||
|
||||
/* Ops Card */
|
||||
.ops-card { text-align: center; }
|
||||
.ops-icon { font-size: 4rem; margin-bottom: 1rem; }
|
||||
.ops-title { font-size: 1.2rem; font-weight: bold; margin-bottom: 0.5rem; color: var(--vp-c-text-1); }
|
||||
.ops-desc { font-size: 0.9rem; color: var(--vp-c-text-2); line-height: 1.5; }
|
||||
|
||||
/* Transitions */
|
||||
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.4s ease; }
|
||||
.fade-slide-enter-from { opacity: 0; transform: translateY(20px); }
|
||||
.fade-slide-leave-to { opacity: 0; transform: translateY(-20px); }
|
||||
</style>
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="monolith-microservice-demo">
|
||||
<div class="controls">
|
||||
<button class="action-btn crash-btn" @click="triggerCrash">💥 Simulate Order Service Crash</button>
|
||||
<button class="action-btn reset-btn" @click="reset">🔄 Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="comparison-view">
|
||||
<!-- Monolith -->
|
||||
<div class="architecture-block monolith">
|
||||
<div class="arch-header">Monolith Architecture</div>
|
||||
<div class="server-container" :class="{ crashed: monolithCrashed }">
|
||||
<div class="process-box">
|
||||
<div class="module user">User</div>
|
||||
<div class="module order" :class="{ error: monolithCrashed }">Order</div>
|
||||
<div class="module pay">Payment</div>
|
||||
</div>
|
||||
<div class="status-indicator">
|
||||
Status: {{ monolithCrashed ? 'SYSTEM DOWN (Critical Failure)' : 'Healthy' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="desc">
|
||||
One process. If "Order" module has a memory leak or fatal error, <strong>the entire server crashes</strong>. Everyone is affected.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Microservices -->
|
||||
<div class="architecture-block microservices">
|
||||
<div class="arch-header">Microservices Architecture</div>
|
||||
<div class="services-container">
|
||||
<div class="service-box user">
|
||||
<span>User Svc</span>
|
||||
<div class="dot green"></div>
|
||||
</div>
|
||||
<div class="service-box order" :class="{ crashed: microCrashed }">
|
||||
<span>Order Svc</span>
|
||||
<div class="dot" :class="microCrashed ? 'red' : 'green'"></div>
|
||||
</div>
|
||||
<div class="service-box pay">
|
||||
<span>Payment Svc</span>
|
||||
<div class="dot green"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-indicator">
|
||||
Status: {{ microCrashed ? 'Partial Outage (Order Down)' : 'Healthy' }}
|
||||
</div>
|
||||
<div class="desc">
|
||||
Isolated processes. If "Order" crashes, User and Payment services <strong>keep running</strong>. The system degrades gracefully.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const monolithCrashed = ref(false)
|
||||
const microCrashed = ref(false)
|
||||
|
||||
const triggerCrash = () => {
|
||||
monolithCrashed.value = true
|
||||
microCrashed.value = true
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
monolithCrashed.value = false
|
||||
microCrashed.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monolith-microservice-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.crash-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.crash-btn:hover { background: #dc2626; }
|
||||
|
||||
.reset-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comparison-view {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.comparison-view { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.architecture-block {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arch-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* Monolith Visuals */
|
||||
.server-container {
|
||||
border: 2px solid #3b82f6;
|
||||
background: #eff6ff;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.server-container.crashed {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
animation: shake 0.5s;
|
||||
}
|
||||
|
||||
.process-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #93c5fd;
|
||||
}
|
||||
|
||||
.module {
|
||||
background: #dbeafe;
|
||||
padding: 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.module.error {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Microservices Visuals */
|
||||
.services-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.service-box {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.service-box.crashed {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.green { background: #22c55e; box-shadow: 0 0 4px #22c55e; }
|
||||
.dot.red { background: #ef4444; box-shadow: 0 0 4px #ef4444; }
|
||||
|
||||
.status-indicator {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% { transform: translate(1px, 1px) rotate(0deg); }
|
||||
10% { transform: translate(-1px, -2px) rotate(-1deg); }
|
||||
20% { transform: translate(-3px, 0px) rotate(1deg); }
|
||||
30% { transform: translate(3px, 2px) rotate(0deg); }
|
||||
40% { transform: translate(1px, -1px) rotate(1deg); }
|
||||
50% { transform: translate(-1px, 2px) rotate(-1deg); }
|
||||
60% { transform: translate(-3px, 1px) rotate(0deg); }
|
||||
70% { transform: translate(3px, 1px) rotate(-1deg); }
|
||||
80% { transform: translate(-1px, -1px) rotate(1deg); }
|
||||
90% { transform: translate(1px, 2px) rotate(0deg); }
|
||||
100% { transform: translate(1px, -2px) rotate(-1deg); }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
+457
@@ -0,0 +1,457 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
|
||||
const activeTab = ref('elements')
|
||||
const selectedNode = ref('h1') // 'container', 'h1', 'button'
|
||||
|
||||
// Live State for the Virtual Page
|
||||
const liveStyles = reactive({
|
||||
container: {
|
||||
backgroundColor: '#f9f9f9',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||
},
|
||||
h1: {
|
||||
color: '#2c3e50',
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
marginBottom: '16px',
|
||||
marginTop: '0px',
|
||||
fontFamily: 'sans-serif'
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#42b983',
|
||||
color: '#ffffff',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 24px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
|
||||
const liveContent = reactive({
|
||||
h1: 'Hello, Easy Vibe!',
|
||||
button: 'Click Me'
|
||||
})
|
||||
|
||||
// Presets Definition
|
||||
const stylePresets = {
|
||||
h1: [
|
||||
{ name: '默认样式 (Default)', style: { color: '#2c3e50', fontSize: '28px', fontFamily: 'sans-serif', fontWeight: '700' } },
|
||||
{ name: '活力红 (Vibrant Red)', style: { color: '#e74c3c', fontSize: '36px', fontFamily: 'serif', fontWeight: '800' } },
|
||||
{ name: '科技蓝 (Tech Blue)', style: { color: '#3498db', fontSize: '32px', fontFamily: 'monospace', fontWeight: '500' } },
|
||||
{ name: '优雅紫 (Elegant Purple)', style: { color: '#9b59b6', fontSize: '24px', fontFamily: 'cursive', fontWeight: '400' } }
|
||||
],
|
||||
button: [
|
||||
{ name: '默认样式 (Default)', style: { backgroundColor: '#42b983', color: '#ffffff', borderRadius: '6px', border: 'none' } },
|
||||
{ name: '警告风格 (Warning)', style: { backgroundColor: '#f1c40f', color: '#333333', borderRadius: '24px', border: '2px solid #e67e22' } },
|
||||
{ name: '幽灵按钮 (Ghost)', style: { backgroundColor: 'transparent', color: '#42b983', borderRadius: '6px', border: '1px solid #42b983' } },
|
||||
{ name: '深黑按钮 (Dark)', style: { backgroundColor: '#34495e', color: '#ecf0f1', borderRadius: '2px', border: '1px solid #2c3e50' } }
|
||||
],
|
||||
container: [
|
||||
{ name: '默认卡片 (Card)', style: { backgroundColor: '#f9f9f9', borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.05)' } },
|
||||
{ name: '深色模式 (Dark)', style: { backgroundColor: '#2c3e50', borderRadius: '8px', boxShadow: '0 8px 16px rgba(0,0,0,0.2)' } },
|
||||
{ name: '极简白 (Minimal)', style: { backgroundColor: '#ffffff', borderRadius: '0px', boxShadow: 'none' } }
|
||||
]
|
||||
}
|
||||
|
||||
// Helper to get current styles for the selected node
|
||||
const currentStyles = computed(() => {
|
||||
return liveStyles[selectedNode.value] || {}
|
||||
})
|
||||
|
||||
// Helper for presets
|
||||
const availablePresets = computed(() => {
|
||||
return stylePresets[selectedNode.value] || []
|
||||
})
|
||||
|
||||
const applyPreset = (event) => {
|
||||
const presetName = event.target.value
|
||||
const preset = availablePresets.value.find(p => p.name === presetName)
|
||||
if (preset) {
|
||||
Object.assign(liveStyles[selectedNode.value], preset.style)
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs definition
|
||||
const tabs = [
|
||||
{ id: 'elements', label: '元素' },
|
||||
{ id: 'console', label: '控制台' },
|
||||
{ id: 'sources', label: '源代码' },
|
||||
{ id: 'network', label: '网络' },
|
||||
{ id: 'application', label: '应用' }
|
||||
]
|
||||
|
||||
const selectNode = (node) => {
|
||||
selectedNode.value = node
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="live-demo-wrapper">
|
||||
<!-- Virtual Web Page Preview -->
|
||||
<div class="virtual-page-container">
|
||||
<div class="virtual-browser-bar">
|
||||
<div class="dots">
|
||||
<span class="dot red"></span>
|
||||
<span class="dot yellow"></span>
|
||||
<span class="dot green"></span>
|
||||
</div>
|
||||
<div class="address-bar">http://localhost:3000/demo</div>
|
||||
</div>
|
||||
<div class="virtual-page-content" :style="liveStyles.container" @click.self="selectNode('container')">
|
||||
<h1 :style="liveStyles.h1" @click.stop="selectNode('h1')">{{ liveContent.h1 }}</h1>
|
||||
<button :style="liveStyles.button" @click.stop="selectNode('button')">{{ liveContent.button }}</button>
|
||||
</div>
|
||||
<div class="instruction-overlay">
|
||||
👆 点击上方元素,下方 DevTools 实时联动
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DevTools Simulator -->
|
||||
<div class="browser-devtools-demo">
|
||||
<!-- Header -->
|
||||
<div class="devtools-header">
|
||||
<div class="header-left">
|
||||
<div class="icon-btn element-picker" title="选择页面中的元素以进行检查">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#6e6e6e"><path d="M4 4h9v2H4V4zm0 4h5v2H4V8zm0 4h5v2H4v-2zm12-5l-4 4h3v4h2v-4h3l-4-4z"/></svg>
|
||||
</div>
|
||||
<div class="icon-btn device-toggle" title="切换设备工具栏">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#6e6e6e"><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/></svg>
|
||||
</div>
|
||||
<div class="separator"></div>
|
||||
<div class="tabs">
|
||||
<div v-for="tab in tabs" :key="tab.id" class="tab" :class="{ active: activeTab === tab.id }" @click="activeTab = tab.id">
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="icon-btn settings">⚙️</div>
|
||||
<div class="icon-btn close">×</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="devtools-body">
|
||||
|
||||
<!-- Elements Panel -->
|
||||
<div v-if="activeTab === 'elements'" class="panel elements-panel">
|
||||
<div class="dom-tree-panel">
|
||||
<div class="dom-tree-content">
|
||||
<div class="dom-node" @click="selectNode('container')" :class="{ selected: selectedNode === 'container' }">
|
||||
<div class="line-content">
|
||||
<span class="arrow expanded">▼</span>
|
||||
<span class="tag-name">div</span>
|
||||
<span class="attr-name">class</span>=<span class="attr-val">"virtual-page-content"</span>
|
||||
<span class="node-trail" v-if="selectedNode === 'container'">== $0</span>
|
||||
</div>
|
||||
<div class="children">
|
||||
<div class="dom-node" @click.stop="selectNode('h1')" :class="{ selected: selectedNode === 'h1' }">
|
||||
<div class="line-content">
|
||||
<span class="indent"></span>
|
||||
<span class="tag-name">h1</span>
|
||||
<span class="node-trail" v-if="selectedNode === 'h1'">== $0</span>
|
||||
</div>
|
||||
<div class="line-content">
|
||||
<span class="indent"></span>
|
||||
<input v-model="liveContent.h1" class="dom-text-input" @click.stop="selectNode('h1')" />
|
||||
</div>
|
||||
<div class="line-content"><span class="indent"></span><span class="tag-name">/h1</span></div>
|
||||
</div>
|
||||
<div class="dom-node" @click.stop="selectNode('button')" :class="{ selected: selectedNode === 'button' }">
|
||||
<div class="line-content">
|
||||
<span class="indent"></span>
|
||||
<span class="tag-name">button</span>
|
||||
<span class="node-trail" v-if="selectedNode === 'button'">== $0</span>
|
||||
</div>
|
||||
<div class="line-content">
|
||||
<span class="indent"></span>
|
||||
<input v-model="liveContent.button" class="dom-text-input" @click.stop="selectNode('button')" />
|
||||
</div>
|
||||
<div class="line-content"><span class="indent"></span><span class="tag-name">/button</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line-content"><span class="tag-name">/div</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="breadcrumbs">
|
||||
html > body > div.virtual-page-content {{ selectedNode !== 'container' ? '> ' + selectedNode : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interactive Styles Panel -->
|
||||
<div class="styles-panel">
|
||||
<div class="styles-tabs">
|
||||
<div class="style-tab active">样式 (Styles)</div>
|
||||
<div class="style-tab">计算 (Computed)</div>
|
||||
</div>
|
||||
<div class="styles-content">
|
||||
<!-- Preset Selector -->
|
||||
<div class="style-section" v-if="availablePresets.length > 0">
|
||||
<div class="style-section-title">✨ 快速预设 (Presets)</div>
|
||||
<select class="preset-select" @change="applyPreset">
|
||||
<option value="" disabled selected>选择一种风格 (Select Preset)...</option>
|
||||
<option v-for="preset in availablePresets" :key="preset.name" :value="preset.name">
|
||||
{{ preset.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- CSS Properties -->
|
||||
<div class="style-rule">
|
||||
<div class="selector">element.style {</div>
|
||||
<div class="property" v-for="(val, key) in currentStyles" :key="key">
|
||||
<span class="prop-name">{{ key }}</span>:
|
||||
<input v-model="liveStyles[selectedNode][key]" class="style-input" />
|
||||
;
|
||||
</div>
|
||||
<div class="selector">}</div>
|
||||
</div>
|
||||
<div class="style-add-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Panels (Simplified placeholders) -->
|
||||
<div v-else class="panel placeholder-panel">
|
||||
<div class="placeholder-text">此演示主要展示 Elements 面板的实时编辑功能。请切换回 "元素" 面板。</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.live-demo-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Virtual Page Preview */
|
||||
.virtual-page-container {
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.virtual-browser-bar {
|
||||
background: #f1f3f4;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dots { display: flex; gap: 8px; }
|
||||
.dot { width: 12px; height: 12px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.1); }
|
||||
.dot.red { background: #ff5f56; }
|
||||
.dot.yellow { background: #ffbd2e; }
|
||||
.dot.green { background: #27c93f; }
|
||||
|
||||
.address-bar {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: #5f6368;
|
||||
border: 1px solid #e0e0e0;
|
||||
text-align: center;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.virtual-page-content {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
background-image: radial-gradient(#e1e1e1 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.instruction-overlay {
|
||||
background: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
font-size: 12px;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #d2e3fc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* DevTools Simulator (Enhanced) */
|
||||
.browser-devtools-demo {
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
color: #202124;
|
||||
font-family: 'Segoe UI', '.SFNSDisplay', 'Roboto', sans-serif;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 320px;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Header & Tabs */
|
||||
.devtools-header {
|
||||
background-color: #f3f3f3;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.header-left, .header-right { display: flex; align-items: center; height: 100%; }
|
||||
.icon-btn {
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
color: #6e6e6e;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.icon-btn:hover { color: #202124; background-color: #eaeaea; }
|
||||
.separator { width: 1px; height: 16px; background-color: #ccc; margin: 0 8px; }
|
||||
|
||||
.tabs { display: flex; height: 100%; overflow-x: auto; }
|
||||
.tab {
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #5f6368;
|
||||
border-bottom: 2px solid transparent;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover { background-color: #e8eaed; color: #202124; }
|
||||
.tab.active { color: #1a73e8; border-bottom: 2px solid #1a73e8; font-weight: 500; }
|
||||
|
||||
.devtools-body { flex: 1; display: flex; overflow: hidden; background-color: #fff; }
|
||||
.panel { flex: 1; display: flex; overflow: hidden; width: 100%; }
|
||||
|
||||
/* Elements Panel */
|
||||
.elements-panel { display: flex; flex-direction: row; }
|
||||
.dom-tree-panel {
|
||||
flex: 3;
|
||||
border-right: 1px solid #d0d7de;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 6px 0;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.dom-node {
|
||||
padding-left: 14px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dom-node.selected { background-color: #cfe8fc; }
|
||||
.dom-node:hover:not(.selected) { background-color: #f0f4f8; }
|
||||
|
||||
.line-content { display: flex; align-items: center; }
|
||||
.node-trail { color: #5f6368; margin-left: 6px; }
|
||||
.arrow { color: #5f6368; font-size: 10px; display: inline-block; width: 14px; margin-left: -14px; text-align: center; }
|
||||
.tag-name { color: #a90d91; }
|
||||
.attr-name { color: #994500; margin-left: 4px; }
|
||||
.attr-val { color: #1a1aa6; }
|
||||
.text-node { color: #222; }
|
||||
.indent { display: inline-block; width: 14px; }
|
||||
|
||||
.breadcrumbs {
|
||||
border-top: 1px solid #ccc;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: #5f6368;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.styles-panel { flex: 2; display: flex; flex-direction: column; background: #fff; border-left: 1px solid #d0d7de; min-width: 280px; }
|
||||
.styles-tabs { display: flex; background: #f1f3f4; border-bottom: 1px solid #ccc; height: 26px; }
|
||||
.style-tab { padding: 0 12px; display: flex; align-items: center; color: #5f6368; cursor: pointer; border-bottom: 2px solid transparent; font-size: 11px; }
|
||||
.style-tab:hover { background-color: #e8eaed; color: #202124; }
|
||||
.style-tab.active { color: #1a73e8; border-bottom: 2px solid #1a73e8; font-weight: 500; }
|
||||
.styles-content { padding: 0; overflow: auto; background: #fff; flex: 1; }
|
||||
|
||||
.style-section { padding: 8px 12px; border-bottom: 1px solid #eee; background: #fafafa; }
|
||||
.style-section-title { font-weight: 600; color: #5f6368; margin-bottom: 6px; font-size: 11px; text-transform: uppercase; }
|
||||
.preset-select { width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 11px; }
|
||||
|
||||
.content-editor-row { font-family: Consolas, Menlo, monospace; font-size: 11px; display: flex; align-items: center; }
|
||||
.content-input {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: #222;
|
||||
flex: 1;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.content-input:focus { border-color: #1a73e8; outline: none; }
|
||||
|
||||
.style-rule { margin-bottom: 8px; font-family: Consolas, Menlo, monospace; font-size: 11px; border-bottom: 1px solid #eee; padding: 8px 12px; }
|
||||
.selector { color: #a90d91; font-weight: bold; }
|
||||
.property { padding-left: 16px; display: flex; align-items: center; margin-bottom: 2px; line-height: 1.6; }
|
||||
.prop-name { color: #c80000; margin-right: 4px; }
|
||||
/* CSS Properties Input Styling */
|
||||
.style-input {
|
||||
border: 1px solid transparent;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: #1a1aa6;
|
||||
width: 140px;
|
||||
background: transparent;
|
||||
padding: 0 2px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.style-input:hover { border: 1px solid #ccc; background: #fff; }
|
||||
.style-input:focus { outline: none; border: 1px solid #ccc; background: #fff; box-shadow: 0 0 0 1px #e0e0e0; }
|
||||
|
||||
/* DOM Text Input Styling */
|
||||
.dom-text-input {
|
||||
border: 1px solid transparent;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #222;
|
||||
background: transparent;
|
||||
padding: 0 2px;
|
||||
margin-left: -2px;
|
||||
width: 200px; /* Give it enough space */
|
||||
}
|
||||
.dom-text-input:hover { border: 1px solid #ccc; background: #fff; }
|
||||
.dom-text-input:focus { outline: none; border: 1px solid #ccc; background: #fff; box-shadow: 0 0 0 1px #e0e0e0; }
|
||||
|
||||
.placeholder-panel {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,58 +1,121 @@
|
||||
<template>
|
||||
<div class="three-areas-demo">
|
||||
<div class="panel">
|
||||
<div class="areas-container">
|
||||
<div class="area working">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">💻</span>
|
||||
<span class="area-name">工作区</span>
|
||||
<span class="area-count">{{ workingFiles.length }}</span>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div v-for="file in workingFiles" :key="file.name" class="file-item">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<button @click="addToStaging(file)" class="mini-btn">+</button>
|
||||
</div>
|
||||
<div v-if="workingFiles.length === 0" class="empty">无文件</div>
|
||||
<div class="scene">
|
||||
<!-- 1. Working Directory (Desk) -->
|
||||
<div class="zone working">
|
||||
<div class="zone-header">
|
||||
<span class="zone-icon">💻</span>
|
||||
<div class="zone-info">
|
||||
<span class="zone-title">工作区 (Desk)</span>
|
||||
<span class="zone-desc">你的书桌,随便乱放</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="area staging">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">📋</span>
|
||||
<span class="area-name">暂存区</span>
|
||||
<span class="area-count">{{ stagedFiles.length }}</span>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div v-for="file in stagedFiles" :key="file.name" class="file-item">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<button @click="commitFile(file)" class="mini-btn">✓</button>
|
||||
<div class="desk-surface">
|
||||
<transition-group name="file-pop">
|
||||
<div
|
||||
v-for="file in workingFiles"
|
||||
:key="file.id"
|
||||
class="file-card"
|
||||
@click="addToStaging(file)"
|
||||
>
|
||||
<div class="file-icon">{{ file.icon }}</div>
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="action-hint">Add +</div>
|
||||
</div>
|
||||
<div v-if="stagedFiles.length === 0" class="empty">无文件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="area repo">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">📦</span>
|
||||
<span class="area-name">仓库</span>
|
||||
<span class="area-count">{{ commits.length }}</span>
|
||||
</div>
|
||||
<div class="commit-list">
|
||||
<div v-for="commit in commits.slice(-3).reverse()" :key="commit.hash" class="commit-item">
|
||||
<span class="commit-hash">{{ commit.hash.substring(0, 6) }}</span>
|
||||
<span class="commit-msg">{{ commit.message }}</span>
|
||||
</div>
|
||||
<div v-if="commits.length === 0" class="empty">无提交</div>
|
||||
</transition-group>
|
||||
<div v-if="workingFiles.length === 0" class="empty-state">
|
||||
桌上很干净 ✨
|
||||
<button class="create-btn" @click="createNewFile">新建文件 📝</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 三区工作流:</strong> 工作区修改 → 添加到暂存区 → 提交到仓库</p>
|
||||
<!-- Arrow -->
|
||||
<div class="flow-arrow">
|
||||
<div class="arrow-line"></div>
|
||||
<div class="arrow-label">git add</div>
|
||||
<div class="arrow-head">▶</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Staging Area (Box) -->
|
||||
<div class="zone staging">
|
||||
<div class="zone-header">
|
||||
<span class="zone-icon">📦</span>
|
||||
<div class="zone-info">
|
||||
<span class="zone-title">暂存区 (Box)</span>
|
||||
<span class="zone-desc">快递盒,准备打包</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-container">
|
||||
<div class="box-body">
|
||||
<transition-group name="file-drop">
|
||||
<div
|
||||
v-for="file in stagedFiles"
|
||||
:key="file.id"
|
||||
class="file-card mini"
|
||||
@click="unstageFile(file)"
|
||||
>
|
||||
<div class="file-icon">{{ file.icon }}</div>
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="action-hint">Remove -</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div v-if="stagedFiles.length === 0" class="empty-state box-empty">
|
||||
盒子是空的 🕸️
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-flap left"></div>
|
||||
<div class="box-flap right"></div>
|
||||
</div>
|
||||
<div class="staging-actions">
|
||||
<button
|
||||
class="commit-btn"
|
||||
:disabled="stagedFiles.length === 0"
|
||||
@click="commitFiles"
|
||||
>
|
||||
封箱寄出 (git commit) 🚚
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flow-arrow">
|
||||
<div class="arrow-line"></div>
|
||||
<div class="arrow-label">git commit</div>
|
||||
<div class="arrow-head">▶</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Repository (Cabinet) -->
|
||||
<div class="zone repo">
|
||||
<div class="zone-header">
|
||||
<span class="zone-icon">🗄️</span>
|
||||
<div class="zone-info">
|
||||
<span class="zone-title">仓库 (Cabinet)</span>
|
||||
<span class="zone-desc">档案柜,永久保存</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cabinet-body">
|
||||
<transition-group name="drawer-slide">
|
||||
<div
|
||||
v-for="commit in commits.slice().reverse()"
|
||||
:key="commit.hash"
|
||||
class="drawer-item"
|
||||
>
|
||||
<div class="drawer-handle"></div>
|
||||
<div class="commit-info">
|
||||
<span class="commit-hash">#{{ commit.hash }}</span>
|
||||
<span class="commit-msg">{{ commit.message }}</span>
|
||||
</div>
|
||||
<div class="commit-files">
|
||||
<span v-for="f in commit.files" :key="f" class="tiny-file">📄</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div v-if="commits.length === 0" class="empty-state">
|
||||
柜子是空的 💨
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -60,145 +123,320 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const fileIdCounter = ref(1)
|
||||
|
||||
const createId = () => `file-${fileIdCounter.value++}`
|
||||
|
||||
const workingFiles = ref([
|
||||
{ name: 'index.vue' },
|
||||
{ name: 'style.css' }
|
||||
{ id: createId(), name: 'essay.txt', icon: '📝' },
|
||||
{ id: createId(), name: 'photo.jpg', icon: '🖼️' },
|
||||
{ id: createId(), name: 'style.css', icon: '🎨' }
|
||||
])
|
||||
|
||||
const stagedFiles = ref([])
|
||||
const commits = ref([])
|
||||
|
||||
const createNewFile = () => {
|
||||
const types = [
|
||||
{ name: 'script.js', icon: '📜' },
|
||||
{ name: 'data.json', icon: '📊' },
|
||||
{ name: 'readme.md', icon: '📘' }
|
||||
]
|
||||
const randomType = types[Math.floor(Math.random() * types.length)]
|
||||
workingFiles.value.push({
|
||||
id: createId(),
|
||||
name: randomType.name,
|
||||
icon: randomType.icon
|
||||
})
|
||||
}
|
||||
|
||||
const addToStaging = (file) => {
|
||||
const index = workingFiles.value.findIndex(f => f.name === file.name)
|
||||
const index = workingFiles.value.findIndex(f => f.id === file.id)
|
||||
if (index !== -1) {
|
||||
workingFiles.value.splice(index, 1)
|
||||
stagedFiles.value.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
const commitFile = (file) => {
|
||||
const index = stagedFiles.value.findIndex(f => f.name === file.name)
|
||||
const unstageFile = (file) => {
|
||||
const index = stagedFiles.value.findIndex(f => f.id === file.id)
|
||||
if (index !== -1) {
|
||||
stagedFiles.value.splice(index, 1)
|
||||
commits.value.push({
|
||||
hash: Math.random().toString(16).substr(2, 7),
|
||||
message: file.name
|
||||
})
|
||||
workingFiles.value.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
const commitFiles = () => {
|
||||
if (stagedFiles.value.length === 0) return
|
||||
|
||||
const files = [...stagedFiles.value]
|
||||
stagedFiles.value = []
|
||||
|
||||
const msgs = ['Fix bug', 'Add feature', 'Update docs', 'Refactor code', 'Initial commit']
|
||||
const randomMsg = msgs[Math.floor(Math.random() * msgs.length)]
|
||||
|
||||
commits.value.push({
|
||||
hash: Math.random().toString(16).substr(2, 6),
|
||||
message: randomMsg,
|
||||
files: files.map(f => f.name)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.three-areas-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.areas-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.area {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.area.working { border-color: #f59e0b; }
|
||||
.area.staging { border-color: #3b82f6; }
|
||||
.area.repo { border-color: #10b981; }
|
||||
|
||||
.area-header {
|
||||
.scene {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.area-icon { font-size: 1.25rem; }
|
||||
.area-name { flex: 1; font-weight: 600; }
|
||||
.area-count {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-list, .commit-list {
|
||||
/* Common Zone Styles */
|
||||
.zone {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
border: 2px solid transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 80px;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.file-item, .commit-item {
|
||||
.zone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.file-icon { font-size: 1rem; }
|
||||
.file-name { flex: 1; font-size: 0.875rem; }
|
||||
.zone-icon { font-size: 1.5rem; }
|
||||
.zone-info { display: flex; flex-direction: column; }
|
||||
.zone-title { font-weight: bold; font-size: 0.9rem; }
|
||||
.zone-desc { font-size: 0.7rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.mini-btn {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mini-btn:hover { background: var(--vp-c-brand); color: var(--vp-c-bg); }
|
||||
|
||||
.commit-hash {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.commit-msg {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-style: italic;
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
/* 1. Working Desk */
|
||||
.zone.working { border-color: #f59e0b; background: #fffbeb; }
|
||||
.desk-surface {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
|
||||
background-size: 10px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
width: 80px;
|
||||
height: 90px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
transform: translateY(-4px) rotate(2deg);
|
||||
box-shadow: 0 8px 12px rgba(0,0,0,0.1);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.file-icon { font-size: 2rem; margin-bottom: 4px; }
|
||||
.file-name { font-size: 0.7rem; text-align: center; word-break: break-all; line-height: 1.2; }
|
||||
.action-hint {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(245, 158, 11, 0.9);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.file-card:hover .action-hint { opacity: 1; }
|
||||
|
||||
.create-btn {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 2. Staging Box */
|
||||
.zone.staging { border-color: #3b82f6; background: #eff6ff; }
|
||||
.box-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.box-body {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
background: #dbeafe;
|
||||
border: 2px solid #3b82f6;
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-card.mini {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding: 4px 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
.file-card.mini .file-icon { font-size: 1rem; margin: 0; }
|
||||
.file-card.mini .file-name { font-size: 0.8rem; }
|
||||
.file-card.mini:hover { border-color: #ef4444; }
|
||||
.file-card.mini .action-hint { background: rgba(239, 68, 68, 0.9); }
|
||||
|
||||
.box-flap {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
width: 45%;
|
||||
height: 20px;
|
||||
background: #93c5fd;
|
||||
border: 2px solid #3b82f6;
|
||||
border-bottom: none;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
.box-flap.left { left: 0; border-radius: 4px 0 0 0; transform-origin: bottom left; transform: rotate(10deg); }
|
||||
.box-flap.right { right: 0; border-radius: 0 4px 0 0; transform-origin: bottom right; transform: rotate(-10deg); }
|
||||
|
||||
.staging-actions { margin-top: 12px; text-align: center; }
|
||||
.commit-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.commit-btn:disabled { background: #93c5fd; cursor: not-allowed; box-shadow: none; }
|
||||
.commit-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 8px rgba(59, 130, 246, 0.4); }
|
||||
|
||||
/* 3. Repo Cabinet */
|
||||
.zone.repo { border-color: #10b981; background: #ecfdf5; }
|
||||
.cabinet-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
background: white;
|
||||
border: 1px solid #10b981;
|
||||
border-radius: 4px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
box-shadow: 0 2px 0 #059669; /* 3D effect */
|
||||
position: relative;
|
||||
}
|
||||
.drawer-handle {
|
||||
width: 30px;
|
||||
height: 6px;
|
||||
background: #d1fae5;
|
||||
border: 1px solid #10b981;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.commit-info { flex: 1; display: flex; flex-direction: column; }
|
||||
.commit-hash { font-size: 0.6rem; color: #10b981; font-family: monospace; }
|
||||
.commit-msg { font-size: 0.8rem; font-weight: bold; }
|
||||
.commit-files { display: flex; gap: 2px; }
|
||||
.tiny-file { font-size: 0.6rem; }
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
/* Arrows */
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
.arrow-line { width: 100%; height: 2px; background: currentColor; }
|
||||
.arrow-label { font-size: 0.7rem; margin: 4px 0; font-weight: bold; white-space: nowrap; }
|
||||
.arrow-head { font-size: 0.8rem; }
|
||||
|
||||
/* Transitions */
|
||||
.file-pop-enter-active, .file-pop-leave-active { transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
|
||||
.file-pop-enter-from { opacity: 0; transform: scale(0.5); }
|
||||
.file-pop-leave-to { opacity: 0; transform: scale(0); }
|
||||
|
||||
.file-drop-enter-active, .file-drop-leave-active { transition: all 0.3s ease; }
|
||||
.file-drop-enter-from { opacity: 0; transform: translateY(-20px); }
|
||||
.file-drop-leave-to { opacity: 0; transform: translateX(20px); }
|
||||
|
||||
.drawer-slide-enter-active { transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
|
||||
.drawer-slide-enter-from { opacity: 0; transform: translateX(50px); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.areas-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.scene { flex-direction: column; min-width: auto; }
|
||||
.flow-arrow { transform: rotate(90deg); margin: 10px 0; width: 100%; align-items: center; }
|
||||
.arrow-line { width: 2px; height: 20px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
<template>
|
||||
<div class="ai-help-demo">
|
||||
<div class="desktop-container">
|
||||
<!-- 1. VS Code 窗口 (全功能模拟) -->
|
||||
<div class="window vscode" :class="getWindowClass('vscode')">
|
||||
<!-- 标题栏 -->
|
||||
<div class="title-bar">
|
||||
<div class="controls">
|
||||
<span class="dot red"></span>
|
||||
<span class="dot yellow"></span>
|
||||
<span class="dot green"></span>
|
||||
</div>
|
||||
<div class="title-text">App.vue - easy-vibe - Visual Studio Code</div>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<!-- 侧边栏 (Activity Bar) -->
|
||||
<div class="activity-bar">
|
||||
<div class="icon active">📁</div>
|
||||
<div class="icon">🔍</div>
|
||||
<div class="icon">🌿</div>
|
||||
<div class="icon">🐛</div>
|
||||
<div class="icon">🧩</div>
|
||||
</div>
|
||||
|
||||
<!-- 资源管理器 (Sidebar) -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-title">EXPLORER</div>
|
||||
<div class="file-tree">
|
||||
<div class="tree-item expanded">
|
||||
<span class="arrow">▼</span> src
|
||||
</div>
|
||||
<div class="tree-item indent">
|
||||
<span class="icon">📄</span> main.js
|
||||
</div>
|
||||
<div class="tree-item indent active">
|
||||
<span class="icon">V</span> App.vue
|
||||
</div>
|
||||
<div class="tree-item indent">
|
||||
<span class="icon">🎨</span> style.css
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器区域 -->
|
||||
<div class="editor-area">
|
||||
<div class="tab-bar">
|
||||
<div class="tab active">
|
||||
<span class="icon">V</span> App.vue <span class="close">×</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="code-content">
|
||||
<div class="line-numbers">
|
||||
<div v-for="n in 15" :key="n">{{ n }}</div>
|
||||
</div>
|
||||
<div class="code-lines">
|
||||
<div class="line"><span class="kwd">import</span> { <span class="var">ref</span>, <span class="var">onMounted</span>, <span class="var">nextTick</span> } <span class="kwd">from</span> <span class="str">'vue'</span></div>
|
||||
<div class="line"></div>
|
||||
<div class="line"><span class="kwd">const</span> <span class="var">chartRef</span> = <span class="func">ref</span>(<span class="kwd">null</span>)</div>
|
||||
<div class="line"><span class="kwd">const</span> <span class="var">data</span> = <span class="func">ref</span>([])</div>
|
||||
<div class="line"></div>
|
||||
<div class="line"><span class="kwd">const</span> <span class="func">initChart</span> = <span class="kwd">async</span> () => {</div>
|
||||
<div class="line"> <span class="comment">// 等待数据加载完成</span></div>
|
||||
<div class="line"> <span class="kwd">await</span> <span class="func">fetchData</span>()</div>
|
||||
<div class="line"> </div>
|
||||
<div class="line" ref="targetCode">
|
||||
<span class="comment">// 👈 等待 DOM 更新后再渲染图表</span>
|
||||
</div>
|
||||
<div class="line" ref="targetCode2">
|
||||
<span class="kwd">await</span> <span class="func">nextTick</span>()
|
||||
</div>
|
||||
<div class="line"> </div>
|
||||
<div class="line"> <span class="kwd">const</span> <span class="var">chart</span> = <span class="var">echarts</span>.<span class="func">init</span>(<span class="var">chartRef</span>.<span class="var">value</span>)</div>
|
||||
<div class="line"> <span class="var">chart</span>.<span class="func">setOption</span>({ ... })</div>
|
||||
<div class="line">}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 截图选框 (Overlay) - Moved to main-layout level -->
|
||||
<div class="screenshot-overlay" v-if="step === 'selecting' || step === 'captured'">
|
||||
<div class="selection-box" :class="{ flashed: step === 'captured' }">
|
||||
<div class="selection-handle top-left"></div>
|
||||
<div class="selection-handle top-right"></div>
|
||||
<div class="selection-handle bottom-left"></div>
|
||||
<div class="selection-handle bottom-right"></div>
|
||||
<div class="cursor-crosshair" v-if="step === 'selecting'"></div>
|
||||
<div class="selection-size" v-if="step === 'selecting'">220 × 350</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模拟操作引导 -->
|
||||
<div class="guide-overlay" v-if="step === 'idle'">
|
||||
<div class="start-btn" @click="startDemo">
|
||||
<span>📸 演示:遇到代码不懂怎么问 AI?</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. ChatGPT 窗口 -->
|
||||
<div class="window chatgpt" :class="getWindowClass('chatgpt')">
|
||||
<div class="chat-sidebar">
|
||||
<div class="new-chat">+ New chat</div>
|
||||
<div class="history-item">Previous 7 Days</div>
|
||||
<div class="history-item active">Vue nextTick explanation</div>
|
||||
<div class="history-item">CSS Grid layout</div>
|
||||
</div>
|
||||
<div class="chat-main">
|
||||
<div class="chat-model-selector">
|
||||
<span>GPT-4o</span> <span class="arrow">▼</span>
|
||||
</div>
|
||||
|
||||
<div class="messages-container" ref="messagesContainer">
|
||||
<div class="msg-row user" v-if="stepInt >= 5">
|
||||
<div class="avatar">U</div>
|
||||
<div class="msg-bubble">
|
||||
<div class="pasted-image" v-if="stepInt >= 5">
|
||||
<div class="ui-snapshot">
|
||||
<div class="snapshot-rect menu-rect"></div>
|
||||
<div class="snapshot-text">Menu Bar.png</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-text">{{ typedText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-row ai" v-if="stepInt >= 7">
|
||||
<div class="avatar gpt">
|
||||
<svg viewBox="0 0 41 41" class="gpt-logo"><path d="M37.532 16.87a9.963 9.963 0 00-.856-8.184c-3.15-5.49-10.25-7.38-15.738-4.23-.718.412-1.35.914-1.896 1.488a9.965 9.965 0 00-7.144-1.156 9.972 9.972 0 00-6.73 4.966c-3.15 5.49-1.26 12.59 4.23 15.738.412.237.854.43 1.306.586a9.963 9.963 0 00.856 8.184c3.15 5.49 10.25 7.38 15.738 4.23.718-.412 1.35-.914 1.896-1.488a9.965 9.965 0 007.144 1.156 9.972 9.972 0 006.73-4.966c3.15-5.49 1.26-12.59-4.23-15.738a9.953 9.953 0 00-1.306-.586zM20.5 29.5a9 9 0 110-18 9 9 0 010 18z" fill="currentColor"></path></svg>
|
||||
</div>
|
||||
<div class="msg-bubble ai-bubble">
|
||||
<p>这是 VS Code 的 <strong>顶部菜单栏 (Menu Bar)</strong>,包含了软件的所有功能入口。</p>
|
||||
<p><strong>常用菜单解释:</strong></p>
|
||||
<ul>
|
||||
<li><strong>File (文件)</strong>:新建、打开、保存文件或项目。</li>
|
||||
<li><strong>Edit (编辑)</strong>:复制粘贴、查找替换、撤销重做。</li>
|
||||
<li><strong>View (视图)</strong>:控制界面显示,比如打开侧边栏、终端等。</li>
|
||||
<li><strong>Terminal (终端)</strong>:打开内置命令行工具。</li>
|
||||
</ul>
|
||||
<p>💡 <strong>小技巧</strong>:如果不记得某个功能在哪,可以按 <code>F1</code> 或 <code>Ctrl+Shift+P</code> 打开命令面板直接搜索功能名字!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="input-wrapper">
|
||||
<div class="input-preview" v-if="stepInt === 4 || (stepInt === 5 && isTyping)">
|
||||
<div class="mini-snapshot-ui">
|
||||
<div class="mini-menu-rect"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fake-input">
|
||||
<span v-if="stepInt < 5" class="placeholder">Message ChatGPT...</span>
|
||||
<span v-else class="typing-text">{{ typedText }}<span class="cursor" v-if="isTyping">|</span></span>
|
||||
</div>
|
||||
<button class="send-btn" :class="{ active: typedText.length > 5 }">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全局重置按钮 -->
|
||||
<button class="reset-btn" v-if="step === 'finished'" @click="reset">
|
||||
🔄 重播
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 状态机
|
||||
// idle: 初始状态
|
||||
// selecting: 正在截图(选框出现)
|
||||
// captured: 截图完成(闪烁)
|
||||
// switching: 窗口切换中
|
||||
// pasting: ChatGPT 界面,显示输入动作
|
||||
// typing: 正在打字
|
||||
// sending: 发送中
|
||||
// responding: AI 回复中
|
||||
// finished: 结束
|
||||
const step = ref('idle')
|
||||
const typedText = ref('')
|
||||
const isTyping = ref(false)
|
||||
|
||||
const stepInt = computed(() => {
|
||||
const map = {
|
||||
idle: 0,
|
||||
selecting: 1,
|
||||
captured: 2,
|
||||
switching: 3,
|
||||
pasting: 4,
|
||||
typing: 5,
|
||||
sending: 6,
|
||||
responding: 7,
|
||||
finished: 8
|
||||
}
|
||||
return map[step.value] || 0
|
||||
})
|
||||
|
||||
const getWindowClass = (winName) => {
|
||||
if (winName === 'vscode') {
|
||||
if (stepInt.value < 3) return 'active'
|
||||
if (stepInt.value === 3) return 'inactive zoom-out'
|
||||
return 'inactive hidden'
|
||||
}
|
||||
if (winName === 'chatgpt') {
|
||||
if (stepInt.value < 3) return 'inactive hidden'
|
||||
if (stepInt.value === 3) return 'active zoom-in'
|
||||
return 'active'
|
||||
}
|
||||
}
|
||||
|
||||
const startDemo = async () => {
|
||||
step.value = 'selecting'
|
||||
|
||||
// 1. 模拟截图过程 (1.5s)
|
||||
await wait(1500)
|
||||
step.value = 'captured'
|
||||
|
||||
// 2. 截图闪烁确认 (0.5s)
|
||||
await wait(600)
|
||||
|
||||
// 3. 窗口切换 (0.8s)
|
||||
step.value = 'switching'
|
||||
await wait(800)
|
||||
|
||||
// 4. ChatGPT 界面准备 (粘贴动作)
|
||||
step.value = 'pasting'
|
||||
await wait(800)
|
||||
|
||||
// 5. 打字
|
||||
step.value = 'typing'
|
||||
isTyping.value = true
|
||||
const question = "帮我看下这张图,左边红框里那一块是干嘛用的?"
|
||||
for (let i = 0; i < question.length; i++) {
|
||||
typedText.value += question[i]
|
||||
await wait(50)
|
||||
}
|
||||
isTyping.value = false
|
||||
await wait(300)
|
||||
|
||||
// 6. 发送
|
||||
step.value = 'sending'
|
||||
await wait(500)
|
||||
|
||||
// 7. AI 回复
|
||||
step.value = 'responding'
|
||||
await wait(2500) // 模拟阅读时间
|
||||
|
||||
step.value = 'finished'
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
step.value = 'idle'
|
||||
typedText.value = ''
|
||||
}
|
||||
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-help-demo {
|
||||
margin: 40px 0;
|
||||
perspective: 1000px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.desktop-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px; /* 增加高度以容纳更多内容 */
|
||||
background: #333; /* 桌面背景 */
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* 通用窗口样式 */
|
||||
.window {
|
||||
position: absolute;
|
||||
top: 5%;
|
||||
left: 5%;
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 窗口状态动画 */
|
||||
.window.active {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
z-index: 2;
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.window.inactive {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.window.zoom-out {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.window.zoom-in {
|
||||
animation: zoomIn 0.5s forwards;
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from { transform: scale(1.1); opacity: 0; filter: blur(4px); }
|
||||
to { transform: scale(1); opacity: 1; filter: blur(0); }
|
||||
}
|
||||
|
||||
/* ================= VS Code 样式 ================= */
|
||||
.vscode {
|
||||
background: #1e1e1e;
|
||||
color: #ccc;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
height: 30px;
|
||||
background: #252526;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.controls { display: flex; gap: 6px; margin-right: 16px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.red { background: #ff5f56; }
|
||||
.yellow { background: #ffbd2e; }
|
||||
.green { background: #27c93f; }
|
||||
|
||||
.main-layout { flex: 1; display: flex; overflow: hidden; }
|
||||
|
||||
.activity-bar {
|
||||
width: 40px;
|
||||
background: #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
gap: 15px;
|
||||
}
|
||||
.activity-bar .icon { font-size: 18px; opacity: 0.5; filter: grayscale(1); }
|
||||
.activity-bar .icon.active { opacity: 1; border-left: 2px solid white; filter: grayscale(0); }
|
||||
|
||||
.sidebar {
|
||||
width: 180px;
|
||||
background: #252526;
|
||||
border-right: 1px solid #1e1e1e;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
.sidebar-title { padding: 10px; font-weight: bold; font-size: 11px; }
|
||||
.tree-item { padding: 4px 10px; display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.tree-item.active { background: #37373d; }
|
||||
.tree-item.indent { padding-left: 20px; }
|
||||
.tree-item .arrow { font-size: 10px; color: #999; }
|
||||
|
||||
.editor-area { flex: 1; display: flex; flex-direction: column; background: #1e1e1e; position: relative; }
|
||||
.tab-bar { height: 35px; background: #252526; display: flex; }
|
||||
.tab {
|
||||
background: #1e1e1e;
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid #007acc;
|
||||
}
|
||||
.tab .close { margin-left: 8px; font-size: 14px; }
|
||||
|
||||
.code-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.line-numbers {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
padding-right: 15px;
|
||||
color: #6e7681;
|
||||
user-select: none;
|
||||
}
|
||||
.code-lines { flex: 1; padding-left: 5px; }
|
||||
|
||||
/* 语法高亮 */
|
||||
.kwd { color: #569cd6; }
|
||||
.var { color: #9cdcfe; }
|
||||
.func { color: #dcdcaa; }
|
||||
.str { color: #ce9178; }
|
||||
.comment { color: #6a9955; }
|
||||
|
||||
/* 截图覆盖层 */
|
||||
.screenshot-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.3);
|
||||
z-index: 10;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selection-box {
|
||||
position: absolute;
|
||||
top: 40px; /* 覆盖左侧 Sidebar */
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 320px;
|
||||
border: 3px solid #ff5f56; /* 醒目的红框 */
|
||||
background: rgba(255, 95, 86, 0.1);
|
||||
box-shadow: 0 0 0 9999px rgba(0,0,0,0.5); /* 遮罩效果 */
|
||||
animation: selectAnim 0.5s ease-out;
|
||||
}
|
||||
|
||||
.selection-box.flashed {
|
||||
animation: flash 0.3s;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
@keyframes selectAnim {
|
||||
from { width: 0; height: 0; }
|
||||
to { width: 280px; height: 320px; }
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% { background: rgba(255, 255, 255, 0.8); }
|
||||
100% { background: rgba(255, 255, 255, 0.1); }
|
||||
}
|
||||
|
||||
.selection-size {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
right: 0;
|
||||
background: #ff5f56;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
/* UI Snapshot Styling */
|
||||
.ui-snapshot {
|
||||
background: #252526;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.snapshot-rect {
|
||||
width: 30px;
|
||||
height: 20px;
|
||||
border: 2px solid #ff5f56;
|
||||
background: rgba(255, 95, 86, 0.2);
|
||||
}
|
||||
.snapshot-text {
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.mini-snapshot-ui {
|
||||
width: 40px;
|
||||
height: 30px;
|
||||
background: #252526;
|
||||
border: 1px solid #565869;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mini-rect {
|
||||
width: 20px;
|
||||
height: 14px;
|
||||
border: 1px solid #ff5f56;
|
||||
}
|
||||
|
||||
/* ================= ChatGPT 样式 ================= */
|
||||
.chatgpt {
|
||||
background: #343541;
|
||||
color: #ececf1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 200px;
|
||||
background: #202123;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.new-chat {
|
||||
border: 1px solid #565869;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-item {
|
||||
font-size: 13px;
|
||||
color: #ececf1;
|
||||
padding: 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.history-item.active { background: #343541; }
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #343541;
|
||||
}
|
||||
|
||||
.chat-model-selector {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
font-weight: 600;
|
||||
color: #d2d6db;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
max-width: 80%;
|
||||
}
|
||||
.msg-row.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.msg-row.ai { align-self: flex-start; }
|
||||
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
.user .avatar { background: #5436da; color: white; border-radius: 50%; }
|
||||
.ai .avatar { background: #19c37d; color: white; }
|
||||
.gpt-logo { width: 20px; height: 20px; }
|
||||
|
||||
.msg-bubble {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.user .msg-bubble { text-align: right; }
|
||||
.ai-bubble {
|
||||
background: #444654;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.pasted-image {
|
||||
margin-bottom: 8px;
|
||||
display: inline-block;
|
||||
border: 1px solid #565869;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-snapshot {
|
||||
background: #1e1e1e;
|
||||
padding: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 20px;
|
||||
background-image: linear-gradient(180deg, rgba(53,55,64,0), #353740 50%);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
background: #40414f;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-preview {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 8px;
|
||||
background: #40414f;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #565869;
|
||||
animation: slideUp 0.3s;
|
||||
}
|
||||
|
||||
.fake-input {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.placeholder { color: #8e8ea0; }
|
||||
|
||||
.send-btn {
|
||||
background: #19c37d;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.send-btn.active { opacity: 1; }
|
||||
|
||||
/* 引导层 */
|
||||
.guide-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 100;
|
||||
}
|
||||
.start-btn {
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 12px 24px;
|
||||
border-radius: 30px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.start-btn:hover { transform: scale(1.05); }
|
||||
|
||||
.reset-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -1625,21 +1625,24 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
.tour-btn {
|
||||
background: #007acc;
|
||||
background: linear-gradient(135deg, #007acc 0%, #005999 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 204, 0.3);
|
||||
}
|
||||
|
||||
.tour-btn:hover {
|
||||
background: #005fa3;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(0, 122, 204, 0.4);
|
||||
}
|
||||
.tour-btn.stop {
|
||||
background: #e51400;
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
<h3 class="stage-title">Step 1: Tokenization (分词)</h3>
|
||||
<p class="stage-desc">
|
||||
计算机首先将文本切分为最小的语义单位(Token)。
|
||||
<span style="font-size: 0.85em; color: var(--vp-c-text-2); display: block; margin-top: 4px;">
|
||||
(注:此处演示简化为按字切分,真实模型通常使用 BPE 算法,如“人工智能”可能合并为一个 Token)
|
||||
</span>
|
||||
</p>
|
||||
<div class="token-container">
|
||||
<div
|
||||
@@ -177,7 +180,8 @@ const getTokenColor = (idx) => colors[idx % colors.length]
|
||||
const getHeatmapColor = (val) => {
|
||||
// val is -1 to 1
|
||||
// Map to blue (negative) -> white (0) -> red (positive)
|
||||
const opacity = Math.abs(val)
|
||||
// Reduce max opacity to avoid confusion with "selection" or "special token"
|
||||
const opacity = Math.abs(val) * 0.6 + 0.1
|
||||
if (val > 0) return `rgba(239, 68, 68, ${opacity})` // Red
|
||||
return `rgba(59, 130, 246, ${opacity})` // Blue
|
||||
}
|
||||
@@ -341,6 +345,7 @@ const getHeatmapColor = (val) => {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center; /* Add centering */
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,110 @@
|
||||
<template>
|
||||
<div class="attn-demo">
|
||||
<div class="controls">
|
||||
<span class="hint">🖱️ 把鼠标悬停在方块上,查看它的“注意力”分配</span>
|
||||
<div class="header">
|
||||
<div class="title">Self-Attention Mechanism</div>
|
||||
<div class="subtitle">自注意力机制:全局信息交互</div>
|
||||
</div>
|
||||
|
||||
<div class="visual-area">
|
||||
<div class="image-grid" @mouseleave="hoverIndex = -1">
|
||||
<div class="visual-stage">
|
||||
<!-- Grid Layout -->
|
||||
<div class="grid-container" @mouseleave="hoverIndex = -1">
|
||||
<!-- SVG Layer for Connection Lines -->
|
||||
<svg class="connections-layer">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="6" markerHeight="4" refX="18" refY="2" orient="auto">
|
||||
<polygon points="0 0, 6 2, 0 4" fill="var(--vp-c-brand)" opacity="0.6"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<!-- Draw lines from hoverIndex to ALL other nodes -->
|
||||
<g v-if="hoverIndex !== -1">
|
||||
<line
|
||||
v-for="(target, tIndex) in items"
|
||||
:key="tIndex"
|
||||
v-show="tIndex !== hoverIndex"
|
||||
:x1="getCenter(hoverIndex).x"
|
||||
:y1="getCenter(hoverIndex).y"
|
||||
:x2="getCenter(tIndex).x"
|
||||
:y2="getCenter(tIndex).y"
|
||||
:stroke="getLineColor(hoverIndex, tIndex)"
|
||||
:stroke-width="getLineWidth(hoverIndex, tIndex)"
|
||||
stroke-linecap="round"
|
||||
:opacity="getLineOpacity(hoverIndex, tIndex)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Cells -->
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="grid-cell"
|
||||
:class="{ active: hoverIndex === index }"
|
||||
:class="{
|
||||
'is-source': hoverIndex === index,
|
||||
'is-target': hoverIndex !== -1 && hoverIndex !== index,
|
||||
'is-strong-attn': hoverIndex !== -1 && getAttentionScore(hoverIndex, index) > 0.5
|
||||
}"
|
||||
@mouseenter="hoverIndex = index"
|
||||
:style="{
|
||||
left: getCenter(index).x - 30 + 'px',
|
||||
top: getCenter(index).y - 30 + 'px'
|
||||
}"
|
||||
>
|
||||
{{ item.icon }}
|
||||
<div class="cell-label">{{ item.label }}</div>
|
||||
<div class="cell-content">
|
||||
<span class="cell-icon">{{ item.icon }}</span>
|
||||
<span class="cell-label">{{ item.label }}</span>
|
||||
</div>
|
||||
<!-- Attention Score Badge -->
|
||||
<div
|
||||
class="attn-badge"
|
||||
v-if="hoverIndex !== -1 && hoverIndex !== index"
|
||||
:style="{ opacity: Math.max(0.3, getAttentionScore(hoverIndex, index)) }"
|
||||
>
|
||||
{{ (getAttentionScore(hoverIndex, index) * 100).toFixed(0) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SVG Overlay for lines -->
|
||||
<svg class="connections" v-if="hoverIndex !== -1">
|
||||
<line
|
||||
v-for="(target, tIndex) in items"
|
||||
:key="tIndex"
|
||||
v-if="tIndex !== hoverIndex"
|
||||
:x1="getCenter(hoverIndex).x"
|
||||
:y1="getCenter(hoverIndex).y"
|
||||
:x2="getCenter(tIndex).x"
|
||||
:y2="getCenter(tIndex).y"
|
||||
:stroke="getAttentionColor(hoverIndex, tIndex)"
|
||||
:stroke-width="getAttentionWidth(hoverIndex, tIndex)"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="info-panel" :class="{ visible: hoverIndex !== -1 }">
|
||||
<div class="info-title">Patch: {{ items[hoverIndex]?.label }}</div>
|
||||
<div class="info-desc">正在关注:</div>
|
||||
<ul class="attn-list" v-if="hoverIndex !== -1">
|
||||
<li
|
||||
v-for="(weight, targetIdx) in getTopAttentions(hoverIndex)"
|
||||
:key="targetIdx"
|
||||
>
|
||||
<span class="target-icon">{{ items[targetIdx].icon }}</span>
|
||||
<span class="target-name">{{ items[targetIdx].label }}</span>
|
||||
<div class="bar-bg">
|
||||
<div
|
||||
class="bar-fill"
|
||||
:style="{ width: weight * 100 + '%' }"
|
||||
></div>
|
||||
<!-- Info Panel -->
|
||||
<div class="info-panel">
|
||||
<div v-if="hoverIndex === -1" class="placeholder-text">
|
||||
<span class="cursor-icon">👆</span>
|
||||
把鼠标悬停在任意方块上,<br>观察它在"关注"谁
|
||||
</div>
|
||||
<div v-else class="active-info">
|
||||
<div class="source-info">
|
||||
<span class="label">当前 Patch:</span>
|
||||
<div class="patch-tag">
|
||||
{{ items[hoverIndex].icon }} {{ items[hoverIndex].label }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="attn-list">
|
||||
<div class="list-header">Attention Weights (注意力权重)</div>
|
||||
<div
|
||||
class="attn-item"
|
||||
v-for="(score, idx) in getTopAttentions(hoverIndex)"
|
||||
:key="idx"
|
||||
>
|
||||
<div class="item-left">
|
||||
<span class="item-icon">{{ items[idx].icon }}</span>
|
||||
<span class="item-name">{{ items[idx].label }}</span>
|
||||
</div>
|
||||
<div class="item-right">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: score * 100 + '%' }"></div>
|
||||
</div>
|
||||
<span class="score-text">{{ (score * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insight-box">
|
||||
<span class="bulb">💡</span>
|
||||
<span class="insight-text">
|
||||
{{ getInsightText(hoverIndex) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,207 +115,309 @@ import { ref } from 'vue'
|
||||
|
||||
const hoverIndex = ref(-1)
|
||||
|
||||
// 3x3 Grid Data (Cat in grass)
|
||||
const items = [
|
||||
{ icon: '🌲', label: '背景' },
|
||||
{ icon: '🌲', label: '背景' },
|
||||
{ icon: '☁️', label: '天空' },
|
||||
{ icon: '👂', label: '猫耳' },
|
||||
{ icon: '😼', label: '猫脸' },
|
||||
{ icon: '🌲', label: '背景' },
|
||||
{ icon: '🐾', label: '猫爪' },
|
||||
{ icon: '🧶', label: '毛线' },
|
||||
{ icon: '🌱', label: '草地' }
|
||||
{ icon: '🌿', label: '草地' }, // 0
|
||||
{ icon: '🌿', label: '草地' }, // 1
|
||||
{ icon: '🦋', label: '蝴蝶' }, // 2
|
||||
{ icon: '🌿', label: '草地' }, // 3
|
||||
{ icon: '🐱', label: '猫头' }, // 4
|
||||
{ icon: '🌿', label: '草地' }, // 5
|
||||
{ icon: '🧶', label: '毛球' }, // 6
|
||||
{ icon: '🐾', label: '猫爪' }, // 7
|
||||
{ icon: '🌿', label: '草地' } // 8
|
||||
]
|
||||
|
||||
// 3x3 Grid
|
||||
// Layout Logic
|
||||
const getCenter = (index) => {
|
||||
const row = Math.floor(index / 3)
|
||||
const col = index % 3
|
||||
// Assuming 80px cell + 10px gap
|
||||
const cellSize = 80
|
||||
const gap = 10
|
||||
const offset = cellSize / 2
|
||||
const gap = 100
|
||||
const offsetX = 50
|
||||
const offsetY = 50
|
||||
return {
|
||||
x: col * (cellSize + gap) + offset,
|
||||
y: row * (cellSize + gap) + offset
|
||||
x: col * gap + offsetX,
|
||||
y: row * gap + offsetY
|
||||
}
|
||||
}
|
||||
|
||||
// Mock attention weights
|
||||
const getAttentionWeight = (source, target) => {
|
||||
// Self attention is ignored for visualization clarity usually, but let's say:
|
||||
// Attention Logic
|
||||
const getAttentionScore = (source, target) => {
|
||||
if (source === target) return 0
|
||||
|
||||
// Cat Head (4) attends strongly to:
|
||||
if (source === 4) {
|
||||
if (target === 7) return 0.95 // Paws (Body parts connected)
|
||||
if (target === 2) return 0.8 // Butterfly (Interest)
|
||||
if (target === 6) return 0.6 // Yarn (Toy)
|
||||
return 0.1 // Background
|
||||
}
|
||||
|
||||
// Cat parts (3, 4, 6) attend strongly to each other
|
||||
const catParts = [3, 4, 6]
|
||||
const isSourceCat = catParts.includes(source)
|
||||
const isTargetCat = catParts.includes(target)
|
||||
// Cat Paws (7) attends strongly to:
|
||||
if (source === 7) {
|
||||
if (target === 4) return 0.95 // Head
|
||||
if (target === 6) return 0.9 // Yarn (Touching)
|
||||
return 0.1
|
||||
}
|
||||
|
||||
if (isSourceCat && isTargetCat) return 0.9 // Strong connection between cat parts
|
||||
// Butterfly (2)
|
||||
if (source === 2) {
|
||||
if (target === 4) return 0.7 // Danger?
|
||||
return 0.2
|
||||
}
|
||||
|
||||
// Cat interacts with Yarn (7)
|
||||
if (isSourceCat && target === 7) return 0.7
|
||||
if (source === 7 && isTargetCat) return 0.7
|
||||
// Grass (Background)
|
||||
// Background patches attend to each other for texture consistency
|
||||
const bgIndices = [0, 1, 3, 5, 8]
|
||||
if (bgIndices.includes(source)) {
|
||||
if (bgIndices.includes(target)) return 0.6
|
||||
return 0.05
|
||||
}
|
||||
|
||||
// Background parts attend to each other
|
||||
const bgParts = [0, 1, 2, 5, 8]
|
||||
if (bgParts.includes(source) && bgParts.includes(target)) return 0.5
|
||||
|
||||
return 0.1 // Weak attention otherwise
|
||||
// Default fallback
|
||||
return 0.1
|
||||
}
|
||||
|
||||
const getAttentionColor = (source, target) => {
|
||||
const weight = getAttentionWeight(source, target)
|
||||
// Green for strong, gray for weak
|
||||
if (weight > 0.6) return `rgba(16, 185, 129, ${weight})`
|
||||
return `rgba(156, 163, 175, ${weight * 0.5})`
|
||||
const getLineColor = (source, target) => {
|
||||
const score = getAttentionScore(source, target)
|
||||
return score > 0.5 ? 'var(--vp-c-brand)' : 'var(--vp-c-text-3)'
|
||||
}
|
||||
|
||||
const getAttentionWidth = (source, target) => {
|
||||
const weight = getAttentionWeight(source, target)
|
||||
return weight * 5
|
||||
const getLineWidth = (source, target) => {
|
||||
const score = getAttentionScore(source, target)
|
||||
return 1 + score * 4
|
||||
}
|
||||
|
||||
const getLineOpacity = (source, target) => {
|
||||
const score = getAttentionScore(source, target)
|
||||
return 0.2 + score * 0.8
|
||||
}
|
||||
|
||||
const getTopAttentions = (source) => {
|
||||
const weights = {}
|
||||
const scores = {}
|
||||
items.forEach((_, idx) => {
|
||||
if (idx !== source) {
|
||||
weights[idx] = getAttentionWeight(source, idx)
|
||||
scores[idx] = getAttentionScore(source, idx)
|
||||
}
|
||||
})
|
||||
// Sort by weight desc
|
||||
return weights
|
||||
// Sort descending
|
||||
const sortedKeys = Object.keys(scores).sort((a, b) => scores[b] - scores[a])
|
||||
const top3 = {}
|
||||
sortedKeys.slice(0, 3).forEach(key => {
|
||||
top3[key] = scores[key]
|
||||
})
|
||||
return top3
|
||||
}
|
||||
|
||||
const getInsightText = (idx) => {
|
||||
if (idx === 4) return "猫头最关注猫爪(组成身体)和蝴蝶(捕猎目标)。"
|
||||
if (idx === 7) return "猫爪最关注毛球(正在玩耍)和猫头。"
|
||||
if (idx === 2) return "蝴蝶关注到了猫,可能是因为它是个威胁。"
|
||||
if ([0,1,3,5,8].includes(idx)) return "草地主要关注周围的草地,确认背景纹理。"
|
||||
if (idx === 6) return "毛球和猫爪有很强的互动关系。"
|
||||
return "Self-Attention 让每个部分找到它的上下文关联。"
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attn-demo {
|
||||
padding: 20px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
user-select: none;
|
||||
font-family: 'Menlo', 'Monaco', sans-serif;
|
||||
}
|
||||
|
||||
.controls {
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.9em;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.visual-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 80px);
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-cell:hover,
|
||||
.grid-cell.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
background: var(--vp-c-bg-mute);
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
font-size: 0.8em;
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.connections {
|
||||
.visual-stage {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Grid Area */
|
||||
.grid-container {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
position: relative;
|
||||
/* background: rgba(0,0,0,0.02); */
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.connections-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.cell-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cell-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
font-size: 10px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Interaction States */
|
||||
.grid-cell:hover, .grid-cell.is-source {
|
||||
z-index: 10;
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.grid-cell.is-strong-attn {
|
||||
border-color: var(--vp-c-brand-light);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.attn-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Info Panel */
|
||||
.info-panel {
|
||||
width: 200px;
|
||||
width: 280px;
|
||||
min-height: 260px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info-panel.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
.placeholder-text {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: var(--vp-c-brand);
|
||||
.cursor-icon {
|
||||
font-size: 32px;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.info-desc {
|
||||
font-size: 0.85em;
|
||||
.source-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.patch-tag {
|
||||
background: var(--vp-c-brand-dimm);
|
||||
color: var(--vp-c-brand-dark);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.attn-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.attn-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.attn-list li {
|
||||
.item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.85em;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.target-icon {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
.item-icon { font-size: 16px; }
|
||||
.item-name { font-size: 12px; font-weight: 500; }
|
||||
|
||||
.item-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.target-name {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.bar-bg {
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
@@ -270,9 +425,50 @@ const getTopAttentions = (source) => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--vp-c-brand);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-2);
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.insight-box {
|
||||
margin-top: 15px;
|
||||
background: var(--vp-c-yellow-dimm);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bulb { font-size: 16px; }
|
||||
.insight-text {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.visual-stage {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
<div class="demo-container">
|
||||
<!-- Step 1: Patch -->
|
||||
<div class="step-box">
|
||||
<div class="label">1. Patch (4x4)</div>
|
||||
<div class="label">1. Patch (16×16×3) (示意 / Toy)</div>
|
||||
<div class="grid-patch">
|
||||
<div
|
||||
v-for="n in 16"
|
||||
v-for="n in patchCellCount"
|
||||
:key="n"
|
||||
class="pixel"
|
||||
:style="{ backgroundColor: getPixelColor(n) }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="desc">768 像素点</div>
|
||||
<div class="desc">16×16 像素 × 3 通道 = 768 标量值</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow">➜</div>
|
||||
@@ -22,13 +22,14 @@
|
||||
<div class="label">2. Flatten</div>
|
||||
<div class="vector-container">
|
||||
<div
|
||||
v-for="n in 16"
|
||||
v-for="n in flattenSampleCount"
|
||||
:key="n"
|
||||
class="vector-cell"
|
||||
:style="{ backgroundColor: getPixelColor(n) }"
|
||||
></div>
|
||||
<div class="vector-ellipsis">…</div>
|
||||
</div>
|
||||
<div class="desc">拉平成向量</div>
|
||||
<div class="desc">得到 1×768 向量 (Vector)</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow">× W</div>
|
||||
@@ -39,13 +40,16 @@
|
||||
<div class="embedding-container">
|
||||
<div v-for="n in 8" :key="n" class="embed-cell"></div>
|
||||
</div>
|
||||
<div class="desc">压缩特征 (D=8)</div>
|
||||
<div class="desc">映射到 D 维 (示意 D=8;常见 D=768)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const patchCellCount = 16 * 16
|
||||
const flattenSampleCount = 32
|
||||
|
||||
const getPixelColor = (n) => {
|
||||
// Generate a gradient of colors
|
||||
const hue = (n * 20) % 360
|
||||
@@ -89,8 +93,8 @@ const getPixelColor = (n) => {
|
||||
|
||||
.grid-patch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
grid-template-columns: repeat(16, 1fr);
|
||||
gap: 1px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
@@ -105,7 +109,7 @@ const getPixelColor = (n) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
height: 120px;
|
||||
height: 140px;
|
||||
width: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -115,6 +119,14 @@ const getPixelColor = (n) => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vector-ellipsis {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.embedding-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+273
-240
@@ -8,113 +8,135 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-label">
|
||||
<span :class="{ active: !isVLM }">Pure LLM</span>
|
||||
<span :class="{ active: !isVLM }">Pure LLM (纯文本)</span>
|
||||
<span class="arrow">→</span>
|
||||
<span :class="{ active: isVLM }">Multimodal VLM</span>
|
||||
<span :class="{ active: isVLM }">Multimodal VLM (多模态)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-desc">
|
||||
{{
|
||||
isVLM
|
||||
? '给大脑装上眼睛:视觉信号经过翻译,变成 Token 混入文字流。'
|
||||
: '纯文本大脑:只能听懂 Token 语言,无法感知图像。'
|
||||
? 'Tokens from vision are translated and placed before text tokens. (视觉信息被翻译成 Token,放在文字 Token 之前。)'
|
||||
: 'Text-only tokens flow into the LLM. (只有文字 Token 流入大模型。)'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diagram-stage" :class="{ 'vlm-mode': isVLM }">
|
||||
<!-- Vision Pipeline (Only visible in VLM mode) -->
|
||||
<div class="pipeline vision-pipeline">
|
||||
<div class="node-group">
|
||||
<div class="node input-node image-node">
|
||||
<span class="icon">�️</span>
|
||||
<span class="label">Image</span>
|
||||
</div>
|
||||
<div class="flow-arrow">⬇</div>
|
||||
<div
|
||||
class="node process-node vit-node"
|
||||
title="Vision Transformer: The Eye"
|
||||
>
|
||||
<span class="icon">�️</span>
|
||||
<span class="label">ViT</span>
|
||||
</div>
|
||||
<div class="flow-arrow">⬇</div>
|
||||
<div
|
||||
class="node adapter-node projector-node"
|
||||
title="Projector: The Translator"
|
||||
>
|
||||
<span class="icon">🔌</span>
|
||||
<span class="label">Projector</span>
|
||||
</div>
|
||||
<div class="flow-arrow connector-arrow">⤵</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Pipeline (Always visible) -->
|
||||
<div class="pipeline text-pipeline">
|
||||
<div class="node-group horizontal">
|
||||
<div class="node input-node text-node">
|
||||
<span class="icon">�</span>
|
||||
<span class="label">Prompt</span>
|
||||
</div>
|
||||
<div class="flow-arrow">➜</div>
|
||||
<div class="node process-node embed-node">
|
||||
<span class="icon">�</span>
|
||||
<span class="label">Embed</span>
|
||||
</div>
|
||||
|
||||
<!-- Merge Point Visualization -->
|
||||
<div class="merge-point" :class="{ active: isVLM }">
|
||||
<div class="plus-icon">+</div>
|
||||
<div class="merge-label">Concat</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow">➜</div>
|
||||
<div class="node core-node llm-node">
|
||||
<span class="icon">🧠</span>
|
||||
<span class="label">LLM Backbone</span>
|
||||
<div class="inner-flow">
|
||||
<span class="dot t1"></span>
|
||||
<span class="dot t2"></span>
|
||||
<span class="dot v1" v-if="isVLM"></span>
|
||||
<div class="diagram-stage">
|
||||
<div class="lanes">
|
||||
<div class="lane lane-vision" v-show="isVLM">
|
||||
<div class="lane-title">Vision Path (视觉路径)</div>
|
||||
<div class="lane-flow">
|
||||
<div class="node input-node">
|
||||
<span class="icon">🖼️</span>
|
||||
<span class="label">Image (图片)</span>
|
||||
</div>
|
||||
<span class="mini-arrow">→</span>
|
||||
<div class="node process-node vit-node">
|
||||
<span class="icon">👁️</span>
|
||||
<span class="label">ViT (视觉模型)</span>
|
||||
</div>
|
||||
<span class="mini-arrow">→</span>
|
||||
<div class="node adapter-node">
|
||||
<span class="icon">🔌</span>
|
||||
<span class="label">Projector (投影器)</span>
|
||||
</div>
|
||||
<span class="mini-arrow">→</span>
|
||||
<div class="token-box token-box-vision">
|
||||
<div class="token-box-title">Vision Tokens (视觉 Token)</div>
|
||||
<div class="tokens">
|
||||
<span class="token vision">v1</span>
|
||||
<span class="token vision">v2</span>
|
||||
<span class="token vision">v3</span>
|
||||
<span class="token vision">…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-arrow">➜</div>
|
||||
<div class="node output-node">
|
||||
<span class="icon">💬</span>
|
||||
<span class="label">Response</span>
|
||||
</div>
|
||||
|
||||
<div class="lane lane-text">
|
||||
<div class="lane-title">Text Path (文字路径)</div>
|
||||
<div class="lane-flow">
|
||||
<div class="node input-node">
|
||||
<span class="icon">⌨️</span>
|
||||
<span class="label">Prompt (提示词)</span>
|
||||
</div>
|
||||
<span class="mini-arrow">→</span>
|
||||
<div class="node process-node">
|
||||
<span class="icon">🔤</span>
|
||||
<span class="label">Embed (向量化)</span>
|
||||
</div>
|
||||
<span class="mini-arrow">→</span>
|
||||
<div class="token-box">
|
||||
<div class="token-box-title">Text Tokens (文字 Token)</div>
|
||||
<div class="tokens">
|
||||
<span class="token text">t1</span>
|
||||
<span class="token text">t2</span>
|
||||
<span class="token text">t3</span>
|
||||
<span class="token text">…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="merge-stage">
|
||||
<div class="merge-title">Token Sequence (输入序列)</div>
|
||||
<div class="sequence">
|
||||
<div v-if="isVLM" class="sequence-row">
|
||||
<span class="sequence-tag vision">Vision (视觉)</span>
|
||||
<div class="tokens">
|
||||
<span class="token vision">v1</span>
|
||||
<span class="token vision">v2</span>
|
||||
<span class="token vision">v3</span>
|
||||
<span class="token vision">…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sequence-row">
|
||||
<span class="sequence-tag text">Text (文字)</span>
|
||||
<div class="tokens">
|
||||
<span class="token text">t1</span>
|
||||
<span class="token text">t2</span>
|
||||
<span class="token text">t3</span>
|
||||
<span class="token text">…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sequence-hint">
|
||||
<span v-if="isVLM">Concat: [Vision Tokens] + [Text Tokens] (拼接:视觉在前,文字在后)</span>
|
||||
<span v-else>Only [Text Tokens] (只有文字 Token)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="core-stage">
|
||||
<span class="big-arrow">→</span>
|
||||
<div class="node core-node">
|
||||
<span class="icon">🧠</span>
|
||||
<span class="label">LLM Backbone (大模型)</span>
|
||||
</div>
|
||||
<span class="big-arrow">→</span>
|
||||
<div class="node output-node">
|
||||
<span class="icon">💬</span>
|
||||
<span class="label">Response (回复)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="interactive-info">
|
||||
<div class="info-card" v-if="!isVLM">
|
||||
<h3>Standard LLM Flow</h3>
|
||||
<p>
|
||||
Text is converted into vectors (Embeddings) and processed by the
|
||||
Transformer to predict the next word.
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-card vlm-info" v-else>
|
||||
<h3>VLM = LLM + Vision Encoder</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>ViT (The Eye):</strong> Slices image into patches and
|
||||
extracts features.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Projector (The Translator):</strong> Converts visual
|
||||
features into the same "language" (vector dimension) as text
|
||||
embeddings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Concatenation:</strong> The translated visual tokens are
|
||||
pasted <em>before</em> the text tokens. The LLM sees them as
|
||||
"foreign words" it learned to understand.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<transition name="fade" mode="out-in">
|
||||
<div class="info-card" v-if="!isVLM" key="llm">
|
||||
<h3>Standard LLM Flow (标准大模型流程)</h3>
|
||||
<p>Prompt → Embedding → Token Sequence → LLM → Response。</p>
|
||||
</div>
|
||||
<div class="info-card vlm-info" v-else key="vlm">
|
||||
<h3>VLM = LLM + Vision Encoder (视觉大模型原理)</h3>
|
||||
<ul>
|
||||
<li><strong>ViT (The Eye):</strong> 把图片编码成视觉特征。</li>
|
||||
<li><strong>Projector (The Translator):</strong> 把视觉特征映射到 LLM 的 Token 空间。</li>
|
||||
<li><strong>Concatenation (拼接):</strong> 把视觉 Token 放在文字 Token 之前,作为同一条输入序列。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,12 +162,11 @@ const toggleMode = () => {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 18px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -216,105 +237,160 @@ const toggleMode = () => {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-align: center;
|
||||
height: 20px;
|
||||
line-height: 1.5;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
/* Diagram Stage */
|
||||
.diagram-stage {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.lanes {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Pipelines */
|
||||
.pipeline {
|
||||
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.text-pipeline {
|
||||
position: absolute;
|
||||
bottom: 80px; /* Centered vertically in LLM mode */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vlm-mode .text-pipeline {
|
||||
bottom: 40px; /* Move down in VLM mode */
|
||||
}
|
||||
|
||||
.vision-pipeline {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20%; /* Align with input side */
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vlm-mode .vision-pipeline {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.node-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-group.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.vision-pipeline .node-group {
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.lane {
|
||||
background: var(--vp-c-bg-mute);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.lane-title {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lane-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.merge-stage {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.merge-title {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sequence {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.sequence-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sequence-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sequence-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.sequence-tag.vision {
|
||||
border-color: var(--vp-c-yellow);
|
||||
}
|
||||
|
||||
.sequence-tag.text {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.sequence-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.core-stage {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.big-arrow {
|
||||
font-size: 18px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mini-arrow {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Nodes */
|
||||
.node {
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 70px;
|
||||
min-width: 110px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.input-node {
|
||||
border-color: #aaa;
|
||||
}
|
||||
|
||||
.process-node {
|
||||
border-color: var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.core-node {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
min-width: 100px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.output-node {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
@@ -323,101 +399,64 @@ const toggleMode = () => {
|
||||
border-color: var(--vp-c-yellow);
|
||||
background: rgba(255, 197, 23, 0.05);
|
||||
}
|
||||
.projector-node {
|
||||
|
||||
.adapter-node {
|
||||
border-color: var(--vp-c-yellow);
|
||||
background: var(--vp-c-yellow-dimm);
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.flow-arrow {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 16px;
|
||||
.token-box {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.connector-arrow {
|
||||
font-size: 24px;
|
||||
color: var(--vp-c-yellow);
|
||||
margin-top: -10px;
|
||||
margin-bottom: -10px;
|
||||
transform: rotate(-45deg) translateX(10px);
|
||||
.token-box-vision {
|
||||
border-color: var(--vp-c-yellow);
|
||||
}
|
||||
|
||||
/* Merge Point */
|
||||
.merge-point {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.5s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.merge-point.active {
|
||||
width: 40px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.plus-icon {
|
||||
font-weight: bold;
|
||||
.token-box-title {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.merge-label {
|
||||
font-size: 9px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
/* Inner Flow Animation inside LLM */
|
||||
.inner-flow {
|
||||
.tokens {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
height: 6px;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
opacity: 0.6;
|
||||
animation: pulse 1s infinite alternate;
|
||||
.token {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.t1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.t2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.v1 {
|
||||
background: var(--vp-c-yellow);
|
||||
animation-delay: 0.4s;
|
||||
.token.vision {
|
||||
border-color: var(--vp-c-yellow);
|
||||
background: rgba(255, 197, 23, 0.12);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.token.text {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
/* Interactive Info */
|
||||
.interactive-info {
|
||||
margin-top: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--vp-c-bg-mute);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
@@ -439,31 +478,25 @@ const toggleMode = () => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.diagram-stage {
|
||||
height: 300px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.text-pipeline {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
width: 90%;
|
||||
.node {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.vision-pipeline {
|
||||
left: 10%;
|
||||
.token-box {
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,67 +6,137 @@
|
||||
<div class="patchify-demo">
|
||||
<div class="control-panel">
|
||||
<div class="controls">
|
||||
<button class="action-btn" @click="toggleState">
|
||||
{{ isPatchified ? '还原图片 (Restore)' : '切分图片 (Patchify)' }}
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="prevStep"
|
||||
:disabled="currentStep === 0"
|
||||
>
|
||||
⬅ 上一步 (Prev)
|
||||
</button>
|
||||
<div class="info">
|
||||
<span>Resolution: 224x224</span>
|
||||
<span>Patch Size: 16x16</span>
|
||||
<span>Total Patches: {{ 14 * 14 }}</span>
|
||||
</div>
|
||||
<span class="step-indicator">Step {{ currentStep + 1 }} / 4</span>
|
||||
<button
|
||||
class="action-btn primary"
|
||||
@click="nextStep"
|
||||
:disabled="currentStep === 3"
|
||||
>
|
||||
{{ currentStep === 3 ? '完成 (Done)' : '下一步 (Next) ➡' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="step-desc">
|
||||
{{ stepDescriptions[currentStep] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visual-area">
|
||||
<!-- 原始/切分视图容器 -->
|
||||
<div class="image-container" :class="{ 'is-patchified': isPatchified }">
|
||||
<!--
|
||||
Step 0: Show container background, cells hidden
|
||||
Step 1: Show container background, grid overlay visible (cells with border)
|
||||
Step 2+: Container background hidden, cells visible with individual backgrounds
|
||||
-->
|
||||
<div
|
||||
class="image-container"
|
||||
:class="{
|
||||
'is-pixelated': currentStep >= 1,
|
||||
'is-patchified': currentStep >= 2
|
||||
}"
|
||||
>
|
||||
<div class="grid-overlay" v-if="currentStep === 1"></div>
|
||||
<div
|
||||
v-for="n in 196"
|
||||
:key="n"
|
||||
class="patch"
|
||||
:style="{
|
||||
'--delay': `${n * 0.005}s`,
|
||||
'--hue': `${(n % 14) * 20 + Math.floor(n / 14) * 20}`
|
||||
}"
|
||||
:style="getPatchStyle(n)"
|
||||
>
|
||||
<span class="patch-id" v-if="isPatchified">{{ n }}</span>
|
||||
<!-- Show number only in Pixelated stage to represent 'digitization' -->
|
||||
<span class="pixel-val" v-if="currentStep === 1">{{ Math.floor(Math.random() * 9) }}</span>
|
||||
<!-- Show ID in Patchified stage -->
|
||||
<span class="patch-id" v-if="currentStep >= 2">{{ n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow" v-if="isPatchified">⬇</div>
|
||||
<div class="arrow-down" v-if="currentStep >= 3">⬇</div>
|
||||
|
||||
<!-- 线性序列视图 -->
|
||||
<div class="sequence-container" v-if="isPatchified">
|
||||
<div class="sequence-label">Flattened Sequence (Token Input)</div>
|
||||
<div class="sequence-container" v-if="currentStep >= 3">
|
||||
<div class="sequence-label">Token Sequence: 196×D (每个 Token 是 D 维向量)</div>
|
||||
<div class="token-stream">
|
||||
<div
|
||||
v-for="n in 196"
|
||||
:key="n"
|
||||
class="mini-patch"
|
||||
:style="{ '--hue': `${(n % 14) * 20 + Math.floor(n / 14) * 20}` }"
|
||||
:style="getMiniPatchStyle(n)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
计算机将图片切成 <strong>14x14 = 196</strong> 个小方块(Patch)。
|
||||
然后把这些方块“拉直”成一长串序列,就像把一段话里的单词排成一排一样。
|
||||
这就是 <strong>Visual Tokenization</strong>。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const isPatchified = ref(false)
|
||||
const currentStep = ref(0)
|
||||
|
||||
const toggleState = () => {
|
||||
isPatchified.value = !isPatchified.value
|
||||
const stepDescriptions = [
|
||||
"1. 原始图片 (Original Image): 计算机看到的原始输入。",
|
||||
"2. 数字化 (Digitization): 图片本质上是一个数字矩阵 (H x W x C)。",
|
||||
"3. 切块 (Patchify): 典型设置:224×224 按 16×16 切成 14×14=196 个 Patch(此处等比示意)。",
|
||||
"4. 序列化 (Serialize): 将二维分布的 Patch “拍扁”成一维序列 (Spatial Flatten)。现在它看起来就像一串“视觉单词”,可以被 Transformer 逐个读取。"
|
||||
]
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < 3) currentStep.value++
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) currentStep.value--
|
||||
}
|
||||
|
||||
// 模拟一张风景图的 CSS 渐变
|
||||
// Sky (Blue) -> Mountains (Green/Grey) -> Sun (Yellow)
|
||||
const bgImage = 'linear-gradient(to bottom, #87CEEB 0%, #87CEEB 50%, #228B22 50%, #228B22 100%)'
|
||||
// Add a sun using radial gradient
|
||||
const complexBg = 'radial-gradient(circle at 70% 20%, #FFD700 0%, #FFD700 10%, transparent 10.5%), linear-gradient(to bottom, #87CEEB 0%, #87CEEB 60%, #4CA1AF 60%, #2C3E50 100%)'
|
||||
|
||||
const getPatchStyle = (n) => {
|
||||
const row = Math.floor((n - 1) / 14)
|
||||
const col = (n - 1) % 14
|
||||
|
||||
// Calculate background position for each patch to match the original image
|
||||
// The container is 280px, each patch is 20px.
|
||||
// 14 cols.
|
||||
const posX = col * -20
|
||||
const posY = row * -20
|
||||
|
||||
const isPatchified = currentStep.value >= 2
|
||||
|
||||
return {
|
||||
backgroundImage: complexBg,
|
||||
backgroundPosition: `${posX}px ${posY}px`,
|
||||
backgroundSize: '280px 280px',
|
||||
// In Step 0, patches are hidden to show pure container background
|
||||
// In Step 1, patches are visible but transparent background to show numbers/borders over container background
|
||||
// In Step 2, patches take over with their own background
|
||||
opacity: currentStep.value === 0 ? 0 : 1,
|
||||
// In Step 1, background must be transparent to see container bg
|
||||
backgroundImage: isPatchified ? complexBg : 'none',
|
||||
transform: isPatchified ? 'scale(0.9)' : 'scale(1)',
|
||||
transition: 'all 0.5s ease',
|
||||
}
|
||||
}
|
||||
|
||||
const getMiniPatchStyle = (n) => {
|
||||
const row = Math.floor((n - 1) / 14)
|
||||
const col = (n - 1) % 14
|
||||
const posX = col * -20
|
||||
const posY = row * -20
|
||||
|
||||
return {
|
||||
backgroundImage: complexBg,
|
||||
backgroundPosition: `${posX}px ${posY}px`,
|
||||
backgroundSize: '280px 280px',
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -77,40 +147,68 @@ const toggleState = () => {
|
||||
padding: 20px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
margin: 20px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
.step-indicator {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 0.9em;
|
||||
color: var(--vp-c-text-1);
|
||||
text-align: center;
|
||||
background: var(--vp-c-bg-mute);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.info {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--vp-c-bg-mute);
|
||||
color: var(--vp-c-text-1);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9em;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn:not(:disabled):hover {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.visual-area {
|
||||
@@ -118,7 +216,7 @@ const toggleState = () => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
min-height: 300px;
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
@@ -126,31 +224,55 @@ const toggleState = () => {
|
||||
grid-template-columns: repeat(14, 1fr);
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
gap: 0;
|
||||
background: #333;
|
||||
/* Step 0 & 1 Background */
|
||||
background-image: radial-gradient(circle at 70% 20%, #FFD700 0%, #FFD700 10%, transparent 10.5%), linear-gradient(to bottom, #87CEEB 0%, #87CEEB 60%, #4CA1AF 60%, #2C3E50 100%);
|
||||
position: relative;
|
||||
transition: all 0.5s ease;
|
||||
border: 2px solid var(--vp-c-text-1);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Step 2+: Remove container background, let patches show */
|
||||
.image-container.is-patchified {
|
||||
background-image: none;
|
||||
background-color: transparent;
|
||||
gap: 2px;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.patch {
|
||||
background-color: hsl(var(--hue), 70%, 60%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
transition: all 0.5s ease;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.is-patchified .patch {
|
||||
/* Step 1: Pixelated Overlay Effect */
|
||||
.image-container.is-pixelated:not(.is-patchified) .patch {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
/* Use pseudo-element or just opacity logic in JS */
|
||||
}
|
||||
|
||||
/* Step 1: Digitization numbers */
|
||||
.pixel-val {
|
||||
font-family: monospace;
|
||||
font-size: 8px;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
.patch-id {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
transform: scale(0.9);
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
font-size: 24px;
|
||||
color: var(--vp-c-text-2);
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
.sequence-container {
|
||||
@@ -159,7 +281,7 @@ const toggleState = () => {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
animation: fadeIn 0.5s ease;
|
||||
animation: slideUp 0.5s ease;
|
||||
}
|
||||
|
||||
.sequence-label {
|
||||
@@ -171,50 +293,48 @@ const toggleState = () => {
|
||||
|
||||
.token-stream {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1px;
|
||||
overflow-x: auto;
|
||||
padding: 10px 5px; /* Space for brackets */
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Add Matrix Brackets */
|
||||
.token-stream::before,
|
||||
.token-stream::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 36px; /* Match vector height + padding */
|
||||
border: 2px solid var(--vp-c-text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.token-stream::before {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.token-stream::after {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.mini-patch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: hsl(var(--hue), 70%, 60%);
|
||||
width: 6px; /* Thinner to allow more density */
|
||||
height: 32px; /* Taller to represent Vector Dimension D */
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
background: var(--vp-c-bg-mute);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 24px;
|
||||
color: var(--vp-c-text-2);
|
||||
animation: bounce 1s infinite;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(5px); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="pipeline">
|
||||
<!-- 1. Transformer Output Grid -->
|
||||
<div class="stage">
|
||||
<div class="stage-label">1. Processed Patches (Grid)</div>
|
||||
<div class="stage-label">1. Patch Tokens (Shown as Grid) (Patch Token 网格示意)</div>
|
||||
<div class="grid-container">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
@@ -19,13 +19,13 @@
|
||||
|
||||
<div class="arrow-section">
|
||||
<div class="arrow-line"></div>
|
||||
<div class="arrow-text">Flatten & Output</div>
|
||||
<div class="arrow-text">Reshape for View: Grid ⇄ Sequence (重排显示:网格⇄序列)</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Feature Vector Sequence -->
|
||||
<div class="stage">
|
||||
<div class="stage-label">
|
||||
2. Feature Vector Sequence (The "Image Sentence")
|
||||
2. Output Token Sequence (N×D) (输出序列)
|
||||
</div>
|
||||
<div class="vector-sequence">
|
||||
<div
|
||||
|
||||
@@ -1,102 +1,109 @@
|
||||
<template>
|
||||
<div class="browser-rendering-demo">
|
||||
<div class="control-bar">
|
||||
<div class="step-indicator">Step: {{ currentStep + 1 }} / 4</div>
|
||||
<div class="steps-nav">
|
||||
<button
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
:class="{ active: currentStep === index }"
|
||||
@click="currentStep = index"
|
||||
>
|
||||
{{ step.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<button
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="step-btn"
|
||||
:class="{ active: currentStep === index, completed: currentStep > index }"
|
||||
@click="currentStep = index"
|
||||
>
|
||||
<span class="step-num">{{ index + 1 }}</span>
|
||||
<span class="step-label">{{ step.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<!-- Left: Source Code -->
|
||||
<div class="source-panel">
|
||||
<div class="panel-label">HTML / CSS</div>
|
||||
<div class="code-block">
|
||||
<div class="line"><div id="app"></div>
|
||||
<div class="line indent"><h1>Hello</h1></div>
|
||||
<div class="line indent"><p>World</p></div>
|
||||
<div class="line"></div></div>
|
||||
<div class="line mt-2">h1 { color: red; }</div>
|
||||
<div class="stage-container">
|
||||
<div class="stage-info">
|
||||
<h3>{{ steps[currentStep].title }}</h3>
|
||||
<p>{{ steps[currentStep].desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="visualization-window">
|
||||
<!-- HTML/CSS Source -->
|
||||
<div class="source-view">
|
||||
<div class="window-title">积木说明书 (HTML/CSS)</div>
|
||||
<div class="code-content">
|
||||
<!-- HTML Highlighted always after Step 0 -->
|
||||
<div class="line" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'html' }" @mouseenter="hoveredPart = 'html'" @mouseleave="hoveredPart = null"><!DOCTYPE html></div>
|
||||
<div class="line" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'html' }" @mouseenter="hoveredPart = 'html'" @mouseleave="hoveredPart = null"><html></div>
|
||||
<div class="line indent" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'body' }" @mouseenter="hoveredPart = 'body'" @mouseleave="hoveredPart = null"><body></div>
|
||||
<div class="line indent-2" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'card' }" @mouseenter="hoveredPart = 'card'" @mouseleave="hoveredPart = null"><div class="card"></div>
|
||||
<div class="line indent-3" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'img' }" @mouseenter="hoveredPart = 'img'" @mouseleave="hoveredPart = null"><img class="icon" src="castle.png" /></div>
|
||||
<div class="line indent-3" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'title' }" @mouseenter="hoveredPart = 'title'" @mouseleave="hoveredPart = null"><h2 class="title">乐高城堡</h2></div>
|
||||
<div class="line indent-3" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'btn' }" @mouseenter="hoveredPart = 'btn'" @mouseleave="hoveredPart = null"><button class="btn">购买</button></div>
|
||||
<div class="line indent-2" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'card' }" @mouseenter="hoveredPart = 'card'" @mouseleave="hoveredPart = null"></div></div>
|
||||
<div class="line indent" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'body' }" @mouseenter="hoveredPart = 'body'" @mouseleave="hoveredPart = null"></body></div>
|
||||
<div class="line" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'html' }" @mouseenter="hoveredPart = 'html'" @mouseleave="hoveredPart = null"></html></div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- CSS Highlighted precisely based on step usage -->
|
||||
<!-- Layout properties -->
|
||||
<div class="line" :class="{ active: currentStep === 2, hovered: hoveredPart === 'card' }" @mouseenter="hoveredPart = 'card'" @mouseleave="hoveredPart = null">.card { display: flex; padding: 10px; }</div>
|
||||
<div class="line" :class="{ active: currentStep === 2, hovered: hoveredPart === 'img' }" @mouseenter="hoveredPart = 'img'" @mouseleave="hoveredPart = null">.icon { width: 50px; height: 50px; }</div>
|
||||
<!-- Style properties -->
|
||||
<div class="line" :class="{ active: currentStep === 1 || currentStep === 3, hovered: hoveredPart === 'title' }" @mouseenter="hoveredPart = 'title'" @mouseleave="hoveredPart = null">.title { color: red; }</div>
|
||||
<div class="line" :class="{ active: currentStep === 1 || currentStep === 3, hovered: hoveredPart === 'btn' }" @mouseenter="hoveredPart = 'btn'" @mouseleave="hoveredPart = null">.btn { background: blue; }</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="transform-arrow">→</div>
|
||||
|
||||
<!-- Render Result -->
|
||||
<div class="result-view">
|
||||
<div class="window-title">{{ steps[currentStep].resultTitle }}</div>
|
||||
|
||||
<div class="render-canvas">
|
||||
<!-- Step 1: DOM (Skeleton) -->
|
||||
<transition-group name="block">
|
||||
<div v-if="currentStep >= 0" key="html" class="block-box root" :class="{ hovered: hoveredPart === 'html' }" @mouseenter.stop="hoveredPart = 'html'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">html</span>
|
||||
<div class="block-box body" :class="{ hovered: hoveredPart === 'body' }" @mouseenter.stop="hoveredPart = 'body'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">body</span>
|
||||
|
||||
<!-- Product Card -->
|
||||
<div class="block-box card" :class="{ layout: currentStep >= 2, hovered: hoveredPart === 'card' }" @mouseenter.stop="hoveredPart = 'card'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">div.card</span>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="block-box img" :class="{ layout: currentStep >= 2, hovered: hoveredPart === 'img' }" @mouseenter.stop="hoveredPart = 'img'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">img.icon</span>
|
||||
<span v-if="currentStep >= 3" class="content-img">🏰</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="block-box title" :class="{ styled: currentStep >= 1, layout: currentStep >= 2, hovered: hoveredPart === 'title' }" @mouseenter.stop="hoveredPart = 'title'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">h2.title</span>
|
||||
<span v-if="currentStep >= 3" class="content">乐高城堡</span>
|
||||
</div>
|
||||
|
||||
<!-- Button -->
|
||||
<div class="block-box btn" :class="{ styled: currentStep >= 1, layout: currentStep >= 2, hovered: hoveredPart === 'btn' }" @mouseenter.stop="hoveredPart = 'btn'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">button.btn</span>
|
||||
<span v-if="currentStep >= 3" class="content-btn">购买</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<!-- Overlays for different steps -->
|
||||
<div v-if="currentStep === 1" class="overlay-info style-info">
|
||||
<div class="brush">🖌️ 正在上色 (Style)...</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 2" class="overlay-info layout-info">
|
||||
<div class="ruler">📏 正在排版 (Layout)...</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 3" class="overlay-info paint-info">
|
||||
<div class="paint">✨ 绘制完成 (Paint)!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="arrow">→</div>
|
||||
|
||||
<!-- Right: Visualization -->
|
||||
<div class="viz-panel">
|
||||
<div class="panel-label">{{ steps[currentStep].title }}</div>
|
||||
|
||||
<transition name="fade" mode="out-in">
|
||||
<!-- Step 1: DOM Tree -->
|
||||
<div v-if="currentStep === 0" class="tree-viz">
|
||||
<div class="node root">Document</div>
|
||||
<div class="tree-lines">
|
||||
<div class="line-v"></div>
|
||||
</div>
|
||||
<div class="node element">html</div>
|
||||
<div class="tree-lines">
|
||||
<div class="line-v"></div>
|
||||
</div>
|
||||
<div class="node element">body</div>
|
||||
<div class="tree-children">
|
||||
<div class="node element active">div#app</div>
|
||||
<div class="tree-children row">
|
||||
<div class="node element">h1</div>
|
||||
<div class="node element">p</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Render Tree -->
|
||||
<div v-else-if="currentStep === 1" class="tree-viz render-tree">
|
||||
<div class="node render-obj">RenderBlock (div)</div>
|
||||
<div class="tree-children row">
|
||||
<div class="node render-obj red">
|
||||
RenderText (h1) <br /><small>color: red</small>
|
||||
</div>
|
||||
<div class="node render-obj">RenderText (p)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Layout -->
|
||||
<div v-else-if="currentStep === 2" class="layout-viz">
|
||||
<div class="layout-box root">
|
||||
<span class="dims">100% x 100%</span>
|
||||
<div class="layout-box container">
|
||||
<span class="dims">div: 100% x auto</span>
|
||||
<div class="layout-box item h1">h1: 100% x 32px (x:0, y:0)</div>
|
||||
<div class="layout-box item p">p: 100% x 16px (x:0, y:32)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Paint -->
|
||||
<div v-else-if="currentStep === 3" class="paint-viz">
|
||||
<div class="browser-window">
|
||||
<div class="painted-content">
|
||||
<h1 style="color: red; margin: 0">Hello</h1>
|
||||
<p style="margin: 0">World</p>
|
||||
</div>
|
||||
<div class="paint-layers">
|
||||
<div class="layer-item">Layer 1: Background</div>
|
||||
<div class="layer-item">Layer 2: Text</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-footer">
|
||||
<p>{{ steps[currentStep].desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -104,204 +111,340 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStep = ref(0)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: '1. DOM',
|
||||
title: 'DOM Tree Construction',
|
||||
desc: '浏览器解析 HTML 标记,构建 DOM (文档对象模型) 树。每个标签成为一个节点。'
|
||||
label: 'DOM (搭骨架)',
|
||||
title: '1. 搭建骨架 (DOM)',
|
||||
desc: '浏览器工头 (Parser) 解析 HTML 代码,构建出完整的文档树结构。注意:即使代码中省略了 html/body,浏览器也会自动补全。',
|
||||
resultTitle: 'DOM 树结构'
|
||||
},
|
||||
{
|
||||
label: '2. Render Tree',
|
||||
title: 'Render Tree Construction',
|
||||
desc: '结合 DOM 和 CSSOM,生成渲染树。只有可见元素会被包含(display: none 的元素会被排除)。'
|
||||
label: 'Style (上色)',
|
||||
title: '2. 计算样式 (Recalculate Style)',
|
||||
desc: '装修工 (CSS Parser) 匹配 CSS 规则。比如发现 .title 需要红色,.btn 需要蓝色背景。此时只关心"长什么样",不关心"在哪"。',
|
||||
resultTitle: '附带样式的节点'
|
||||
},
|
||||
{
|
||||
label: '3. Layout',
|
||||
title: 'Layout (Reflow)',
|
||||
desc: '计算每个节点在屏幕上的确切位置和大小。这一步也叫"回流"。'
|
||||
label: 'Layout (排版)',
|
||||
title: '3. 布局排版 (Layout/Reflow)',
|
||||
desc: '测量员 (Layout) 根据 display:flex 和 padding 等属性,计算每个盒子的精确位置和大小。图片在左,文字在右。',
|
||||
resultTitle: '几何布局'
|
||||
},
|
||||
{
|
||||
label: '4. Paint',
|
||||
title: 'Painting & Composite',
|
||||
desc: '将各个节点绘制到屏幕像素。现代浏览器会将不同部分绘制到不同图层,最后合成。'
|
||||
label: 'Paint (绘制)',
|
||||
title: '4. 像素绘制 (Paint)',
|
||||
desc: '画家 (Paint) 按照计算好的位置和样式,真正把像素点画在屏幕上。最终你看到了一个完整的商品卡片。',
|
||||
resultTitle: '最终画面'
|
||||
}
|
||||
]
|
||||
|
||||
const currentStep = ref(0)
|
||||
const hoveredPart = ref(null)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.browser-rendering-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
.stepper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.steps-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.steps-nav button {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.steps-nav button.active {
|
||||
.step-btn:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.step-btn.active {
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
background: var(--vp-c-bg-alt);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.step-btn.active .step-num,
|
||||
.step-btn.completed .step-num {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
.stage-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.source-panel,
|
||||
.viz-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--vp-c-text-2);
|
||||
.stage-info {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
.stage-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.line.indent {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.line.mt-2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.viz-panel {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Visualization Styles */
|
||||
.node {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
.node.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.node.root {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.tree-viz {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tree-children.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.render-obj.red {
|
||||
border-color: red;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.layout-box {
|
||||
border: 1px dashed var(--vp-c-text-3);
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layout-box .dims {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
font-size: 0.6rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.layout-box.container {
|
||||
border-color: var(--vp-c-brand);
|
||||
margin: 0.5rem;
|
||||
}
|
||||
.layout-box.item {
|
||||
border-style: solid;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.painted-content {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.paint-layers {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.info-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
.stage-info p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
.visualization-window {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
.source-view, .result-view {
|
||||
flex: 1;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: monospace;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.line {
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.5s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line.active {
|
||||
opacity: 1;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
font-weight: bold;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.line.indent { padding-left: 1rem; }
|
||||
.line.indent-2 { padding-left: 2rem; }
|
||||
.line.indent-3 { padding-left: 3rem; }
|
||||
.line.mt-2 { margin-top: 1rem; }
|
||||
|
||||
.transform-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.result-view {
|
||||
background: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.render-canvas {
|
||||
padding: 2rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Blocks Animation */
|
||||
.block-box {
|
||||
border: 1px dashed #9ca3af;
|
||||
background: #f3f4f6;
|
||||
padding: 0.5rem;
|
||||
margin: 0.2rem;
|
||||
border-radius: 2px;
|
||||
transition: all 0.8s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
min-width: 50px;
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block-box.root { width: 95%; border-color: #e5e7eb; background: #fff; }
|
||||
.block-box.body { width: 90%; border-color: #d1d5db; background: #f9fafb; }
|
||||
.block-box.card { width: 80%; border-color: #9ca3af; background: #e5e7eb; }
|
||||
|
||||
.block-label {
|
||||
font-size: 0.6rem;
|
||||
color: #9ca3af;
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 4px;
|
||||
background: white;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* Step 2: Style */
|
||||
.block-box.title.styled {
|
||||
color: red; /* Text color applied but not painted yet */
|
||||
border: 1px solid red; /* Visual cue for style applied */
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.block-box.btn.styled {
|
||||
background: blue;
|
||||
color: white;
|
||||
border: 1px solid blue;
|
||||
}
|
||||
|
||||
/* Step 3: Layout */
|
||||
.block-box.card.layout {
|
||||
display: flex;
|
||||
flex-direction: row; /* Horizontal layout */
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.block-box.img.layout {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: #eee;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.block-box.title.layout {
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.block-box.btn.layout {
|
||||
margin-left: auto; /* Push to right */
|
||||
padding: 5px 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Content visibility for Paint step */
|
||||
.content, .content-img, .content-btn {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
animation: fadeIn 0.5s;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.content-img { font-size: 2rem; }
|
||||
.content-btn { font-size: 0.8rem; }
|
||||
|
||||
/* Overlay Info */
|
||||
.overlay-info {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
animation: bounceIn 0.5s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brush, .ruler, .paint {
|
||||
display: inline-block;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Vue Transitions */
|
||||
.block-enter-active,
|
||||
.block-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.block-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% { transform: scale(0.8); opacity: 0; }
|
||||
60% { transform: scale(1.1); opacity: 1; }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Hover Interactions */
|
||||
.line.hovered {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
opacity: 1 !important;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.block-box.hovered {
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
z-index: 10;
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
cursor: crosshair;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="component-reusability-demo">
|
||||
<div class="toolbox">
|
||||
<div class="tool-title">Component Library</div>
|
||||
<button class="spawn-btn" @click="spawn('counter')">➕ New Counter</button>
|
||||
<button class="spawn-btn" @click="spawn('card')">➕ New Card</button>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<div class="workspace-label">App Workspace</div>
|
||||
<div class="instances-container">
|
||||
<transition-group name="list">
|
||||
<div
|
||||
v-for="item in instances"
|
||||
:key="item.id"
|
||||
class="instance-wrapper"
|
||||
>
|
||||
<!-- Counter Component -->
|
||||
<div v-if="item.type === 'counter'" class="comp-instance counter">
|
||||
<div class="comp-header">
|
||||
<span>Counter #{{ item.id }}</span>
|
||||
<button class="close-btn" @click="remove(item.id)">×</button>
|
||||
</div>
|
||||
<div class="comp-body">
|
||||
<span class="count-val">{{ item.data.count }}</span>
|
||||
<button class="mini-btn" @click="item.data.count++">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Component -->
|
||||
<div v-if="item.type === 'card'" class="comp-instance card">
|
||||
<div class="comp-header">
|
||||
<span>Card #{{ item.id }}</span>
|
||||
<button class="close-btn" @click="remove(item.id)">×</button>
|
||||
</div>
|
||||
<div class="comp-body">
|
||||
<div class="skeleton-img"></div>
|
||||
<div class="skeleton-text"></div>
|
||||
<button
|
||||
class="like-btn"
|
||||
:class="{ liked: item.data.liked }"
|
||||
@click="item.data.liked = !item.data.liked"
|
||||
>
|
||||
{{ item.data.liked ? '❤️ Liked' : '♡ Like' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div v-if="instances.length === 0" class="empty-hint">
|
||||
Click buttons above to add components.
|
||||
<br>
|
||||
Notice how each one works independently!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const instances = ref([])
|
||||
let nextId = 1
|
||||
|
||||
const spawn = (type) => {
|
||||
if (type === 'counter') {
|
||||
instances.value.push({
|
||||
id: nextId++,
|
||||
type: 'counter',
|
||||
data: { count: 0 }
|
||||
})
|
||||
} else if (type === 'card') {
|
||||
instances.value.push({
|
||||
id: nextId++,
|
||||
type: 'card',
|
||||
data: { liked: false }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (id) => {
|
||||
instances.value = instances.value.filter(i => i.id !== id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.component-reusability-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbox {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.spawn-btn {
|
||||
background: white;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.spawn-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 1.5rem;
|
||||
min-height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.instances-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 3rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.instance-wrapper {
|
||||
transition: all 0.4s;
|
||||
}
|
||||
|
||||
.comp-instance {
|
||||
background: white;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
width: 140px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comp-header {
|
||||
background: #f1f5f9;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.close-btn:hover { color: #ef4444; }
|
||||
|
||||
.comp-body {
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Counter Style */
|
||||
.counter .count-val {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.mini-btn {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-btn:hover { background: #e2e8f0; }
|
||||
|
||||
/* Card Style */
|
||||
.skeleton-img {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.skeleton-text {
|
||||
width: 80%;
|
||||
height: 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.like-btn {
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.like-btn.liked {
|
||||
border-color: #fecaca;
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="css-props-ref">
|
||||
<div class="intro">
|
||||
CSS 属性就像装修队的“施工指令”。常用的其实只有几十个,这里有一份“装修菜单”供你参考:
|
||||
</div>
|
||||
|
||||
<div class="categories">
|
||||
<div
|
||||
v-for="(cat, index) in categories"
|
||||
:key="index"
|
||||
class="category"
|
||||
>
|
||||
<div class="cat-title">{{ cat.title }}</div>
|
||||
<div class="props-grid">
|
||||
<div
|
||||
v-for="prop in cat.props"
|
||||
:key="prop.name"
|
||||
class="prop-item"
|
||||
@click="activeProp = prop"
|
||||
:class="{ active: activeProp && activeProp.name === prop.name }"
|
||||
>
|
||||
<div class="prop-name">{{ prop.name }}</div>
|
||||
<div class="prop-desc">{{ prop.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeProp" class="prop-detail">
|
||||
<div class="detail-header">
|
||||
<span class="detail-name">{{ activeProp.name }}</span>
|
||||
<span class="detail-cat-badge">{{ activeProp.categoryLabel }}</span>
|
||||
</div>
|
||||
<div class="detail-desc">{{ activeProp.fullDesc }}</div>
|
||||
<div class="detail-code">
|
||||
<div class="code-label">示例代码:</div>
|
||||
<pre><code>{{ activeProp.example }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="prop-detail empty">
|
||||
点击上面的属性看看它能做什么 👆
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeProp = ref(null)
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: '📝 文字与排版',
|
||||
props: [
|
||||
{ name: 'color', desc: '文字颜色', categoryLabel: '文字', fullDesc: '改变文字的颜色。可以使用英文单词(red)、十六进制(#ff0000)或RGB值。', example: 'color: #333333;' },
|
||||
{ name: 'font-size', desc: '字号大小', categoryLabel: '文字', fullDesc: '设置文字的大小。常用单位是 px (像素) 或 rem。', example: 'font-size: 16px;' },
|
||||
{ name: 'font-weight', desc: '字体粗细', categoryLabel: '文字', fullDesc: '设置文字的粗细。bold 是加粗,normal 是正常。', example: 'font-weight: bold;' },
|
||||
{ name: 'text-align', desc: '对齐方式', categoryLabel: '排版', fullDesc: '设置文字水平对齐方式:左对齐(left)、居中(center)、右对齐(right)。', example: 'text-align: center;' },
|
||||
{ name: 'line-height', desc: '行高', categoryLabel: '排版', fullDesc: '设置行间距。通常设为 1.5 左右让阅读更舒服。', example: 'line-height: 1.5;' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '📦 盒子与大小',
|
||||
props: [
|
||||
{ name: 'width / height', desc: '宽 / 高', categoryLabel: '尺寸', fullDesc: '设置元素的宽度和高度。', example: 'width: 100px;\nheight: 50px;' },
|
||||
{ name: 'padding', desc: '内边距', categoryLabel: '间距', fullDesc: '盒子内部的空间(内容距离边框的距离)。像填充泡沫一样撑大盒子。', example: 'padding: 20px;' },
|
||||
{ name: 'margin', desc: '外边距', categoryLabel: '间距', fullDesc: '盒子外部的空间(盒子与其他元素之间的距离)。', example: 'margin: 20px;' },
|
||||
{ name: 'background', desc: '背景', categoryLabel: '外观', fullDesc: '设置背景颜色或背景图片。', example: 'background: #f0f0f0;' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '🎨 边框与装饰',
|
||||
props: [
|
||||
{ name: 'border', desc: '边框', categoryLabel: '边框', fullDesc: '设置边框的粗细、样式和颜色。', example: 'border: 1px solid #ccc;' },
|
||||
{ name: 'border-radius', desc: '圆角', categoryLabel: '边框', fullDesc: '让盒子的角变圆润。现在的按钮通常都有点圆角。', example: 'border-radius: 8px;' },
|
||||
{ name: 'box-shadow', desc: '阴影', categoryLabel: '装饰', fullDesc: '给盒子添加阴影效果,增加立体感和层次感。', example: 'box-shadow: 0 4px 6px rgba(0,0,0,0.1);' },
|
||||
{ name: 'opacity', desc: '透明度', categoryLabel: '装饰', fullDesc: '设置元素的透明度,0 是全透明(看不见但还在),1 是不透明。', example: 'opacity: 0.8;' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '📐 布局与定位',
|
||||
props: [
|
||||
{ name: 'display', desc: '显示模式', categoryLabel: '布局', fullDesc: '决定盒子怎么摆。block(独占一行), flex(弹性布局), none(隐藏)。', example: 'display: flex;' },
|
||||
{ name: 'position', desc: '定位方式', categoryLabel: '定位', fullDesc: '决定盒子怎么定位。relative(相对), absolute(绝对), fixed(固定在屏幕)。', example: 'position: absolute;\ntop: 0;\nleft: 0;' },
|
||||
{ name: 'z-index', desc: '层级', categoryLabel: '定位', fullDesc: '决定谁叠在谁上面。数字越大越靠上。', example: 'z-index: 100;' },
|
||||
{ name: 'cursor', desc: '鼠标手势', categoryLabel: '交互', fullDesc: '鼠标移上去变成什么样。pointer(小手), text(输入光标)。', example: 'cursor: pointer;' },
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.css-props-ref {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cat-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.props-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prop-item {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.prop-item:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.prop-item.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.prop-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.prop-desc {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.prop-detail {
|
||||
margin-top: 20px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.prop-detail.empty {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 13px;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.detail-cat-badge {
|
||||
font-size: 11px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-code {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-label {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<!--
|
||||
CssLayoutDemo.vue
|
||||
布局演示:Flexbox 核心概念交互
|
||||
-->
|
||||
<template>
|
||||
<div class="layout-demo">
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>排列方向 (flex-direction)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="val in ['row', 'column']"
|
||||
:key="val"
|
||||
:class="{ active: direction === val }"
|
||||
@click="direction = val"
|
||||
>
|
||||
{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>主轴对齐 (justify-content)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="val in ['flex-start', 'center', 'space-between', 'space-around']"
|
||||
:key="val"
|
||||
:class="{ active: justify === val }"
|
||||
@click="justify = val"
|
||||
>
|
||||
{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>交叉轴对齐 (align-items)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="val in ['stretch', 'center', 'flex-start', 'flex-end']"
|
||||
:key="val"
|
||||
:class="{ active: align === val }"
|
||||
@click="align = val"
|
||||
>
|
||||
{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>换行 (flex-wrap)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="val in ['nowrap', 'wrap']"
|
||||
:key="val"
|
||||
:class="{ active: wrap === val }"
|
||||
@click="wrap = val"
|
||||
>
|
||||
{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-area">
|
||||
<div class="container" :style="containerStyle">
|
||||
<div
|
||||
v-for="n in itemCount"
|
||||
:key="n"
|
||||
class="item"
|
||||
:style="[itemStyle, getItemColor(n)]"
|
||||
>
|
||||
{{ n }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-display">
|
||||
<div class="code-header">👆 点击代码行可以暂时禁用该属性</div>
|
||||
<pre>.container {
|
||||
display: flex;
|
||||
<div
|
||||
class="code-line"
|
||||
:class="{ disabled: !activeProps.direction }"
|
||||
@click="toggleProp('direction')"
|
||||
>flex-direction: <span class="val">{{ direction }}</span>;</div>
|
||||
<div
|
||||
class="code-line"
|
||||
:class="{ disabled: !activeProps.justify }"
|
||||
@click="toggleProp('justify')"
|
||||
>justify-content: <span class="val">{{ justify }}</span>;</div>
|
||||
<div
|
||||
class="code-line"
|
||||
:class="{ disabled: !activeProps.align }"
|
||||
@click="toggleProp('align')"
|
||||
>align-items: <span class="val">{{ align }}</span>;</div>
|
||||
<div
|
||||
class="code-line"
|
||||
:class="{ disabled: !activeProps.wrap }"
|
||||
@click="toggleProp('wrap')"
|
||||
>flex-wrap: <span class="val">{{ wrap }}</span>;</div>
|
||||
/* ...其他样式 */
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
|
||||
const direction = ref('row')
|
||||
const justify = ref('center')
|
||||
const align = ref('center')
|
||||
const wrap = ref('nowrap')
|
||||
|
||||
const activeProps = reactive({
|
||||
direction: true,
|
||||
justify: true,
|
||||
align: true,
|
||||
wrap: true
|
||||
})
|
||||
|
||||
const toggleProp = (prop) => {
|
||||
activeProps[prop] = !activeProps[prop]
|
||||
}
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
const style = { display: 'flex' }
|
||||
if (activeProps.direction) style.flexDirection = direction.value
|
||||
if (activeProps.justify) style.justifyContent = justify.value
|
||||
if (activeProps.align) style.alignItems = align.value
|
||||
if (activeProps.wrap) style.flexWrap = wrap.value
|
||||
return style
|
||||
})
|
||||
|
||||
const itemStyle = computed(() => {
|
||||
const style = {}
|
||||
// Default fixed size
|
||||
style.width = '60px'
|
||||
style.height = '60px'
|
||||
|
||||
// Adjust for stretch - use effective align/direction values
|
||||
const effectiveAlign = activeProps.align ? align.value : 'stretch'
|
||||
const effectiveDirection = activeProps.direction ? direction.value : 'row'
|
||||
|
||||
if (effectiveAlign === 'stretch') {
|
||||
if (effectiveDirection === 'row') {
|
||||
style.height = 'auto'
|
||||
} else {
|
||||
style.width = 'auto'
|
||||
}
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
const itemCount = computed(() => (wrap.value === 'wrap' ? 12 : 5))
|
||||
|
||||
const colors = [
|
||||
'#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981',
|
||||
'#6366f1', '#14b8a6', '#f97316', '#ef4444', '#84cc16',
|
||||
'#06b6d4', '#d946ef'
|
||||
]
|
||||
|
||||
const getItemColor = (n) => {
|
||||
return { background: colors[(n - 1) % colors.length] }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-group button {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-group button:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.btn-group button.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
height: 200px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
background-image: radial-gradient(var(--vp-c-divider) 1px, transparent 1px);
|
||||
background-size: 10px 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
/* Dimensions handled by inline style */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
background: #1e293b;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.code-line:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.code-line.disabled {
|
||||
opacity: 0.4;
|
||||
text-decoration: line-through;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.code-line.disabled .val {
|
||||
color: #94a3b8;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
pre { margin: 0; }
|
||||
.val { color: #f472b6; font-weight: bold; }
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="css-playground">
|
||||
<div class="demo-box">
|
||||
<div
|
||||
class="target-element"
|
||||
:style="{
|
||||
backgroundColor: bgColor,
|
||||
color: textColor,
|
||||
fontSize: fontSize + 'px',
|
||||
padding: padding + 'px',
|
||||
borderRadius: borderRadius + 'px',
|
||||
border: `${borderWidth}px solid ${borderColor}`
|
||||
}"
|
||||
>
|
||||
我是演示元素
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>背景颜色 (background-color)</label>
|
||||
<input type="color" v-model="bgColor" />
|
||||
<span class="value">{{ bgColor }}</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>文字颜色 (color)</label>
|
||||
<input type="color" v-model="textColor" />
|
||||
<span class="value">{{ textColor }}</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>字体大小 (font-size)</label>
|
||||
<input type="range" v-model="fontSize" min="12" max="48" />
|
||||
<span class="value">{{ fontSize }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>内边距 (padding)</label>
|
||||
<input type="range" v-model="padding" min="0" max="50" />
|
||||
<span class="value">{{ padding }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>圆角 (border-radius)</label>
|
||||
<input type="range" v-model="borderRadius" min="0" max="50" />
|
||||
<span class="value">{{ borderRadius }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>边框宽度 (border-width)</label>
|
||||
<input type="range" v-model="borderWidth" min="0" max="10" />
|
||||
<span class="value">{{ borderWidth }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>边框颜色 (border-color)</label>
|
||||
<input type="color" v-model="borderColor" />
|
||||
<span class="value">{{ borderColor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-preview">
|
||||
<div class="code-title">生成的 CSS 代码:</div>
|
||||
<pre><code>.element {
|
||||
background-color: <span class="highlight">{{ bgColor }}</span>;
|
||||
color: <span class="highlight">{{ textColor }}</span>;
|
||||
font-size: <span class="highlight">{{ fontSize }}px</span>;
|
||||
padding: <span class="highlight">{{ padding }}px</span>;
|
||||
border-radius: <span class="highlight">{{ borderRadius }}px</span>;
|
||||
border: <span class="highlight">{{ borderWidth }}px</span> solid <span class="highlight">{{ borderColor }}</span>;
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const bgColor = ref('#3b82f6')
|
||||
const textColor = ref('#ffffff')
|
||||
const fontSize = ref(16)
|
||||
const padding = ref(20)
|
||||
const borderRadius = ref(8)
|
||||
const borderWidth = ref(0)
|
||||
const borderColor = ref('#000000')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.css-playground {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.demo-box {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.target-element {
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
/* Ensure it doesn't overflow easily */
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-1);
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
color: #d4d4d4;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
color: #808080;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #9cdcfe;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="selectors-demo">
|
||||
<div class="hint">👇 鼠标悬停在左侧 CSS 代码上,看看右侧 HTML 谁会被选中</div>
|
||||
|
||||
<div class="comparison">
|
||||
<!-- Left: CSS Rules -->
|
||||
<div class="column css-col">
|
||||
<div class="col-title">CSS (样式表)</div>
|
||||
<div class="rules-list">
|
||||
<div
|
||||
class="rule-item"
|
||||
:class="{ active: activeType === 'tag' }"
|
||||
@mouseenter="activeType = 'tag'"
|
||||
@mouseleave="activeType = null"
|
||||
>
|
||||
<div class="selector">p</div>
|
||||
<div class="block">{ color: #333; }</div>
|
||||
<div class="explanation">
|
||||
<span class="badge tag">标签选择器</span>
|
||||
直接写标签名,选中所有 <code><p></code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rule-item"
|
||||
:class="{ active: activeType === 'class' }"
|
||||
@mouseenter="activeType = 'class'"
|
||||
@mouseleave="activeType = null"
|
||||
>
|
||||
<div class="selector">.card</div>
|
||||
<div class="block">{ background: white; }</div>
|
||||
<div class="explanation">
|
||||
<span class="badge class">类选择器</span>
|
||||
以 <code>.</code> 开头,选中所有 <code>class="card"</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rule-item"
|
||||
:class="{ active: activeType === 'id' }"
|
||||
@mouseenter="activeType = 'id'"
|
||||
@mouseleave="activeType = null"
|
||||
>
|
||||
<div class="selector">#submit-btn</div>
|
||||
<div class="block">{ font-weight: bold; }</div>
|
||||
<div class="explanation">
|
||||
<span class="badge id">ID 选择器</span>
|
||||
以 <code>#</code> 开头,选中唯一 <code>id="submit-btn"</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Connector -->
|
||||
<div class="connector">
|
||||
<div class="line-path" :class="activeType"></div>
|
||||
<div class="icon">🔗</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: HTML Structure -->
|
||||
<div class="column html-col">
|
||||
<div class="col-title">HTML (结构)</div>
|
||||
<div class="code-view">
|
||||
<div
|
||||
class="html-line"
|
||||
:class="{ highlight: activeType === 'tag' }"
|
||||
>
|
||||
<p>我是普通段落</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="html-line"
|
||||
:class="{ highlight: activeType === 'class' }"
|
||||
>
|
||||
<div <span class="attr">class="card"</span>>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="html-line indent"
|
||||
:class="{ highlight: activeType === 'tag' || activeType === 'class' }"
|
||||
>
|
||||
<p>我是卡片里的段落</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="html-line"
|
||||
:class="{ highlight: activeType === 'class' }"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="html-line"
|
||||
:class="{ highlight: activeType === 'id' }"
|
||||
>
|
||||
<button <span class="attr">id="submit-btn"</span>>提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeType = ref(null)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selectors-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 16px;
|
||||
font-family: var(--vp-font-family-base);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* CSS Column */
|
||||
.rule-item {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rule-item:hover, .rule-item.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.selector {
|
||||
color: #d73a49; /* Red-ish for selector */
|
||||
font-weight: bold;
|
||||
}
|
||||
.rule-item:nth-child(2) .selector { color: #6f42c1; } /* Purple for class */
|
||||
.rule-item:nth-child(3) .selector { color: #005cc5; } /* Blue for ID */
|
||||
|
||||
.explanation {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
.badge.tag { background: #d73a49; }
|
||||
.badge.class { background: #6f42c1; }
|
||||
.badge.id { background: #005cc5; }
|
||||
|
||||
/* HTML Column */
|
||||
.code-view {
|
||||
background: #1e1e1e;
|
||||
color: #abb2bf;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.html-line {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.html-line.indent {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.html-line.highlight {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
text-shadow: 0 0 5px rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.attr {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
/* Connector */
|
||||
.connector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
z-index: 2;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.comparison {
|
||||
flex-direction: column;
|
||||
}
|
||||
.rule-item:hover, .rule-item.active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
.connector {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,289 +1,167 @@
|
||||
<template>
|
||||
<div class="dns-lookup-demo">
|
||||
<div class="control-panel">
|
||||
<div class="input-group">
|
||||
<label>Domain</label>
|
||||
<input
|
||||
v-model="domain"
|
||||
placeholder="www.example.com"
|
||||
class="domain-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="useCache" />
|
||||
<span class="slider"></span>
|
||||
<span class="label">Simulate Cache Hit</span>
|
||||
</label>
|
||||
</div>
|
||||
<button @click="startLookup" :disabled="isLooking" class="lookup-btn">
|
||||
{{ isLooking ? 'Resolving...' : 'Lookup' }}
|
||||
</button>
|
||||
<div class="dns-lookup-demo simple-mode">
|
||||
<div class="concept-explanation">
|
||||
<p class="why-text">
|
||||
<strong>为什么需要 DNS?(查导航)</strong>
|
||||
</p>
|
||||
<p class="why-desc-zh">
|
||||
你知道店铺名字叫 "Shop.com",但快递员需要知道具体的经纬度坐标 (IP 地址) 才能送达。
|
||||
<br>
|
||||
DNS 就像是<strong>地图导航</strong>,输入店名,它告诉你具体的坐标。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="viz-area">
|
||||
<!-- Client -->
|
||||
<div class="node client">
|
||||
<span class="icon">💻</span>
|
||||
<span>Browser</span>
|
||||
<div class="demo-stage">
|
||||
<div class="input-area">
|
||||
<span class="label">店铺名称 (域名)</span>
|
||||
<div class="fake-input">shop.com</div>
|
||||
</div>
|
||||
|
||||
<!-- Servers -->
|
||||
<div class="servers-container">
|
||||
<div
|
||||
v-for="(server, index) in servers"
|
||||
:key="server.name"
|
||||
class="node server"
|
||||
:class="{
|
||||
active: currentServer === index,
|
||||
success: completed && currentServer === index
|
||||
}"
|
||||
>
|
||||
<div class="server-icon">{{ server.icon }}</div>
|
||||
<div class="server-name">{{ server.name }}</div>
|
||||
<div class="server-desc">{{ server.desc }}</div>
|
||||
<div class="process-animation">
|
||||
<div class="arrow-down">⬇️</div>
|
||||
<div class="dns-box">
|
||||
<div class="icon">🧭</div>
|
||||
<div class="title">DNS (地图导航)</div>
|
||||
<div class="desc">正在查找 shop.com 的位置...</div>
|
||||
</div>
|
||||
<div class="arrow-down">⬇️</div>
|
||||
</div>
|
||||
|
||||
<!-- Packet Animation -->
|
||||
<div v-if="packet.visible" class="packet" :style="packetStyle">
|
||||
{{ packet.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-panel" ref="logPanel">
|
||||
<div v-for="(log, i) in logs" :key="i" class="log-entry">
|
||||
<span class="time">{{ log.time }}ms</span>
|
||||
<span class="msg">{{ log.message }}</span>
|
||||
<div class="output-area">
|
||||
<span class="label">GPS 坐标 (IP 地址)</span>
|
||||
<div class="fake-output">93.184.216.34</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const domain = ref('www.google.com')
|
||||
const useCache = ref(false)
|
||||
const isLooking = ref(false)
|
||||
const currentServer = ref(-1)
|
||||
const completed = ref(false)
|
||||
const logs = ref([])
|
||||
const packet = ref({ visible: false, text: '?', x: 0, y: 0 })
|
||||
|
||||
const servers = [
|
||||
{ name: 'Root (.)', icon: '🌲', desc: 'Global Root' },
|
||||
{ name: 'TLD (.com)', icon: '🏢', desc: 'Top Level' },
|
||||
{ name: 'Authoritative', icon: '📝', desc: 'example.com' }
|
||||
]
|
||||
|
||||
const packetStyle = computed(() => ({
|
||||
transform: `translate(${packet.value.x}px, ${packet.value.y}px)`,
|
||||
opacity: packet.value.visible ? 1 : 0
|
||||
}))
|
||||
|
||||
const addLog = (message) => {
|
||||
logs.value.push({
|
||||
time: Math.floor(performance.now() % 10000),
|
||||
message
|
||||
})
|
||||
// Auto scroll
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('.log-panel')
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
const startLookup = async () => {
|
||||
if (isLooking.value) return
|
||||
isLooking.value = true
|
||||
currentServer.value = -1
|
||||
completed.value = false
|
||||
logs.value = []
|
||||
|
||||
addLog(`Starting DNS lookup for ${domain.value}`)
|
||||
|
||||
if (useCache.value) {
|
||||
await wait(500)
|
||||
addLog('✅ Cache HIT! IP found in browser cache.')
|
||||
completed.value = true
|
||||
isLooking.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Recursive Query Simulation
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const server = servers[i]
|
||||
addLog(`Querying ${server.name}...`)
|
||||
|
||||
// Simulate network delay
|
||||
currentServer.value = i
|
||||
await wait(800)
|
||||
|
||||
if (i < servers.length - 1) {
|
||||
addLog(`Received referral to ${servers[i + 1].name}`)
|
||||
} else {
|
||||
addLog(`✅ Resolved IP: 142.250.185.238`)
|
||||
}
|
||||
}
|
||||
|
||||
completed.value = true
|
||||
isLooking.value = false
|
||||
}
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
// Simplified: No need for complex i18n logic anymore as we display both.
|
||||
defineProps({
|
||||
lang: String // Accepted but ignored
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dns-lookup-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.domain-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.lookup-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
height: 40px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.lookup-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.viz-area {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
transition: all 0.3s;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.node.client {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.servers-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.node.server.active {
|
||||
border-color: #f59e0b;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 15px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.node.server.success {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.server-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.server-desc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
background: #1e1e1e;
|
||||
color: #10b981;
|
||||
.concept-explanation {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
height: 150px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.log-entry .time {
|
||||
color: #6b7280;
|
||||
margin-right: 0.5rem;
|
||||
.why-text {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle {
|
||||
.why-desc-en {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.why-desc-zh {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.demo-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input-area, .output-area {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.fake-input, .fake-output {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.8rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.fake-input {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.fake-output {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.process-animation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.toggle input {
|
||||
display: none;
|
||||
|
||||
.dns-box {
|
||||
background: #fffbeb;
|
||||
border: 2px solid #f59e0b;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
width: 240px; /* Slightly wider for bilingual text */
|
||||
}
|
||||
.toggle .slider {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background: #ccc;
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
transition: 0.3s;
|
||||
|
||||
.html.dark .dns-box {
|
||||
background: #451a03;
|
||||
}
|
||||
.toggle .slider:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: 0.3s;
|
||||
|
||||
.icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.toggle input:checked + .slider {
|
||||
background: var(--vp-c-brand);
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
color: #d97706;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.toggle input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
|
||||
.desc {
|
||||
font-size: 0.8rem;
|
||||
color: #b45309;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(5px); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
<template>
|
||||
<div class="frontend-evolution-demo">
|
||||
<!-- Modern Timeline -->
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-track"></div>
|
||||
<button
|
||||
v-for="(stage, index) in stages"
|
||||
:key="index"
|
||||
class="timeline-node"
|
||||
:class="{ active: currentStage === index, passed: currentStage > index }"
|
||||
@click="currentStage = index"
|
||||
>
|
||||
<div class="node-dot">
|
||||
<div class="inner-dot"></div>
|
||||
</div>
|
||||
<div class="node-content">
|
||||
<span class="year-badge">{{ stage.year }}</span>
|
||||
<span class="node-label">{{ stage.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div :key="currentStage" class="stage-content">
|
||||
<div class="header-section">
|
||||
<h3>
|
||||
<span class="stage-index">{{ indexToRoman(currentStage + 1) }}.</span>
|
||||
{{ stages[currentStage].title }}
|
||||
</h3>
|
||||
<p>{{ stages[currentStage].desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="visualization-grid">
|
||||
<!-- Code Editor -->
|
||||
<div class="mac-window code-window">
|
||||
<div class="window-bar">
|
||||
<div class="traffic-lights">
|
||||
<span class="light red"></span>
|
||||
<span class="light yellow"></span>
|
||||
<span class="light green"></span>
|
||||
</div>
|
||||
<div class="window-title">{{ stages[currentStage].codeTitle }}</div>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<pre><code>{{ stages[currentStage].code }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagram View -->
|
||||
<div class="mac-window diagram-window">
|
||||
<div class="window-bar">
|
||||
<div class="window-title">Architecture Pattern</div>
|
||||
</div>
|
||||
<div class="diagram-canvas">
|
||||
|
||||
<!-- Stage 0: Static -->
|
||||
<div v-if="currentStage === 0" class="diagram static">
|
||||
<div class="flow-stack">
|
||||
<div class="concept-box html">
|
||||
<span class="icon">📄</span> HTML (Content)
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div class="concept-box browser">
|
||||
<span class="icon">🌍</span> Browser (Display)
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-note">Server sends complete HTML</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 1: jQuery -->
|
||||
<div v-if="currentStage === 1" class="diagram jquery">
|
||||
<div class="concept-box dom">
|
||||
<span class="icon">🌳</span> DOM Tree
|
||||
</div>
|
||||
<div class="chaos-arrows">
|
||||
<svg viewBox="0 0 100 60" class="chaos-svg">
|
||||
<path d="M10,10 Q50,5 90,10" class="arrow-path" marker-end="url(#arrowhead)"/>
|
||||
<path d="M90,50 Q50,55 10,50" class="arrow-path" marker-end="url(#arrowhead)"/>
|
||||
<path d="M20,20 Q50,40 80,20" class="arrow-path dashed" marker-end="url(#arrowhead)"/>
|
||||
</svg>
|
||||
<span class="label-action">Direct Manipulation</span>
|
||||
<span class="label-event">Events</span>
|
||||
</div>
|
||||
<div class="concept-box js">
|
||||
<span class="icon">🍝</span> jQuery / JS
|
||||
</div>
|
||||
|
||||
<!-- SVG Marker Definition -->
|
||||
<svg style="position: absolute; width: 0; height: 0;">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#666" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Stage 2: MVC -->
|
||||
<div v-if="currentStage === 2" class="diagram mvc">
|
||||
<div class="mvc-triangle">
|
||||
<div class="concept-box model">Model</div>
|
||||
<div class="concept-box view">View</div>
|
||||
<div class="concept-box controller">Controller</div>
|
||||
|
||||
<!-- Connecting Lines -->
|
||||
<div class="line m-v"></div>
|
||||
<div class="line v-c"></div>
|
||||
<div class="line c-m"></div>
|
||||
</div>
|
||||
<div class="mvc-desc">Two-way Binding</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 3: Component -->
|
||||
<div v-if="currentStage === 3" class="diagram component">
|
||||
<div class="comp-structure">
|
||||
<div class="comp-box root">
|
||||
<span class="comp-label">App</span>
|
||||
<div class="comp-children">
|
||||
<div class="comp-box header">Header</div>
|
||||
<div class="comp-box list">
|
||||
ProductList
|
||||
<div class="comp-children row">
|
||||
<div class="comp-box item">Item</div>
|
||||
<div class="comp-box item">Item</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-pill">
|
||||
State ➔ UI = f(State)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStage = ref(0)
|
||||
|
||||
const indexToRoman = (num) => {
|
||||
const map = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV' }
|
||||
return map[num] || num
|
||||
}
|
||||
|
||||
const stages = [
|
||||
{
|
||||
year: '1990s',
|
||||
label: 'Static Web',
|
||||
title: 'The Static Era',
|
||||
desc: 'Web pages were just digital documents. The server sent HTML, and the browser rendered it. Want new content? Refresh the whole page.',
|
||||
codeTitle: 'index.html',
|
||||
code: `<html>
|
||||
<body>
|
||||
<h1>Hello World</h1>
|
||||
<p>Static content served by server.</p>
|
||||
</body>
|
||||
</html>`
|
||||
},
|
||||
{
|
||||
year: '2005+',
|
||||
label: 'jQuery Era',
|
||||
title: 'Imperative DOM',
|
||||
desc: 'JS directly manipulated the DOM. "Find that button, add a click listener, change that div\'s color". Logic became tangled like "spaghetti".',
|
||||
codeTitle: 'script.js',
|
||||
code: `$('#btn').click(function() {
|
||||
// Find & Modify directly
|
||||
$('.box').show();
|
||||
$('.text').text('Loading...');
|
||||
|
||||
// Callback hell...
|
||||
$.ajax('/api', function(data) {
|
||||
$('.content').html(data);
|
||||
});
|
||||
});`
|
||||
},
|
||||
{
|
||||
year: '2010+',
|
||||
label: 'MVC/MVVM',
|
||||
title: 'Framework Era',
|
||||
desc: 'Separation of concerns. Data (Model) and View were separated. Two-way data binding (like in AngularJS) was magic but performance-heavy.',
|
||||
codeTitle: 'controller.js',
|
||||
code: `$scope.user = { name: 'Bob' };
|
||||
|
||||
// Magic: Data changes -> View updates
|
||||
$scope.updateName = function() {
|
||||
$scope.user.name = 'Alice';
|
||||
};`
|
||||
},
|
||||
{
|
||||
year: '2013+',
|
||||
label: 'Component',
|
||||
title: 'Component Era',
|
||||
desc: 'UI is broken into independent "Lego blocks" (Components). Declarative: You define "What it looks like given State X", framework handles the "How".',
|
||||
codeTitle: 'ProductCard.vue',
|
||||
code: `<template>
|
||||
<div class="card">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<button @click="buy">Buy</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// State driven
|
||||
export default {
|
||||
props: ['product'],
|
||||
methods: { buy() { ... } }
|
||||
}
|
||||
<\/script>`
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.frontend-evolution-demo {
|
||||
border-radius: 16px;
|
||||
background: var(--vp-c-bg);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
margin: 2rem 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* --- Timeline --- */
|
||||
.timeline-container {
|
||||
padding: 2rem 1rem 1rem;
|
||||
background: linear-gradient(to bottom, var(--vp-c-bg-soft), var(--vp-c-bg));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
position: absolute;
|
||||
top: 2.5rem; /* Center with dots */
|
||||
left: 3rem;
|
||||
right: 3rem;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 25%;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.timeline-node:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.timeline-node.active, .timeline-node.passed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.node-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-text-3);
|
||||
margin-bottom: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.inner-dot {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.timeline-node.active .node-dot {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 0 4px var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.timeline-node.active .inner-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.timeline-node.passed .node-dot {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* --- Content --- */
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.header-section h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(120deg, var(--vp-c-brand), #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stage-index {
|
||||
color: var(--vp-c-text-3);
|
||||
-webkit-text-fill-color: var(--vp-c-text-3);
|
||||
margin-right: 0.5rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.header-section p {
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* --- Visualization Grid --- */
|
||||
.visualization-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.visualization-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mac-window {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.mac-window:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.code-window {
|
||||
background: #1e1e2e; /* Dark theme */
|
||||
}
|
||||
|
||||
.diagram-window {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.window-bar {
|
||||
padding: 0.8rem 1rem;
|
||||
background: rgba(255,255,255,0.05); /* Transparent on dark */
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.diagram-window .window-bar {
|
||||
background: white;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.light.red { background: #ff5f56; }
|
||||
.light.yellow { background: #ffbd2e; }
|
||||
.light.green { background: #27c93f; }
|
||||
|
||||
.window-title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.diagram-window .window-title {
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-content pre {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-content code {
|
||||
font-family: 'Fira Code', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
.diagram-canvas {
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 250px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* --- Diagram Specifics --- */
|
||||
.concept-box {
|
||||
background: white;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
padding: 0.8rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon { font-size: 1.2rem; }
|
||||
|
||||
/* Static Diagram */
|
||||
.diagram.static .flow-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.side-note {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
background: rgba(0,0,0,0.05);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* jQuery Diagram */
|
||||
.diagram.jquery {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.chaos-arrows {
|
||||
position: relative;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chaos-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.arrow-path {
|
||||
fill: none;
|
||||
stroke: #9ca3af;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.arrow-path.dashed {
|
||||
stroke-dasharray: 4;
|
||||
}
|
||||
.label-action, .label-event {
|
||||
font-size: 0.75rem;
|
||||
background: white;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.label-action { transform: translate(-20px, -10px); }
|
||||
.label-event { transform: translate(20px, 10px); }
|
||||
|
||||
/* MVC Diagram */
|
||||
.diagram.mvc {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.mvc-triangle {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 160px;
|
||||
}
|
||||
.mvc-triangle .model { position: absolute; top: 0; left: 50%; transform: translateX(-50%); }
|
||||
.mvc-triangle .view { position: absolute; bottom: 0; left: 0; }
|
||||
.mvc-triangle .controller { position: absolute; bottom: 0; right: 0; }
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
background: #cbd5e1;
|
||||
z-index: 1;
|
||||
}
|
||||
.line.m-v {
|
||||
height: 2px;
|
||||
width: 110px;
|
||||
top: 45%;
|
||||
left: 20px;
|
||||
transform: rotate(60deg);
|
||||
}
|
||||
.line.v-c {
|
||||
height: 2px;
|
||||
width: 100px;
|
||||
bottom: 20px;
|
||||
left: 50px;
|
||||
}
|
||||
.line.c-m {
|
||||
height: 2px;
|
||||
width: 110px;
|
||||
top: 45%;
|
||||
right: 20px;
|
||||
transform: rotate(-60deg);
|
||||
}
|
||||
.mvc-desc {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Component Diagram */
|
||||
.diagram.component {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.comp-structure {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.comp-box {
|
||||
background: white;
|
||||
border: 2px solid #3b82f6;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 0 rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
.comp-box.root {
|
||||
background: #eff6ff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
.comp-label {
|
||||
font-weight: bold;
|
||||
color: #1e40af;
|
||||
}
|
||||
.comp-children {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.comp-children.row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.comp-box.header { background: #dbeafe; border-style: dashed; }
|
||||
.comp-box.list { background: #dbeafe; }
|
||||
.comp-box.item { background: #bfdbfe; font-size: 0.7rem; padding: 4px; }
|
||||
|
||||
.flow-pill {
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,82 +1,101 @@
|
||||
<template>
|
||||
<div class="http-exchange-demo">
|
||||
<div class="demo-container">
|
||||
<!-- Client Side -->
|
||||
<div class="panel client-panel">
|
||||
<div class="panel-header">
|
||||
<span class="icon">💻</span> Client (Browser)
|
||||
</div>
|
||||
<div class="browser-frame">
|
||||
<!-- Address Bar (Simplified) -->
|
||||
<div class="address-bar">
|
||||
<select v-model="method" class="method-select" :disabled="loading">
|
||||
<option>GET</option>
|
||||
<option>POST</option>
|
||||
<option>PUT</option>
|
||||
<option>DELETE</option>
|
||||
</select>
|
||||
<input v-model="path" class="url-input" :disabled="loading" />
|
||||
<button @click="sendRequest" :disabled="loading" class="send-btn">
|
||||
{{ loading ? '...' : t.send }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="request-builder">
|
||||
<div class="input-row">
|
||||
<select v-model="method" class="method-select">
|
||||
<option>GET</option>
|
||||
<option>POST</option>
|
||||
<option>PUT</option>
|
||||
<option>DELETE</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="path"
|
||||
class="path-input"
|
||||
placeholder="/index.html"
|
||||
/>
|
||||
<div class="split-view">
|
||||
<!-- Network Log (Left) -->
|
||||
<div class="network-log">
|
||||
<div class="log-header">
|
||||
<span>{{ t.cols.name }}</span>
|
||||
<span>{{ t.cols.status }}</span>
|
||||
<span>{{ t.cols.type }}</span>
|
||||
<span>{{ t.cols.time }}</span>
|
||||
</div>
|
||||
|
||||
<div class="headers-section">
|
||||
<div class="section-title">Headers</div>
|
||||
<div v-for="(value, key) in headers" :key="key" class="header-row">
|
||||
<span class="header-key">{{ key }}:</span>
|
||||
<span class="header-value">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="sendRequest"
|
||||
class="send-btn"
|
||||
:disabled="isProcessing"
|
||||
<div
|
||||
class="log-row"
|
||||
:class="{ active: requestSent, selected: true }"
|
||||
v-if="requestSent"
|
||||
>
|
||||
{{ isProcessing ? 'Sending...' : 'Send Request' }}
|
||||
</button>
|
||||
<span class="col-name">{{ path.split('/').pop() || 'index' }}</span>
|
||||
<span class="col-status" :class="statusClass">{{ responseStatus }}</span>
|
||||
<span class="col-type">document</span>
|
||||
<span class="col-time">{{ loading ? 'Pending' : '45ms' }}</span>
|
||||
</div>
|
||||
<div v-else class="empty-state">{{ t.noRequests }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Visualization -->
|
||||
<div class="network-space">
|
||||
<div class="connection-line"></div>
|
||||
<div
|
||||
v-if="currentPacket"
|
||||
class="packet"
|
||||
:class="currentPacket.type"
|
||||
:style="{ left: packetPosition + '%' }"
|
||||
>
|
||||
{{ currentPacket.label }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Details Panel (Right) -->
|
||||
<div class="details-panel" v-if="requestSent">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="tabKey in ['headers', 'response', 'preview']"
|
||||
:key="tabKey"
|
||||
:class="{ active: activeTab === tabKey }"
|
||||
@click="activeTab = tabKey"
|
||||
>
|
||||
{{ t.tabs[tabKey] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Server Side -->
|
||||
<div class="panel server-panel">
|
||||
<div class="panel-header"><span class="icon">🖥️</span> Server</div>
|
||||
|
||||
<div class="response-viewer" :class="{ empty: !response }">
|
||||
<div v-if="response">
|
||||
<div class="status-row" :class="statusClass">
|
||||
{{ response.status }} {{ response.statusText }}
|
||||
</div>
|
||||
<div class="headers-section">
|
||||
<div
|
||||
v-for="(value, key) in response.headers"
|
||||
:key="key"
|
||||
class="header-row"
|
||||
>
|
||||
<span class="header-key">{{ key }}:</span>
|
||||
<span class="header-value">{{ value }}</span>
|
||||
<div class="tab-content">
|
||||
<!-- Headers Tab -->
|
||||
<div v-if="activeTab === 'headers'" class="headers-view">
|
||||
<div class="section">
|
||||
<div class="section-title">{{ t.general }}</div>
|
||||
<div class="kv-row">
|
||||
<span class="key">{{ t.requestUrl }}:</span>
|
||||
<span class="value">https://api.example.com{{ path }}</span>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span class="key">{{ t.requestMethod }}:</span>
|
||||
<span class="value">{{ method }}</span>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span class="key">{{ t.statusCode }}:</span>
|
||||
<span class="value">
|
||||
<span class="status-dot" :class="statusClass"></span>
|
||||
{{ responseStatus || '...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">{{ t.responseHeaders }}</div>
|
||||
<div class="kv-row" v-for="(val, key) in responseHeaders" :key="key">
|
||||
<span class="key">{{ key }}:</span>
|
||||
<span class="value">{{ val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-preview">
|
||||
{{ response.body }}
|
||||
|
||||
<!-- Response Tab -->
|
||||
<div v-if="activeTab === 'response'" class="code-view">
|
||||
<pre>{{ responseBody }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Preview Tab -->
|
||||
<div v-if="activeTab === 'preview'" class="preview-view">
|
||||
<div v-if="method === 'GET'" class="html-preview" v-html="responseBody"></div>
|
||||
<div v-else class="json-preview">
|
||||
JSON Data: {{ responseBody }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="placeholder">Waiting for request...</div>
|
||||
</div>
|
||||
<div v-else class="details-placeholder">
|
||||
{{ t.placeholder }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,252 +105,260 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const method = ref('GET')
|
||||
const path = ref('/index.html')
|
||||
const isProcessing = ref(false)
|
||||
const packetPosition = ref(10)
|
||||
const currentPacket = ref(null)
|
||||
const response = ref(null)
|
||||
|
||||
const headers = ref({
|
||||
Host: 'www.example.com',
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Accept: 'text/html'
|
||||
const props = defineProps({
|
||||
lang: {
|
||||
type: String,
|
||||
default: 'zh'
|
||||
}
|
||||
})
|
||||
|
||||
const responses = {
|
||||
GET: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'text/html',
|
||||
Server: 'Nginx'
|
||||
},
|
||||
body: '<!DOCTYPE html>\n<html>\n <body>Hello World</body>\n</html>'
|
||||
const t = {
|
||||
send: '提交订单 (发送请求)',
|
||||
noRequests: '购物车是空的 (无请求)',
|
||||
placeholder: '点击 "提交订单" 向店员购买玩具',
|
||||
general: '订单详情 (General)',
|
||||
requestUrl: '商品地址 (URL)',
|
||||
requestMethod: '操作类型 (Method)',
|
||||
statusCode: '店员回复 (Status)',
|
||||
responseHeaders: '包裹标签 (Headers)',
|
||||
tabs: {
|
||||
headers: '订单信息',
|
||||
response: '包裹内容',
|
||||
preview: '玩具预览'
|
||||
},
|
||||
POST: {
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: '{"success": true, "id": 123}'
|
||||
cols: {
|
||||
name: '商品',
|
||||
status: '状态',
|
||||
type: '类型',
|
||||
time: '耗时'
|
||||
}
|
||||
}
|
||||
|
||||
const method = ref('GET')
|
||||
const path = ref('/toys/lego-castle')
|
||||
const loading = ref(false)
|
||||
const requestSent = ref(false)
|
||||
const activeTab = ref('headers')
|
||||
|
||||
const responseStatus = ref('')
|
||||
const responseBody = ref('')
|
||||
const responseHeaders = ref({})
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
requestSent.value = true
|
||||
responseStatus.value = '处理中...'
|
||||
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
|
||||
loading.value = false
|
||||
|
||||
if (method.value === 'GET') {
|
||||
responseStatus.value = '200 OK (有货)'
|
||||
responseHeaders.value = {
|
||||
'Content-Type': 'application/json (积木)',
|
||||
'Date': new Date().toLocaleString(),
|
||||
'Store': '乐高官方店'
|
||||
}
|
||||
responseBody.value = `{\n "id": 101,\n "name": "Lego Castle",\n "pieces": 500,\n "price": "$99"\n}`
|
||||
} else {
|
||||
responseStatus.value = '201 Created (下单成功)'
|
||||
responseHeaders.value = {
|
||||
'Content-Type': 'application/json',
|
||||
'Date': new Date().toLocaleString()
|
||||
}
|
||||
responseBody.value = `{\n "success": true,\n "message": "Order placed"\n}`
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (!response.value) return ''
|
||||
const code = response.value.status
|
||||
if (code >= 200 && code < 300) return 'success'
|
||||
if (code >= 400) return 'error'
|
||||
return ''
|
||||
if (loading.value) return 'pending'
|
||||
if (responseStatus.value.startsWith('2')) return 'success'
|
||||
return 'error'
|
||||
})
|
||||
|
||||
const sendRequest = () => {
|
||||
if (isProcessing.value) return
|
||||
isProcessing.value = true
|
||||
response.value = null
|
||||
|
||||
// Animate Request
|
||||
currentPacket.value = {
|
||||
type: 'request',
|
||||
label: `${method.value} ${path.value}`
|
||||
}
|
||||
animatePacket(10, 90, () => {
|
||||
// Server Processing
|
||||
setTimeout(() => {
|
||||
// Prepare Response
|
||||
const mockResponse = responses[method.value] || responses['GET']
|
||||
|
||||
// Animate Response
|
||||
currentPacket.value = {
|
||||
type: 'response',
|
||||
label: `${mockResponse.status} ${mockResponse.statusText}`
|
||||
}
|
||||
animatePacket(90, 10, () => {
|
||||
response.value = mockResponse
|
||||
currentPacket.value = null
|
||||
isProcessing.value = false
|
||||
})
|
||||
}, 800)
|
||||
})
|
||||
}
|
||||
|
||||
const animatePacket = (start, end, callback) => {
|
||||
let pos = start
|
||||
const step = (end - start) / 50
|
||||
const interval = setInterval(() => {
|
||||
pos += step
|
||||
packetPosition.value = pos
|
||||
|
||||
if ((step > 0 && pos >= end) || (step < 0 && pos <= end)) {
|
||||
clearInterval(interval)
|
||||
callback()
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.http-exchange-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-weight: bold;
|
||||
.address-bar {
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.network-space {
|
||||
width: 20%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connection-line {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
border-top: 1px dashed var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.packet {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.packet.request {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.packet.response {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.method-select {
|
||||
padding: 0.3rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
.url-input {
|
||||
flex: 1;
|
||||
padding: 0.3rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.headers-section {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
.split-view {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.response-viewer.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
.network-log {
|
||||
width: 40%;
|
||||
border-right: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.log-header span { flex: 1; }
|
||||
|
||||
.log-row {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.log-row.selected {
|
||||
background: #e0f2fe; /* Light blue */
|
||||
}
|
||||
|
||||
html.dark .log-row.selected {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.log-row span { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.col-status.success { color: #10b981; }
|
||||
.col-status.pending { color: #9ca3af; }
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
.details-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.details-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-bottom-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.status-row.success {
|
||||
color: #10b981;
|
||||
}
|
||||
.status-row.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.body-preview {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
.kv-row {
|
||||
display: flex;
|
||||
margin-bottom: 0.3rem;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.kv-row .key {
|
||||
width: 120px;
|
||||
color: var(--vp-c-text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kv-row .value {
|
||||
color: var(--vp-c-text-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.code-view pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.status-dot.success { background: #10b981; }
|
||||
.status-dot.pending { background: #9ca3af; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="imperative-declarative-demo">
|
||||
<div class="demo-grid">
|
||||
<!-- Imperative (jQuery Style) -->
|
||||
<div class="panel imperative">
|
||||
<div class="panel-header">
|
||||
<span class="badge yellow">Imperative (命令式)</span>
|
||||
<span class="sub-text">jQuery Style</span>
|
||||
</div>
|
||||
<div class="code-preview">
|
||||
<code>
|
||||
// 手动操作 DOM<br>
|
||||
$('#count').text(val);<br>
|
||||
if (val > 5) $('#msg').show();
|
||||
</code>
|
||||
</div>
|
||||
<div class="interactive-area">
|
||||
<div class="output-box">
|
||||
Count: <span id="imp-count-display">{{ impCount }}</span>
|
||||
<div v-show="impShowMsg" class="warning-msg">⚠️ Count is high!</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="impIncrement" class="btn">Step 1: Value++</button>
|
||||
<button @click="impUpdateText" class="btn" :disabled="!impChanged">Step 2: Update Text</button>
|
||||
<button @click="impCheckState" class="btn" :disabled="!impTextUpdated">Step 3: Check Logic</button>
|
||||
</div>
|
||||
<div class="status-log">{{ impStatus }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Declarative (Vue Style) -->
|
||||
<div class="panel declarative">
|
||||
<div class="panel-header">
|
||||
<span class="badge green">Declarative (声明式)</span>
|
||||
<span class="sub-text">Vue/React Style</span>
|
||||
</div>
|
||||
<div class="code-preview">
|
||||
<code>
|
||||
// 只需要绑定数据<br>
|
||||
{{ '{' + '{ count }' + '}' }}<br>
|
||||
<div v-if="count > 5">...</div>
|
||||
</code>
|
||||
</div>
|
||||
<div class="interactive-area">
|
||||
<div class="output-box">
|
||||
Count: <span>{{ decCount }}</span>
|
||||
<div v-if="decCount > 5" class="warning-msg">⚠️ Count is high!</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="decIncrement" class="btn primary">Value++ (Auto Render)</button>
|
||||
</div>
|
||||
<div class="status-log">Framework handles DOM updates automatically.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Imperative State
|
||||
const impCount = ref(0)
|
||||
const impShowMsg = ref(false)
|
||||
const impChanged = ref(false)
|
||||
const impTextUpdated = ref(false)
|
||||
const impStatus = ref('Ready.')
|
||||
|
||||
const impIncrement = () => {
|
||||
// Logic layer changes, but DOM doesn't
|
||||
impStatus.value = 'Variable `count` is now ' + (impCount.value + 1) + '. DOM is NOT updated.'
|
||||
impCount.value++
|
||||
impChanged.value = true
|
||||
impTextUpdated.value = false
|
||||
}
|
||||
|
||||
const impUpdateText = () => {
|
||||
// Manually update text
|
||||
impStatus.value = 'DOM text updated manually.'
|
||||
impChanged.value = false
|
||||
impTextUpdated.value = true
|
||||
}
|
||||
|
||||
const impCheckState = () => {
|
||||
// Manually check logic
|
||||
if (impCount.value > 5) {
|
||||
impShowMsg.value = true
|
||||
impStatus.value = 'Logic checked: > 5. Manually showing message.'
|
||||
} else {
|
||||
impShowMsg.value = false
|
||||
impStatus.value = 'Logic checked: <= 5. No message.'
|
||||
}
|
||||
}
|
||||
|
||||
// Declarative State
|
||||
const decCount = ref(0)
|
||||
const decIncrement = () => {
|
||||
decCount.value++
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.imperative-declarative-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.demo-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 0.8rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
.badge.yellow { background: #f59e0b; }
|
||||
.badge.green { background: #10b981; }
|
||||
|
||||
.sub-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
background: #1e1e2e;
|
||||
padding: 0.8rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #a6accd;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.interactive-area {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.output-box {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.warning-msg {
|
||||
color: #ef4444;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
animation: popIn 0.3s;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #f3f4f6; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn.primary { background: #3b82f6; color: white; border: none; }
|
||||
.btn.primary:hover { background: #2563eb; }
|
||||
|
||||
.status-log {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-style: italic;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
@keyframes popIn {
|
||||
0% { transform: scale(0.8); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +1,153 @@
|
||||
<template>
|
||||
<div class="tcp-handshake-demo">
|
||||
<div class="diagram">
|
||||
<!-- Client Column -->
|
||||
<div class="column client">
|
||||
<div class="actor-icon">💻 Client</div>
|
||||
<div class="state-label">{{ clientState }}</div>
|
||||
<div class="controls">
|
||||
<div class="status-indicator">
|
||||
{{ t.statusLabel }}: <span :class="connectionStatus.toLowerCase()">{{ statusText }}</span>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button v-if="step === 0" @click="startHandshake" class="action-btn">{{ t.connect }}</button>
|
||||
<button v-else @click="reset" class="reset-btn">{{ t.reset }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sequence-diagram">
|
||||
<!-- Client Timeline -->
|
||||
<div class="timeline client">
|
||||
<div class="actor">
|
||||
<span class="icon">💻</span>
|
||||
<span class="name">{{ t.client }}</span>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="state-marker" :class="{ active: step >= 1 }">SYN_SENT</div>
|
||||
<div class="state-marker" :class="{ active: step >= 3 }">ESTABLISHED</div>
|
||||
</div>
|
||||
|
||||
<!-- Interaction Area -->
|
||||
<div class="interaction-zone">
|
||||
<!-- Step 1: SYN -->
|
||||
<div class="packet-row" :class="{ active: step === 1, done: step > 1 }">
|
||||
<button
|
||||
@click="sendSyn"
|
||||
:disabled="step !== 0"
|
||||
class="packet-btn syn"
|
||||
>
|
||||
SYN (SEQ=x) →
|
||||
</button>
|
||||
<div class="interaction-space">
|
||||
<!-- SYN Packet -->
|
||||
<div class="packet-track">
|
||||
<transition name="slide-right">
|
||||
<div v-if="showSyn" class="packet syn">
|
||||
<div class="packet-body">SYN</div>
|
||||
<div class="packet-detail">SEQ=0</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: SYN-ACK -->
|
||||
<div
|
||||
class="packet-row reverse"
|
||||
:class="{ active: step === 2, done: step > 2 }"
|
||||
>
|
||||
<button
|
||||
@click="sendSynAck"
|
||||
:disabled="step !== 1"
|
||||
class="packet-btn syn-ack"
|
||||
>
|
||||
← SYN-ACK (ACK=x+1, SEQ=y)
|
||||
</button>
|
||||
<!-- SYN-ACK Packet -->
|
||||
<div class="packet-track reverse">
|
||||
<transition name="slide-left">
|
||||
<div v-if="showSynAck" class="packet syn-ack">
|
||||
<div class="packet-body">SYN-ACK</div>
|
||||
<div class="packet-detail">SEQ=0, ACK=1</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: ACK -->
|
||||
<div class="packet-row" :class="{ active: step === 3, done: step > 3 }">
|
||||
<button
|
||||
@click="sendAck"
|
||||
:disabled="step !== 2"
|
||||
class="packet-btn ack"
|
||||
>
|
||||
ACK (ACK=y+1) →
|
||||
</button>
|
||||
<!-- ACK Packet -->
|
||||
<div class="packet-track">
|
||||
<transition name="slide-right">
|
||||
<div v-if="showAck" class="packet ack">
|
||||
<div class="packet-body">ACK</div>
|
||||
<div class="packet-detail">SEQ=1, ACK=1</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Column -->
|
||||
<div class="column server">
|
||||
<div class="actor-icon">🖥️ Server</div>
|
||||
<div class="state-label">{{ serverState }}</div>
|
||||
<!-- Server Timeline -->
|
||||
<div class="timeline server">
|
||||
<div class="actor">
|
||||
<span class="icon">🖥️</span>
|
||||
<span class="name">{{ t.server }}</span>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="state-marker" :class="{ active: step >= 2 }">SYN_RCVD</div>
|
||||
<div class="state-marker" :class="{ active: step >= 3 }">ESTABLISHED</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-message">
|
||||
<p v-if="step === 0">点击 <strong>SYN</strong> 开始连接。</p>
|
||||
<p v-if="step === 1">
|
||||
服务器收到了请求,现在需要回复 <strong>SYN-ACK</strong>。
|
||||
</p>
|
||||
<p v-if="step === 2">
|
||||
客户端收到了确认,最后发送 <strong>ACK</strong> 完成握手。
|
||||
</p>
|
||||
<p v-if="step === 3" class="success">🎉 连接已建立 (ESTABLISHED)!</p>
|
||||
<div class="description-box">
|
||||
<p>{{ currentDescription }}</p>
|
||||
</div>
|
||||
|
||||
<button v-if="step === 3" @click="reset" class="reset-btn">Reset</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
lang: {
|
||||
type: String,
|
||||
default: 'zh'
|
||||
}
|
||||
})
|
||||
|
||||
// Bilingual text directly
|
||||
const t = {
|
||||
statusLabel: '通话状态',
|
||||
connect: '拨打电话',
|
||||
reset: '挂断重拨',
|
||||
client: '我 (顾客)',
|
||||
server: '玩具店',
|
||||
status: {
|
||||
closed: '未通话',
|
||||
handshaking: '正在拨号...',
|
||||
established: '通话中 (连接已建立)'
|
||||
},
|
||||
steps: {
|
||||
0: '点击 "拨打电话" 开始确认店铺是否营业。',
|
||||
1: '步骤 1: 我问 "喂?有人在吗?" (SYN)',
|
||||
2: '步骤 2: 店员答 "在的!请问有什么事?" (SYN-ACK)',
|
||||
3: '步骤 3: 我说 "太好了,我想买东西!" (ACK)'
|
||||
}
|
||||
}
|
||||
|
||||
const step = ref(0)
|
||||
const clientState = ref('CLOSED')
|
||||
const serverState = ref('LISTEN')
|
||||
const showSyn = ref(false)
|
||||
const showSynAck = ref(false)
|
||||
const showAck = ref(false)
|
||||
|
||||
const sendSyn = () => {
|
||||
const connectionStatus = computed(() => {
|
||||
if (step.value === 0) return 'closed'
|
||||
if (step.value < 3) return 'handshaking'
|
||||
return 'established'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
const s = connectionStatus.value
|
||||
return t.status[s] || s.toUpperCase()
|
||||
})
|
||||
|
||||
const currentDescription = computed(() => {
|
||||
return t.steps[step.value] || ''
|
||||
})
|
||||
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
const startHandshake = async () => {
|
||||
if (step.value > 0) return
|
||||
|
||||
// Step 1: SYN
|
||||
step.value = 1
|
||||
clientState.value = 'SYN_SENT'
|
||||
}
|
||||
|
||||
const sendSynAck = () => {
|
||||
showSyn.value = true
|
||||
await wait(1500)
|
||||
|
||||
// Step 2: SYN-ACK
|
||||
step.value = 2
|
||||
serverState.value = 'SYN_RCVD'
|
||||
}
|
||||
|
||||
const sendAck = () => {
|
||||
showSynAck.value = true
|
||||
await wait(1500)
|
||||
|
||||
// Step 3: ACK
|
||||
step.value = 3
|
||||
clientState.value = 'ESTABLISHED'
|
||||
serverState.value = 'ESTABLISHED'
|
||||
showAck.value = true
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
step.value = 0
|
||||
clientState.value = 'CLOSED'
|
||||
serverState.value = 'LISTEN'
|
||||
showSyn.value = false
|
||||
showSynAck.value = false
|
||||
showAck.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -102,127 +155,200 @@ const reset = () => {
|
||||
.tcp-handshake-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.diagram {
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.column {
|
||||
width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actor-icon {
|
||||
font-size: 1.2rem;
|
||||
.status-indicator {
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-indicator span.closed { color: var(--vp-c-text-3); }
|
||||
.status-indicator span.handshaking { color: #f59e0b; }
|
||||
.status-indicator span.established { color: #10b981; }
|
||||
|
||||
.state-label {
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.interaction-zone {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.packet-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
opacity: 0.3;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.packet-row.reverse {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.packet-row.active {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.packet-row.done {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.packet-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.packet-btn:not(:disabled):hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.packet-btn.syn {
|
||||
.action-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.packet-btn.syn-ack {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
.packet-btn.ack {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.packet-btn:disabled {
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: not-allowed;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
height: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.status-message .success {
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
.sequence-diagram {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
z-index: 2;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.timeline .line {
|
||||
width: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.state-marker {
|
||||
margin-top: 2rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.state-marker.active {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.interaction-space {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.packet-track {
|
||||
height: 40px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.packet-track.reverse {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.packet {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.packet.syn-ack { background: #f59e0b; }
|
||||
.packet.ack { background: #10b981; }
|
||||
|
||||
.packet-body { font-weight: bold; }
|
||||
.packet-detail { font-size: 0.7rem; opacity: 0.9; }
|
||||
|
||||
/* Animations */
|
||||
.slide-right-enter-active {
|
||||
animation: slide-right 1.5s linear;
|
||||
}
|
||||
.slide-left-enter-active {
|
||||
animation: slide-left 1.5s linear;
|
||||
}
|
||||
|
||||
@keyframes slide-right {
|
||||
0% { transform: translateX(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateX(100%); opacity: 1; } /* Not quite right, need to stick */
|
||||
}
|
||||
|
||||
/*
|
||||
Vue transitions are tricky for "moving across".
|
||||
Let's use a simpler approach: CSS transitions on left/right property or keyframes.
|
||||
Actually, for a "send" animation, we want it to move from A to B and then stay or disappear.
|
||||
Here I want it to appear and move.
|
||||
*/
|
||||
|
||||
.slide-right-enter-active,
|
||||
.slide-left-enter-active {
|
||||
transition: all 1.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
transform: translateX(-150px);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-right-enter-to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* This is getting complicated with Vue transitions for simple movement.
|
||||
Let's just use CSS keyframes on the element itself when it renders.
|
||||
*/
|
||||
|
||||
.packet {
|
||||
animation-duration: 1s;
|
||||
animation-fill-mode: forwards;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.packet-track .packet {
|
||||
animation-name: moveRight;
|
||||
}
|
||||
.packet-track.reverse .packet {
|
||||
animation-name: moveLeft;
|
||||
}
|
||||
|
||||
@keyframes moveRight {
|
||||
from { transform: translateX(-100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes moveLeft {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.description-box {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
min-height: 3rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,129 @@
|
||||
<template>
|
||||
<div class="url-parser-demo">
|
||||
<div class="control-panel">
|
||||
<div class="input-group">
|
||||
<label>输入 URL</label>
|
||||
<div class="browser-bar">
|
||||
<div class="nav-buttons">
|
||||
<span class="nav-btn">←</span>
|
||||
<span class="nav-btn">→</span>
|
||||
<span class="nav-btn">↻</span>
|
||||
</div>
|
||||
<div class="omnibox">
|
||||
<span class="lock-icon">🔒</span>
|
||||
<!-- Segmented URL Display -->
|
||||
<div class="segmented-url" v-if="parsedUrl">
|
||||
<span
|
||||
class="url-part protocol"
|
||||
:class="{ active: highlightedPart === 'protocol' }"
|
||||
@mouseover="highlightedPart = 'protocol'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.protocol }}:</span>
|
||||
<span class="divider">//</span>
|
||||
<span
|
||||
class="url-part host"
|
||||
:class="{ active: highlightedPart === 'host' }"
|
||||
@mouseover="highlightedPart = 'host'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.host }}</span>
|
||||
<span
|
||||
v-if="parts.port"
|
||||
class="url-part port"
|
||||
:class="{ active: highlightedPart === 'port' }"
|
||||
@mouseover="highlightedPart = 'port'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>:{{ parts.port }}</span>
|
||||
<span
|
||||
class="url-part pathname"
|
||||
:class="{ active: highlightedPart === 'pathname' }"
|
||||
@mouseover="highlightedPart = 'pathname'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.pathname }}</span>
|
||||
<span
|
||||
v-if="parts.search"
|
||||
class="url-part search"
|
||||
:class="{ active: highlightedPart === 'search' }"
|
||||
@mouseover="highlightedPart = 'search'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.search }}</span>
|
||||
<span
|
||||
v-if="parts.hash"
|
||||
class="url-part hash"
|
||||
:class="{ active: highlightedPart === 'hash' }"
|
||||
@mouseover="highlightedPart = 'hash'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.hash }}</span>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
v-model="inputUrl"
|
||||
type="text"
|
||||
placeholder="https://www.example.com:8080/path?query=1#fragment"
|
||||
class="url-input"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="encoding-toggle">
|
||||
<button @click="encodeUrl" class="action-btn">Encode</button>
|
||||
<button @click="decodeUrl" class="action-btn">Decode</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div v-if="parsedUrl" class="url-parts">
|
||||
<div
|
||||
v-for="(part, key) in parts"
|
||||
<div v-if="parsedUrl" class="url-breakdown">
|
||||
<div
|
||||
v-for="(part, key) in parts"
|
||||
:key="key"
|
||||
class="url-part"
|
||||
:class="key"
|
||||
class="url-segment"
|
||||
:class="[key, { active: highlightedPart === key }]"
|
||||
@mouseover="highlightedPart = key"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>
|
||||
<div class="part-label">{{ labels[key] }}</div>
|
||||
<div class="part-value">{{ part || '-' }}</div>
|
||||
<div class="part-desc" v-if="highlightedPart === key">
|
||||
{{ descriptions[key] }}
|
||||
<div class="segment-header">
|
||||
<span class="segment-icon">{{ icons[key] }}</span>
|
||||
<span class="segment-label">{{ labels[key] }}</span>
|
||||
</div>
|
||||
<div class="segment-value">{{ part || '-' }}</div>
|
||||
<div class="segment-desc">{{ descriptions[key] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="error-message">无效的 URL 格式</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>Note:</strong>
|
||||
URL (统一资源定位符)
|
||||
是互联网资源的地址。浏览器首先需要将它拆解成不同的部分,才能知道要去哪里(域名)、用什么方式(协议)、找什么东西(路径)。
|
||||
</p>
|
||||
<div v-else class="error-state">
|
||||
Invalid URL format / 无效的 URL 格式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const inputUrl = ref(
|
||||
'https://www.example.com:8080/search/results?q=vue&page=1#top'
|
||||
)
|
||||
const props = defineProps({
|
||||
lang: {
|
||||
type: String,
|
||||
default: 'zh'
|
||||
}
|
||||
})
|
||||
|
||||
const inputUrl = ref('https://shop.com/toys/lego-castle?color=red#summary')
|
||||
const highlightedPart = ref(null)
|
||||
|
||||
const icons = {
|
||||
protocol: '🚛',
|
||||
host: '🏢',
|
||||
port: '🚪',
|
||||
pathname: '🧸',
|
||||
search: '📝',
|
||||
hash: '📍'
|
||||
}
|
||||
|
||||
const labels = {
|
||||
protocol: '协议 (Protocol)',
|
||||
host: '域名 (Host)',
|
||||
port: '端口 (Port)',
|
||||
pathname: '路径 (Path)',
|
||||
search: '查询 (Query)',
|
||||
hash: '锚点 (Fragment)'
|
||||
protocol: '交通方式 (Protocol)',
|
||||
host: '店铺地址 (Host)',
|
||||
port: '大门号 (Port)',
|
||||
pathname: '商品位置 (Path)',
|
||||
search: '备注要求 (Query)',
|
||||
hash: '快速定位 (Hash)'
|
||||
}
|
||||
|
||||
const descriptions = {
|
||||
protocol: '告诉浏览器使用什么方式连接(如 https 安全连接)',
|
||||
host: '服务器的地址,需要通过 DNS 解析为 IP',
|
||||
port: '服务器的门牌号(http默认80,https默认443)',
|
||||
pathname: '资源在服务器上的具体位置',
|
||||
search: '传递给服务器的额外参数',
|
||||
hash: '页面内的定位标记,不会发送给服务器'
|
||||
protocol: '怎么去?(例如 https = 坐装甲车去,很安全)',
|
||||
host: '去哪家店?(域名,例如 shop.com)',
|
||||
port: '从哪个门进?(默认 443 号门)',
|
||||
pathname: '商品在哪个货架?(路径)',
|
||||
search: '给店员的备注 (例如 ?color=red)',
|
||||
hash: '直接翻到说明书第几页 (锚点)'
|
||||
}
|
||||
|
||||
const parsedUrl = computed(() => {
|
||||
@@ -86,162 +139,173 @@ const parts = computed(() => {
|
||||
return {
|
||||
protocol: parsedUrl.value.protocol.replace(':', ''),
|
||||
host: parsedUrl.value.hostname,
|
||||
port:
|
||||
parsedUrl.value.port ||
|
||||
(parsedUrl.value.protocol === 'https:' ? '443' : '80'),
|
||||
port: parsedUrl.value.port || (parsedUrl.value.protocol === 'https:' ? '443' : '80'),
|
||||
pathname: parsedUrl.value.pathname,
|
||||
search: parsedUrl.value.search,
|
||||
hash: parsedUrl.value.hash
|
||||
}
|
||||
})
|
||||
|
||||
const encodeUrl = () => {
|
||||
inputUrl.value = encodeURI(inputUrl.value)
|
||||
}
|
||||
|
||||
const decodeUrl = () => {
|
||||
inputUrl.value = decodeURI(inputUrl.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.url-parser-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
background-color: var(--vp-c-bg);
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 2rem;
|
||||
.browser-bar {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.8rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 1.2rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.omnibox {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 20px;
|
||||
padding: 0.4rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Segmented URL Styles */
|
||||
.segmented-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.url-part {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.url-part:hover, .url-part.active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.url-part.protocol { color: #ef4444; }
|
||||
.url-part.host { color: #3b82f6; }
|
||||
.url-part.port { color: #f59e0b; }
|
||||
.url-part.pathname { color: #10b981; }
|
||||
.url-part.search { color: #8b5cf6; }
|
||||
.url-part.hash { color: #ec4899; }
|
||||
|
||||
.url-part.active.protocol { background: #fef2f2; }
|
||||
.url-part.active.host { background: #eff6ff; }
|
||||
.url-part.active.port { background: #fffbeb; }
|
||||
.url-part.active.pathname { background: #ecfdf5; }
|
||||
.url-part.active.search { background: #f5f3ff; }
|
||||
.url-part.active.hash { background: #fdf2f8; }
|
||||
|
||||
.divider {
|
||||
color: var(--vp-c-text-3);
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.url-breakdown {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
.url-segment {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent; /* Prepare for border color */
|
||||
background: var(--vp-c-bg-alt);
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: monospace;
|
||||
width: 100%;
|
||||
.url-segment.active {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.encoding-toggle {
|
||||
/* Color Coding for Cards */
|
||||
.url-segment.protocol { border-color: #ef4444; }
|
||||
.url-segment.host { border-color: #3b82f6; }
|
||||
.url-segment.port { border-color: #f59e0b; }
|
||||
.url-segment.pathname { border-color: #10b981; }
|
||||
.url-segment.search { border-color: #8b5cf6; }
|
||||
.url-segment.hash { border-color: #ec4899; }
|
||||
|
||||
.url-segment.active.protocol { background: #fef2f2; }
|
||||
.url-segment.active.host { background: #eff6ff; }
|
||||
.url-segment.active.port { background: #fffbeb; }
|
||||
.url-segment.active.pathname { background: #ecfdf5; }
|
||||
.url-segment.active.search { background: #f5f3ff; }
|
||||
.url-segment.active.hash { background: #fdf2f8; }
|
||||
|
||||
.segment-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
.segment-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
margin-bottom: 1.5rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.url-parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.url-part {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.url-part:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.url-part.protocol {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
.url-part.host {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
.url-part.port {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
.url-part.pathname {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
.url-part.search {
|
||||
border-left: 4px solid #8b5cf6;
|
||||
}
|
||||
.url-part.hash {
|
||||
border-left: 4px solid #ec4899;
|
||||
}
|
||||
|
||||
.part-label {
|
||||
.segment-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.part-value {
|
||||
font-size: 1rem;
|
||||
.segment-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.part-desc {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--vp-c-text-inverse-1);
|
||||
color: var(--vp-c-text-inverse-2);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
.segment-desc {
|
||||
font-size: 0.8rem;
|
||||
z-index: 10;
|
||||
margin-top: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
.error-state {
|
||||
text-align: center;
|
||||
color: #ef4444;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
<template>
|
||||
<div class="url-to-browser-demo">
|
||||
<div class="stage-nav">
|
||||
<div class="stage-tracker">
|
||||
<button
|
||||
v-for="(stage, index) in stages"
|
||||
:key="index"
|
||||
:class="{ active: currentStage === index }"
|
||||
class="tracker-node"
|
||||
:class="{ active: currentStage === index, visited: currentStage > index }"
|
||||
@click="currentStage = index"
|
||||
>
|
||||
<span class="stage-num">{{ index + 1 }}</span>
|
||||
<span class="stage-name">{{ stage.name }}</span>
|
||||
<div class="node-circle">
|
||||
<span class="icon">{{ stage.icon }}</span>
|
||||
</div>
|
||||
<span class="node-label">{{ stage.name }}</span>
|
||||
</button>
|
||||
<div class="tracker-line">
|
||||
<div class="line-fill" :style="{ width: (currentStage / (stages.length - 1)) * 100 + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-content">
|
||||
<transition name="fade" mode="out-in">
|
||||
<div :key="currentStage" class="stage-viz">
|
||||
<div class="viz-icon">{{ stages[currentStage].icon }}</div>
|
||||
<div class="viz-desc">
|
||||
<h3>{{ stages[currentStage].title }}</h3>
|
||||
<p>{{ stages[currentStage].desc }}</p>
|
||||
</div>
|
||||
<div class="viz-action">
|
||||
<component
|
||||
:is="stages[currentStage].component"
|
||||
v-if="stages[currentStage].component"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="stage-display">
|
||||
<div class="header">
|
||||
<h2>{{ stages[currentStage].title }}</h2>
|
||||
<p>{{ stages[currentStage].desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="component-wrapper">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component
|
||||
:is="stages[currentStage].component"
|
||||
:key="currentStage"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="action-footer" v-if="currentStage < stages.length - 1">
|
||||
<button class="next-btn" @click="nextStage">
|
||||
下一步 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,117 +50,185 @@ const currentStage = ref(0)
|
||||
const stages = [
|
||||
{
|
||||
name: 'URL',
|
||||
title: 'Parsing the Address',
|
||||
desc: 'Browser breaks down the URL to understand protocol, domain, and path.',
|
||||
icon: '🔍',
|
||||
title: '1. 填写购物单 (URL)',
|
||||
desc: '你想买一个玩具。首先要在订单上写清楚:去哪家店 (域名)、买什么 (路径)、用什么快递 (协议)。',
|
||||
icon: '📝',
|
||||
component: 'UrlParserDemo'
|
||||
},
|
||||
{
|
||||
name: 'DNS',
|
||||
title: 'Finding the IP',
|
||||
desc: 'Browser asks DNS servers to translate the domain name into an IP address.',
|
||||
icon: '🌐',
|
||||
title: '2. 查找店铺地址 (DNS)',
|
||||
desc: '快递员不知道 "玩具店" 在哪。他需要查地图 (DNS),把店名翻译成具体的 GPS 坐标 (IP 地址)。',
|
||||
icon: '🧭',
|
||||
component: 'DnsLookupDemo'
|
||||
},
|
||||
{
|
||||
name: 'TCP',
|
||||
title: 'Establishing Connection',
|
||||
desc: 'Browser and Server perform a 3-way handshake to create a reliable connection.',
|
||||
icon: '🤝',
|
||||
title: '3. 建立通话 (TCP)',
|
||||
desc: '找到店了!进店前先敲门确认:"有人吗?" "有!" "那我进来了!"。确保连接通畅,不会白跑一趟。',
|
||||
icon: '📞',
|
||||
component: 'TcpHandshakeDemo'
|
||||
},
|
||||
{
|
||||
name: 'HTTP',
|
||||
title: 'Exchanging Data',
|
||||
desc: 'Browser sends a request, and the server sends back the website content.',
|
||||
icon: '📨',
|
||||
title: '4. 购买商品 (HTTP)',
|
||||
desc: '进店后,你递交订单:"我要这个玩具"。店员去仓库找货,最后把装有玩具的包裹 (HTML) 递给你。',
|
||||
icon: '📦',
|
||||
component: 'HttpExchangeDemo'
|
||||
},
|
||||
{
|
||||
name: 'Render',
|
||||
title: 'Painting the Page',
|
||||
desc: 'Browser parses HTML/CSS and paints pixels on your screen.',
|
||||
icon: '🎨',
|
||||
title: '5. 拆盒组装 (渲染)',
|
||||
desc: '回到家,拆开包裹。照着说明书 (HTML),把积木 (DOM) 搭起来,涂上颜色 (CSS),玩具就变好看了!',
|
||||
icon: '🧩',
|
||||
component: 'BrowserRenderingDemo'
|
||||
}
|
||||
]
|
||||
|
||||
const nextStage = () => {
|
||||
if (currentStage.value < stages.length - 1) {
|
||||
currentStage.value++
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.url-to-browser-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
overflow: hidden;
|
||||
margin: 2rem 0;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.stage-nav {
|
||||
.stage-tracker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
padding: 2rem 2rem 1rem;
|
||||
position: relative;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.stage-nav::before {
|
||||
content: '';
|
||||
.tracker-line {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 3.2rem; /* Adjusted for padding */
|
||||
left: 3.5rem;
|
||||
right: 3.5rem;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.stage-nav button {
|
||||
.line-fill {
|
||||
height: 100%;
|
||||
background: var(--vp-c-brand);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.tracker-node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 20px;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.stage-nav button.active {
|
||||
.node-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tracker-node.visited .node-circle {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tracker-node.active .node-circle {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 4px var(--vp-c-brand-dimm);
|
||||
transform: scale(1.1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.stage-num {
|
||||
.node-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.stage-content {
|
||||
.tracker-node.active .node-label {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stage-display {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
border: none;
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.component-wrapper {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
/* padding: 1rem; */
|
||||
}
|
||||
|
||||
.viz-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
.action-footer {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.viz-desc h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-1);
|
||||
.next-btn {
|
||||
padding: 0.8rem 2rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.viz-desc p {
|
||||
color: var(--vp-c-text-2);
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
.next-btn:hover {
|
||||
background: var(--vp-c-brand-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
|
||||
@@ -23,6 +23,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🎨 交互式配置面板 -->
|
||||
<div class="config-panel" v-if="current === 'css'">
|
||||
<div class="config-header">
|
||||
<div class="config-title">🎨 教学演示参数调整 (仅 CSS 模式生效)</div>
|
||||
<button class="reset-btn" @click="resetColors">重置默认</button>
|
||||
</div>
|
||||
<div class="config-items">
|
||||
<div class="config-item">
|
||||
<label>Primary Color (主题色)</label>
|
||||
<div class="input-group">
|
||||
<input type="color" v-model="customColors.primary" />
|
||||
<input type="text" v-model="customColors.primary" class="hex-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Text Color (文字色)</label>
|
||||
<div class="input-group">
|
||||
<input type="color" v-model="customColors.text" />
|
||||
<input type="text" v-model="customColors.text" class="hex-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Button Text (按钮文字)</label>
|
||||
<div class="input-group">
|
||||
<input type="color" v-model="customColors.btnText" />
|
||||
<input type="text" v-model="customColors.btnText" class="hex-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview" :class="current">
|
||||
<div class="hint">点一下标题/段落/按钮,我会在下面的代码里高亮对应行。</div>
|
||||
<h1
|
||||
@@ -98,7 +129,48 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, reactive } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
primaryColor: {
|
||||
type: String,
|
||||
default: '#0ea5e9'
|
||||
},
|
||||
textColor: {
|
||||
type: String,
|
||||
default: '#111827'
|
||||
},
|
||||
btnTextColor: {
|
||||
type: String,
|
||||
default: '#fff'
|
||||
}
|
||||
})
|
||||
|
||||
// 🎨 用户自定义颜色状态
|
||||
const customColors = reactive({
|
||||
primary: props.primaryColor,
|
||||
text: props.textColor,
|
||||
btnText: props.btnTextColor
|
||||
})
|
||||
|
||||
// 重置为默认值
|
||||
const resetColors = () => {
|
||||
customColors.primary = props.primaryColor
|
||||
customColors.text = props.textColor
|
||||
customColors.btnText = props.btnTextColor
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 🔧 演示参数配置 (Demo Configuration)
|
||||
// =============================================================================
|
||||
// 优先使用用户自定义的颜色,如果有变化的话
|
||||
const DEMO_CONFIG = computed(() => ({
|
||||
colors: {
|
||||
primary: customColors.primary,
|
||||
text: customColors.text,
|
||||
btnText: customColors.btnText
|
||||
}
|
||||
}))
|
||||
|
||||
const modes = [
|
||||
{ id: 'html', label: '看骨架 (HTML)' },
|
||||
@@ -126,9 +198,9 @@ const codeLines = computed(() => {
|
||||
}
|
||||
if (current.value === 'css') {
|
||||
return [
|
||||
{ key: 'h1', text: '.hero { color: #0ea5e9; font-size: 24px; }' },
|
||||
{ key: 'p', text: '.desc { color: #111827; }' },
|
||||
{ key: 'btn', text: '.cta { background: #0ea5e9; color: #fff; border-radius: 10px; }' }
|
||||
{ key: 'h1', text: `.hero { color: ${DEMO_CONFIG.value.colors.primary}; font-size: 24px; }` },
|
||||
{ key: 'p', text: `.desc { color: ${DEMO_CONFIG.value.colors.text}; }` },
|
||||
{ key: 'btn', text: `.cta { background: ${DEMO_CONFIG.value.colors.primary}; color: ${DEMO_CONFIG.value.colors.btnText}; border-radius: 10px; }` }
|
||||
]
|
||||
}
|
||||
return [
|
||||
@@ -211,6 +283,91 @@ const increment = () => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* New Config Panel Styles */
|
||||
.config-panel {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.config-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hex-input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 13px;
|
||||
width: 65px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.reset-btn:hover {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.top { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.title { font-weight: 800; font-size: 16px; }
|
||||
.subtitle { color: var(--vp-c-text-2); font-size: 13px; margin-top: 4px; }
|
||||
@@ -282,18 +439,43 @@ const increment = () => {
|
||||
|
||||
.click-tip { margin-top: 6px; color: var(--vp-c-text-2); font-size: 13px; }
|
||||
|
||||
.preview.css .hero { color: #0ea5e9; }
|
||||
.preview.css .desc { color: var(--vp-c-text-1); }
|
||||
.preview.css .cta { background: #0ea5e9; color: #fff; border-color: #0ea5e9; box-shadow: 0 4px 12px rgba(14,165,233,0.25); }
|
||||
.preview.css .hero { color: v-bind('DEMO_CONFIG.colors.primary'); font-size: 24px; }
|
||||
.preview.css .desc { color: v-bind('DEMO_CONFIG.colors.text'); }
|
||||
.preview.css .cta {
|
||||
background: v-bind('DEMO_CONFIG.colors.primary');
|
||||
color: v-bind('DEMO_CONFIG.colors.btnText');
|
||||
border-color: v-bind('DEMO_CONFIG.colors.primary');
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.preview.js .cta { background: #22c55e; color: #fff; border-color: #22c55e; box-shadow: 0 4px 12px rgba(34,197,94,0.25); }
|
||||
.preview.js { border-color: rgba(34, 197, 94, 0.4); }
|
||||
|
||||
.code-block { background: var(--vp-c-bg-alt); border: 1px solid var(--vp-c-divider); border-radius: 10px; padding: 16px; }
|
||||
.code-title { font-weight: 700; margin-bottom: 8px; font-size: 13px; color: var(--vp-c-text-2); }
|
||||
pre { margin: 0; background: #0b1221; color: #e5e7eb; border-radius: 8px; padding: 16px; font-family: var(--vp-font-family-mono); font-size: 13px; overflow-x: auto; line-height: 1.6; }
|
||||
.code-content {
|
||||
background: #0b1221;
|
||||
color: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.line { min-height: 1.6em; }
|
||||
.hl { background: rgba(34, 197, 94, 0.2); border-radius: 4px; display: block; width: 100%; }
|
||||
.hl {
|
||||
background: var(--vp-c-brand-dimm);
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-left: 8px; /* Offset text to account for border */
|
||||
font-weight: bold; /* Make text bolder */
|
||||
/* color: white; Removed to fix visibility issue on light theme if brand-dimm is light */
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); /* Add subtle shadow */
|
||||
}
|
||||
|
||||
.explain { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
|
||||
.card { background: var(--vp-c-bg); border: 1px dashed var(--vp-c-divider); border-radius: 10px; padding: 10px; }
|
||||
|
||||
Reference in New Issue
Block a user