feat(docs): add Netlify deployment guide and data encoding demos
- Add Netlify deployment section with form handling and functions examples - Replace old Git demos with new interactive components - Add comprehensive data encoding visualization demos - Update comparison table with Netlify information
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div class="audio-encoding-demo">
|
||||
<div class="demo-header">
|
||||
<span class="demo-title">声音是如何变成数字的?</span>
|
||||
<span class="demo-subtitle">(拖拽滑块调整采样率)</span>
|
||||
</div>
|
||||
|
||||
<div class="controls-panel">
|
||||
<div class="slider-group">
|
||||
<label>采样频率:{{ sampleRate }} 次/秒</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model="sliderValue"
|
||||
min="1"
|
||||
max="50"
|
||||
step="1"
|
||||
class="range-slider"
|
||||
>
|
||||
<div class="scale-marks">
|
||||
<span>低音质 (严重失真)</span>
|
||||
<span>高音质 (贴近原声)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wave-visualization">
|
||||
<!-- Continuous Wave Shape (Analog) -->
|
||||
<svg class="analog-wave" viewBox="0 0 500 100" preserveAspectRatio="none">
|
||||
<path :d="analogPath" fill="none" stroke="var(--vp-c-divider)" stroke-width="2" stroke-dasharray="4" />
|
||||
</svg>
|
||||
|
||||
<!-- Digital Samples (Bars) -->
|
||||
<div class="digital-samples">
|
||||
<div
|
||||
v-for="(sample, i) in samples"
|
||||
:key="i"
|
||||
class="sample-bar"
|
||||
:style="{
|
||||
left: `${sample.x}%`,
|
||||
height: `${Math.abs(sample.y)}%`,
|
||||
bottom: sample.y >= 0 ? '50%' : 'auto',
|
||||
top: sample.y < 0 ? '50%' : 'auto',
|
||||
width: `${100 / sampleRate}%`
|
||||
}"
|
||||
>
|
||||
<div class="sample-dot" :class="{ 'positive': sample.y >= 0, 'negative': sample.y < 0 }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-stream">
|
||||
<div class="stream-label">转译后的数字(高度):</div>
|
||||
<div class="stream-numbers">
|
||||
<span v-for="(s, i) in displayedNumbers" :key="i" class="num">{{ s }}</span>
|
||||
<span v-if="samples.length > 15" class="num">...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-insight">
|
||||
说明:灰色的虚线是真实的连贯声波(大自然的模拟信号)。蓝色柱子是我们每隔一段时间去测量它的高度(数字信号)。采样频率越密集,记录下来的数字就越多,恢复出来的声音就越清晰逼真,但产生的文件也随之飙升。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const sliderValue = ref(8)
|
||||
const sampleRate = computed(() => Number(sliderValue.value))
|
||||
|
||||
// Generate a smooth sine wave path for the SVG
|
||||
const analogPath = computed(() => {
|
||||
let path = 'M 0 50 '
|
||||
for (let x = 0; x <= 500; x += 5) {
|
||||
// Generate a compound wave
|
||||
const normalizedX = x / 500
|
||||
const y = Math.sin(normalizedX * Math.PI * 4) * 35 + Math.sin(normalizedX * Math.PI * 8) * 10
|
||||
path += `L ${x} ${50 - y} `
|
||||
}
|
||||
return path
|
||||
})
|
||||
|
||||
// Generate discrete samples
|
||||
const samples = computed(() => {
|
||||
const result = []
|
||||
const count = sampleRate.value
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const normalizedX = i / count
|
||||
// Same compound wave formula
|
||||
const rawY = Math.sin(normalizedX * Math.PI * 4) * 35 + Math.sin(normalizedX * Math.PI * 8) * 10
|
||||
// Map to percentage of height (0 to 50 for max amplitude)
|
||||
result.push({
|
||||
x: normalizedX * 100,
|
||||
y: rawY, // -45 to +45 roughly
|
||||
val: Math.round(rawY * 1.5) // scaled value for display
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const displayedNumbers = computed(() => {
|
||||
return samples.value.slice(0, 15).map(s => s.val)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audio-encoding-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.25rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.controls-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.slider-group label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.range-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--vp-c-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scale-marks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.wave-visualization {
|
||||
position: relative;
|
||||
height: 140px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.analog-wave {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.digital-samples {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sample-bar {
|
||||
position: absolute;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-left: 1px solid rgba(59, 130, 246, 0.4);
|
||||
border-right: 1px solid rgba(59, 130, 246, 0.4);
|
||||
transform: translateX(-50%);
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.sample-bar:hover {
|
||||
background: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.sample-dot {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--vp-c-brand);
|
||||
border-radius: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sample-dot.positive { top: -3px; }
|
||||
.sample-dot.negative { bottom: -3px; }
|
||||
|
||||
/* Add center line */
|
||||
.wave-visualization::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--vp-c-divider);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.data-stream {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stream-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stream-numbers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.num {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.demo-insight {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
border-left: 3px solid var(--vp-c-divider);
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="encoding-explorer">
|
||||
<div class="input-row">
|
||||
<label class="input-label">输入任意文字,看看它在计算机里长什么样</label>
|
||||
<input
|
||||
v-model="inputText"
|
||||
class="text-input"
|
||||
placeholder="输入文字,如:你好 Hello 🎉"
|
||||
maxlength="20"
|
||||
/>
|
||||
<div class="quick-btns">
|
||||
<button v-for="preset in presets" :key="preset" class="preset-btn" @click="inputText = preset">
|
||||
{{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="inputText" class="char-breakdown">
|
||||
<div class="breakdown-header">
|
||||
<span class="col-char">字符</span>
|
||||
<span class="col-arrow">→</span>
|
||||
<span class="col-unicode">Unicode 码点</span>
|
||||
<span class="col-arrow">→</span>
|
||||
<span class="col-utf8">UTF-8 字节</span>
|
||||
<span class="col-bytes">字节数</span>
|
||||
</div>
|
||||
<transition-group name="fade" tag="div">
|
||||
<div
|
||||
v-for="(item, i) in charData"
|
||||
:key="i"
|
||||
class="char-row"
|
||||
:class="item.type"
|
||||
>
|
||||
<span class="col-char char-glyph">{{ item.char }}</span>
|
||||
<span class="col-arrow dim">→</span>
|
||||
<span class="col-unicode codepoint">{{ item.codepoint }}</span>
|
||||
<span class="col-arrow dim">→</span>
|
||||
<div class="col-utf8 bytes-grid">
|
||||
<span v-for="(b, j) in item.utf8Bytes" :key="j" class="hex-byte">{{ b }}</span>
|
||||
</div>
|
||||
<span class="col-bytes byte-count">{{ item.byteCount }} 字节</span>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<div v-if="inputText" class="summary-row">
|
||||
<div class="summary-item">
|
||||
<span class="s-label">字符数</span>
|
||||
<span class="s-value">{{ charData.length }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="s-label">UTF-8 总字节数</span>
|
||||
<span class="s-value highlight">{{ totalBytes }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="s-label">平均每字符</span>
|
||||
<span class="s-value">{{ avgBytes }} 字节</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-box">
|
||||
<span><strong>提示:</strong>英文字母在 UTF-8 中只占 <strong>1 字节</strong>,常用汉字占 <strong>3 字节</strong>,Emoji 占 <strong>4 字节</strong>。这就是为什么处理中文文本时,“字符数”和“字节数”是两个完全不同的概念。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const inputText = ref('你好 Hello')
|
||||
const presets = ['你好', 'Hello', '你好 Hello', '🎉', 'AI助手']
|
||||
|
||||
function toUtf8Bytes(char) {
|
||||
const bytes = []
|
||||
const encoder = new TextEncoder()
|
||||
const encoded = encoder.encode(char)
|
||||
for (const b of encoded) {
|
||||
bytes.push('0x' + b.toString(16).toUpperCase().padStart(2, '0'))
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function getCharType(char) {
|
||||
const code = char.codePointAt(0)
|
||||
if (code > 0xFFFF) return 'emoji'
|
||||
if (code > 0x4E00 && code < 0x9FFF) return 'cjk'
|
||||
if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) return 'ascii'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const charData = computed(() => {
|
||||
return [...inputText.value].slice(0, 12).map(char => {
|
||||
const utf8Bytes = toUtf8Bytes(char)
|
||||
return {
|
||||
char,
|
||||
codepoint: 'U+' + char.codePointAt(0).toString(16).toUpperCase().padStart(4, '0'),
|
||||
utf8Bytes,
|
||||
byteCount: utf8Bytes.length,
|
||||
type: getCharType(char)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const totalBytes = computed(() => charData.value.reduce((s, c) => s + c.byteCount, 0))
|
||||
const avgBytes = computed(() => charData.value.length ? (totalBytes.value / charData.value.length).toFixed(1) : 0)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.encoding-explorer {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.25rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.text-input:focus { border-color: var(--vp-c-brand); }
|
||||
|
||||
.quick-btns {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: var(--vp-c-brand-soft);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.char-breakdown {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.breakdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.char-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
gap: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.char-row:hover { background: var(--vp-c-bg-soft); }
|
||||
.char-row.emoji { border-left: 3px solid #f59e0b; }
|
||||
.char-row.cjk { border-left: 3px solid var(--vp-c-brand); }
|
||||
.char-row.ascii { border-left: 3px solid var(--vp-c-green-1); }
|
||||
.char-row.other { border-left: 3px solid var(--vp-c-divider); }
|
||||
|
||||
.col-char { width: 2.5rem; text-align: center; }
|
||||
.col-unicode { width: 6rem; font-family: monospace; font-size: 0.82rem; color: var(--vp-c-brand); }
|
||||
.col-utf8 { flex: 1; }
|
||||
.col-bytes { width: 4.5rem; text-align: right; font-size: 0.8rem; }
|
||||
.col-arrow { color: var(--vp-c-divider); font-size: 0.8rem; }
|
||||
|
||||
.char-glyph { font-size: 1.4rem; font-weight: bold; }
|
||||
.codepoint { font-family: monospace; }
|
||||
.dim { opacity: 0.4; }
|
||||
|
||||
.bytes-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hex-byte {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.byte-count {
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.s-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.s-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.s-value.highlight { color: var(--vp-c-brand); }
|
||||
|
||||
.tip-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-left: 4px solid var(--vp-c-yellow-1);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tip-icon { font-size: 1rem; flex-shrink: 0; }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div class="transmission-demo">
|
||||
<!-- Mode selector -->
|
||||
<div class="mode-panel">
|
||||
<div class="mode-label">选择传输方式,然后点"发送数据包"</div>
|
||||
<div class="mode-buttons">
|
||||
<button
|
||||
:class="['mode-btn', { active: mode === 'serial' }]"
|
||||
@click="mode = 'serial'; reset()"
|
||||
>
|
||||
串行传输(现代)
|
||||
</button>
|
||||
<button
|
||||
:class="['mode-btn', { active: mode === 'parallel' }]"
|
||||
@click="mode = 'parallel'; reset()"
|
||||
>
|
||||
并行传输(旧时代)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visualization -->
|
||||
<div class="vis-area">
|
||||
<!-- Sender -->
|
||||
<div class="device sender">
|
||||
<div class="device-icon">Tx</div>
|
||||
<div class="device-label">发送方</div>
|
||||
<div class="data-bits">
|
||||
<span
|
||||
v-for="(bit, i) in dataBits"
|
||||
:key="i"
|
||||
class="bit"
|
||||
:class="{ sent: sentBits.includes(i) }"
|
||||
>{{ bit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wire(s) -->
|
||||
<div class="wire-container" :class="mode">
|
||||
<div v-if="mode === 'serial'" class="wire-group serial">
|
||||
<div class="wire-label">1 条线</div>
|
||||
<div class="wire">
|
||||
<span
|
||||
v-for="(p, i) in particles"
|
||||
:key="'p' + i"
|
||||
class="particle"
|
||||
:style="{ left: p.progress + '%', top: '50%' }"
|
||||
>{{ p.bit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mode === 'parallel'" class="wire-group parallel-group">
|
||||
<div class="wire-label">8 条线</div>
|
||||
<div v-for="l in 8" :key="l" class="wire">
|
||||
<span
|
||||
v-if="parallelParticle && parallelParticle.lane === l - 1"
|
||||
class="particle"
|
||||
:style="{ left: parallelParticle.progress + '%', top: '50%' }"
|
||||
>{{ parallelBits[l - 1] || '·' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receiver -->
|
||||
<div class="device receiver">
|
||||
<div class="device-icon">Rx</div>
|
||||
<div class="device-label">接收方</div>
|
||||
<div class="received-bits">
|
||||
<span
|
||||
v-for="(bit, i) in receivedBits"
|
||||
:key="'r' + i"
|
||||
class="bit received"
|
||||
>{{ bit }}</span>
|
||||
</div>
|
||||
<div v-if="checksumResult !== null" class="checksum-badge" :class="checksumResult ? 'ok' : 'fail'">
|
||||
{{ checksumResult ? '✓ 校验通过' : '✕ 校验失败' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="status-bar">
|
||||
<div class="status-item">
|
||||
<span class="s-label">已发送</span>
|
||||
<span class="s-val">{{ sentBits.length }} / {{ dataBits.length }} 位</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="s-label">传输速率</span>
|
||||
<span class="s-val">{{ mode === 'serial' ? '1 位/次' : '8 位/次' }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="s-label">状态</span>
|
||||
<span class="s-val" :class="statusColor">{{ statusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send button -->
|
||||
<button class="send-btn" :disabled="isSending" @click="send">
|
||||
{{ isSending ? '传输中...' : '发送数据包' }}
|
||||
</button>
|
||||
|
||||
<div class="note-box">
|
||||
<strong>提示:等等,串行不是更慢吗?</strong><br>
|
||||
表面上是的——但现代串行接口(USB 4、PCIe)传输频率高达每秒 <strong>数百亿次</strong>,而并行线路之间会产生 <em>信号串扰(Crosstalk)</em>,反而限制了速度。所以高速接口全面转向了串行。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const mode = ref('serial')
|
||||
const dataBits = ref([1,0,1,1,0,0,1,0]) // "Hello" first byte 0b10110010
|
||||
const sentBits = ref([])
|
||||
const receivedBits = ref([])
|
||||
const particles = ref([])
|
||||
const parallelParticle = ref(null)
|
||||
const parallelBits = ref([])
|
||||
const isSending = ref(false)
|
||||
const checksumResult = ref(null)
|
||||
|
||||
function reset() {
|
||||
sentBits.value = []
|
||||
receivedBits.value = []
|
||||
particles.value = []
|
||||
parallelParticle.value = null
|
||||
parallelBits.value = []
|
||||
checksumResult.value = null
|
||||
isSending.value = false
|
||||
}
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isSending.value) return '传输中...'
|
||||
if (receivedBits.value.length === dataBits.value.length) return '传输完成 ✓'
|
||||
if (receivedBits.value.length > 0) return '接收中...'
|
||||
return '就绪'
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (receivedBits.value.length === dataBits.value.length) return 'green'
|
||||
if (isSending.value) return 'yellow'
|
||||
return ''
|
||||
})
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function send() {
|
||||
if (isSending.value) return
|
||||
reset()
|
||||
isSending.value = true
|
||||
|
||||
if (mode.value === 'serial') {
|
||||
await sendSerial()
|
||||
} else {
|
||||
await sendParallel()
|
||||
}
|
||||
|
||||
// Checksum simulation
|
||||
await sleep(400)
|
||||
checksumResult.value = true // always pass in demo
|
||||
isSending.value = false
|
||||
}
|
||||
|
||||
async function sendSerial() {
|
||||
for (let i = 0; i < dataBits.value.length; i++) {
|
||||
sentBits.value.push(i)
|
||||
const bit = dataBits.value[i]
|
||||
// animate particle
|
||||
const p = { bit, progress: 0, id: i }
|
||||
particles.value.push(p)
|
||||
for (let prog = 0; prog <= 100; prog += 10) {
|
||||
p.progress = prog
|
||||
await sleep(35)
|
||||
}
|
||||
particles.value = particles.value.filter(x => x !== p)
|
||||
receivedBits.value.push(bit)
|
||||
await sleep(30)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendParallel() {
|
||||
sentBits.value = dataBits.value.map((_, i) => i)
|
||||
parallelBits.value = [...dataBits.value]
|
||||
for (let prog = 0; prog <= 100; prog += 8) {
|
||||
parallelParticle.value = { progress: prog, lane: Math.floor(Math.random() * 8) }
|
||||
await sleep(40)
|
||||
}
|
||||
parallelParticle.value = null
|
||||
receivedBits.value = [...dataBits.value]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transmission-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.25rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mode-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Visualization */
|
||||
.vis-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.device {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.device-icon { font-size: 2rem; }
|
||||
.device-label { font-size: 0.8rem; font-weight: bold; color: var(--vp-c-text-2); }
|
||||
|
||||
.data-bits, .received-bits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bit {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bit.sent { background: var(--vp-c-brand-soft); border-color: var(--vp-c-brand); }
|
||||
.bit.received { background: #d1fae5; border-color: #059669; color: #065f46; }
|
||||
|
||||
.checksum-badge {
|
||||
margin-top: 4px;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.checksum-badge.ok { background: #d1fae5; color: #065f46; }
|
||||
.checksum-badge.fail { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Wires */
|
||||
.wire-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.wire-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.wire {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wire-group.serial .wire { height: 20px; }
|
||||
.parallel-group { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: monospace;
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
transition: left 0.04s linear;
|
||||
background: var(--vp-c-brand-soft);
|
||||
border-radius: 2px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
}
|
||||
|
||||
.status-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.s-label { font-size: 0.72rem; color: var(--vp-c-text-3); }
|
||||
.s-val { font-size: 0.88rem; font-weight: bold; }
|
||||
.s-val.green { color: #059669; }
|
||||
.s-val.yellow { color: #d97706; }
|
||||
|
||||
.send-btn {
|
||||
padding: 0.5rem 1.2rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
transition: opacity 0.2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.send-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.note-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-left: 4px solid var(--vp-c-yellow-1);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.83rem;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="garbled-demo">
|
||||
<div class="demo-scenario">
|
||||
<div class="scenario-label">你收到的文件内容(字节流)</div>
|
||||
<div class="bytes-display">
|
||||
<span v-for="(byte, i) in fileBytes" :key="i" class="byte-chip">0x{{ byte }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="decoder-panel">
|
||||
<div class="decoder-label">用什么规则来「读」它?</div>
|
||||
<div class="encoding-buttons">
|
||||
<button
|
||||
v-for="enc in encodings"
|
||||
:key="enc.name"
|
||||
:class="['enc-btn', { active: selectedEncoding === enc.name }]"
|
||||
@click="selectedEncoding = enc.name"
|
||||
>
|
||||
{{ enc.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-panel" :class="currentEncoding.correct ? 'correct' : 'garbled'">
|
||||
<div class="result-label">
|
||||
<span v-if="currentEncoding.correct">正确({{ selectedEncoding }})</span>
|
||||
<span v-else>乱码!(用 {{ selectedEncoding }} 读 UTF-8 文件)</span>
|
||||
</div>
|
||||
<div class="result-text">{{ currentEncoding.result }}</div>
|
||||
<div class="result-explanation">{{ currentEncoding.explanation }}</div>
|
||||
</div>
|
||||
|
||||
<div class="insight-box">
|
||||
<strong>核心领悟</strong>:字节本身没有含义,<strong>编码规则决定了字节变成什么字</strong>。发件人用 UTF-8 存,你用 GBK 读,当然面目全非。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// "你好" in UTF-8 bytes (hex)
|
||||
const fileBytes = ['E4', 'BD', 'A0', 'E5', 'A5', 'BD']
|
||||
|
||||
const encodings = [
|
||||
{
|
||||
name: 'UTF-8',
|
||||
label: 'UTF-8(正确)',
|
||||
result: '你好',
|
||||
correct: true,
|
||||
explanation: '发件人用 UTF-8 存储了「你好」,你也用 UTF-8 读,当然正确。'
|
||||
},
|
||||
{
|
||||
name: 'GBK',
|
||||
label: 'GBK(乱码)',
|
||||
result: '浣犲ソ',
|
||||
correct: false,
|
||||
explanation: 'GBK 用不同的规则把同样的字节解读成了另一些字,所以出现了乱码。'
|
||||
},
|
||||
{
|
||||
name: 'Latin-1',
|
||||
label: 'Latin-1(乱码)',
|
||||
result: 'ä½ å¥½',
|
||||
correct: false,
|
||||
explanation: 'Latin-1(ISO-8859-1)只能表示 256 个字符,把 UTF-8 的多字节序列当成单字节,全乱了。'
|
||||
}
|
||||
]
|
||||
|
||||
const selectedEncoding = ref('UTF-8')
|
||||
|
||||
const currentEncoding = computed(() =>
|
||||
encodings.find(e => e.name === selectedEncoding.value) || encodings[0]
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.garbled-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.25rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.demo-scenario {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.scenario-label,
|
||||
.decoder-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.bytes-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.byte-chip {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.decoder-panel {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.encoding-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.enc-btn {
|
||||
padding: 0.35rem 0.85rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.enc-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
border: 2px solid;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.result-panel.correct {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.result-panel.garbled {
|
||||
border-color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.result-explanation {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.insight-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="image-encoding-demo">
|
||||
<div class="demo-header">
|
||||
<span class="demo-title">🖼️ 图片是如何变成数字的?</span>
|
||||
<span class="demo-subtitle">(悬停在像素方块上看看)</span>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<!-- The Grid (Image) -->
|
||||
<div class="pixel-grid" @mouseleave="hoveredPixel = null">
|
||||
<div
|
||||
v-for="(pixel, i) in pixels"
|
||||
:key="i"
|
||||
class="pixel-cell"
|
||||
:style="{ backgroundColor: pixel.color }"
|
||||
@mouseenter="hoveredPixel = { ...pixel, index: i }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- The Code (Data) -->
|
||||
<div class="data-panel">
|
||||
<div class="data-label">💻 计算机实际看到的:</div>
|
||||
<div class="hex-stream">
|
||||
<span
|
||||
v-for="(pixel, i) in pixels"
|
||||
:key="'hex' + i"
|
||||
class="hex-code"
|
||||
:class="{ active: hoveredPixel && hoveredPixel.index === i }"
|
||||
>
|
||||
{{ pixel.color }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="inspection-box" v-if="hoveredPixel">
|
||||
<div class="preview-color" :style="{ backgroundColor: hoveredPixel.color }"></div>
|
||||
<div class="preview-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">像素位置:</span>
|
||||
<span class="info-val">第 {{ hoveredPixel.index + 1 }} 个方块</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">十六进制:</span>
|
||||
<span class="info-val highlight">{{ hoveredPixel.color }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inspection-box empty" v-else>
|
||||
将鼠标悬停在左侧画布的方块上
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-insight">
|
||||
💡 <strong>原理解析</strong>:一张 1080p 的高清壁纸,其实就是 <strong>207 万</strong> 个像左边这样密密麻麻的小色块组成的。计算机把这两百多万个颜色的编号(如 #FF0000)按顺序记录下来,图片就变成了几百万个数字的集合。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Create a simple 8x8 pixel art (a smiley face)
|
||||
const rawArt = [
|
||||
'00000000',
|
||||
'01100110',
|
||||
'01100110',
|
||||
'00000000',
|
||||
'10000001',
|
||||
'01000010',
|
||||
'00111100',
|
||||
'00000000'
|
||||
]
|
||||
|
||||
const colorMap = {
|
||||
'0': '#F3F4F6', // Background (light gray)
|
||||
'1': '#3B82F6' // Face (blue)
|
||||
}
|
||||
|
||||
const pixels = ref([])
|
||||
for (let row of rawArt) {
|
||||
for (let char of row) {
|
||||
pixels.value.push({ color: colorMap[char] })
|
||||
}
|
||||
}
|
||||
|
||||
const hoveredPixel = ref(null)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-encoding-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.25rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.visualization-area { flex-direction: column; }
|
||||
}
|
||||
|
||||
.pixel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pixel-cell {
|
||||
border: 1px solid rgba(0,0,0,0.05);
|
||||
cursor: crosshair;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.pixel-cell:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 8px rgba(0,0,0,0.2);
|
||||
z-index: 10;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.data-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.hex-stream {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
max-height: 90px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.hex-code {
|
||||
padding: 2px 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.hex-code.active {
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.inspection-box {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.inspection-box.empty {
|
||||
justify-content: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.85rem;
|
||||
border-color: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.preview-color {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.info-label { color: var(--vp-c-text-2); width: 60px; }
|
||||
.info-val { font-family: monospace; font-weight: bold; }
|
||||
.info-val.highlight { color: var(--vp-c-brand); font-size: 0.9rem; }
|
||||
|
||||
.demo-insight {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<div class="journey-demo">
|
||||
<!-- Step tabs -->
|
||||
<div class="step-tabs">
|
||||
<div
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
:class="['step-tab', { active: currentStep >= i, current: currentStep === i }]"
|
||||
@click="goToStep(i)"
|
||||
>
|
||||
<span class="tab-num">{{ i + 1 }}</span>
|
||||
<span class="tab-label">{{ step.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main canvas -->
|
||||
<div class="journey-canvas" :style="{ borderColor: currentStepData.color + '88' }">
|
||||
<!-- Scene -->
|
||||
<div class="scene">
|
||||
<div class="scene-actors">
|
||||
<div
|
||||
v-for="(actor, i) in currentStepData.actors"
|
||||
:key="i"
|
||||
class="actor"
|
||||
:class="{ highlighted: actor.highlight, animated: actor.animated }"
|
||||
>
|
||||
<div class="actor-icon">{{ actor.icon }}</div>
|
||||
<div class="actor-name">{{ actor.name }}</div>
|
||||
<div v-if="actor.value" class="actor-value">{{ actor.value }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrows between actors -->
|
||||
<div
|
||||
v-for="(arrow, i) in currentStepData.arrows"
|
||||
:key="'arrow' + i"
|
||||
class="flow-arrow"
|
||||
:class="{ animated: isRunning }"
|
||||
>
|
||||
<span class="arrow-label">{{ arrow.label }}</span>
|
||||
<span class="arrow-sym">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation panel -->
|
||||
<div class="explanation-panel" :style="{ borderLeftColor: currentStepData.color }">
|
||||
<div class="exp-header">
|
||||
<span class="exp-icon">{{ currentStepData.icon }}</span>
|
||||
<span class="exp-title">{{ currentStepData.title }}</span>
|
||||
</div>
|
||||
<ul class="exp-points">
|
||||
<li v-for="(pt, i) in currentStepData.points" :key="i" class="exp-point" :class="{ visible: visiblePoints.includes(i) }">
|
||||
{{ pt }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="exp-insight">💡 {{ currentStepData.insight }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="controls">
|
||||
<button class="ctrl-btn secondary" :disabled="currentStep === 0" @click="goToStep(currentStep - 1)">← 上一步</button>
|
||||
<button class="ctrl-btn primary" @click="runCurrentStep" :disabled="isRunning">
|
||||
{{ isRunning ? '进行中...' : currentStep === steps.length - 1 ? '🔄 重新演示' : '▶ 执行这一步' }}
|
||||
</button>
|
||||
<button class="ctrl-btn secondary" :disabled="currentStep >= steps.length - 1" @click="goToStep(currentStep + 1)">下一步 →</button>
|
||||
</div>
|
||||
|
||||
<!-- Overall insight -->
|
||||
<div class="final-insight">
|
||||
🎯 <strong>三步三役</strong>:<strong>编码</strong>负责"翻译成机器语言",<strong>存储</strong>负责"记住它",<strong>传输</strong>负责"送到目的地"。缺了任何一环,这张照片就不会出现在云端。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentStep = ref(0)
|
||||
const isRunning = ref(false)
|
||||
const visiblePoints = ref([])
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: '编码',
|
||||
icon: '🔢',
|
||||
title: '第一步:编码 — 把光变成数字',
|
||||
color: '#7c3aed',
|
||||
actors: [
|
||||
{ icon: '☀️', name: '光线', highlight: false },
|
||||
{ icon: '📷', name: '传感器', highlight: true, animated: true },
|
||||
{ icon: '📊', name: 'RAW 数据', value: '24MB / 4860万像素' },
|
||||
{ icon: '🗜️', name: 'JPEG 压缩', highlight: true },
|
||||
{ icon: '📄', name: 'JPEG 文件', value: '3.2MB(压缩后)' }
|
||||
],
|
||||
arrows: [
|
||||
{ label: 'ADC 采样' },
|
||||
{ label: '像素编码' },
|
||||
{ label: '有损压缩' }
|
||||
],
|
||||
points: [
|
||||
'📸 相机传感器把光信号转换成 RGB 数值(每个像素 3 × 8 bit = 24 bit)',
|
||||
'🔢 整张照片 4860 万像素 × 24 bit ≈ 140 MB 的原始数据',
|
||||
'🗜️ JPEG 算法分析像素之间的相似性,去掉人眼不敏感的信息,压缩到 3 MB'
|
||||
],
|
||||
insight: '压缩 ≠ 降质,好的压缩算法让你几乎看不出差别,但文件小了 97%。'
|
||||
},
|
||||
{
|
||||
label: '存储',
|
||||
icon: '💾',
|
||||
title: '第二步:存储 — 先闪存后闪存',
|
||||
color: '#059669',
|
||||
actors: [
|
||||
{ icon: '📄', name: 'JPEG(已编码)', value: '3.2 MB' },
|
||||
{ icon: '🧠', name: 'RAM(内存)', value: '写入耗时:~1 ms', highlight: true, animated: true },
|
||||
{ icon: '💾', name: '闪存(Flash)', value: '写入耗时:~10 ms', highlight: true }
|
||||
],
|
||||
arrows: [
|
||||
{ label: '临时缓存' },
|
||||
{ label: '持久写入' }
|
||||
],
|
||||
points: [
|
||||
'⚡ 图像先写进内存(RAM)——速度极快,但断电消失',
|
||||
'💾 内存中的数据再异步写入闪存(手机存储)——速度慢一些,但永久保存',
|
||||
'🔒 写完后操作系统标记文件"安全",你才能看到相册里的新照片'
|
||||
],
|
||||
insight: '为什么拍完不能马上拔电池?因为数据可能还在内存里,还没写进闪存!'
|
||||
},
|
||||
{
|
||||
label: '传输',
|
||||
icon: '📡',
|
||||
title: '第三步:传输 — 数据"旅行"到云端',
|
||||
color: '#d97706',
|
||||
actors: [
|
||||
{ icon: '💾', name: '闪存(JPEG)', value: '3.2 MB' },
|
||||
{ icon: '📶', name: 'Wi-Fi / 4G', value: 'TCP 分包传输', highlight: true, animated: true },
|
||||
{ icon: '☁️', name: '云端服务器', value: '写入云存储', highlight: true }
|
||||
],
|
||||
arrows: [
|
||||
{ label: '分包 + 加密' },
|
||||
{ label: '校验 + 重组' }
|
||||
],
|
||||
points: [
|
||||
'📦 3.2 MB 的 JPEG 文件被 TCP 协议切成数千个小"数据包"',
|
||||
'🔐 每个包都有序号和校验码,丢了会自动重传——所以传输是可靠的',
|
||||
'☁️ 云端收齐所有包,重新拼成完整 JPEG,写入对象存储(如 OSS/S3)'
|
||||
],
|
||||
insight: '上传时你以为数据是"整个发过去"的,其实是"切碎了一片片送过去"。'
|
||||
}
|
||||
]
|
||||
|
||||
const currentStepData = computed(() => steps[currentStep.value])
|
||||
|
||||
function goToStep(i) {
|
||||
currentStep.value = i
|
||||
visiblePoints.value = []
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
async function runCurrentStep() {
|
||||
if (currentStep.value === steps.length - 1 && !isRunning.value && visiblePoints.value.length === steps[currentStep.value].points.length) {
|
||||
goToStep(0)
|
||||
return
|
||||
}
|
||||
isRunning.value = true
|
||||
visiblePoints.value = []
|
||||
const pts = steps[currentStep.value].points
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
visiblePoints.value.push(i)
|
||||
}
|
||||
isRunning.value = false
|
||||
// Auto advance after last point, unless last step
|
||||
if (currentStep.value < steps.length - 1) {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
currentStep.value++
|
||||
visiblePoints.value = []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.journey-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.25rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Step tabs */
|
||||
.step-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.step-tab.active { opacity: 1; border-color: var(--vp-c-brand); }
|
||||
.step-tab.current { background: var(--vp-c-brand-soft); }
|
||||
|
||||
.tab-num {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-tab.active .tab-num { background: var(--vp-c-brand); color: white; }
|
||||
.tab-label { font-weight: bold; }
|
||||
|
||||
/* Canvas */
|
||||
.journey-canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: border-color 0.4s;
|
||||
}
|
||||
|
||||
/* Scene */
|
||||
.scene { padding: 0.5rem 0; }
|
||||
|
||||
.scene-actors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-width: 80px;
|
||||
transition: all 0.3s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actor.highlighted { border-color: var(--vp-c-brand); background: var(--vp-c-brand-soft); }
|
||||
.actor.animated { animation: pulse-gentle 1.5s ease-in-out infinite; }
|
||||
|
||||
@keyframes pulse-gentle {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.04); }
|
||||
}
|
||||
|
||||
.actor-icon { font-size: 1.6rem; }
|
||||
.actor-name { font-size: 0.72rem; font-weight: bold; margin-top: 2px; }
|
||||
.actor-value { font-size: 0.65rem; color: var(--vp-c-text-2); margin-top: 2px; white-space: nowrap; }
|
||||
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arrow-label { font-size: 0.65rem; color: var(--vp-c-text-3); white-space: nowrap; }
|
||||
.arrow-sym { font-size: 1.2rem; color: var(--vp-c-brand); }
|
||||
|
||||
/* Explanation */
|
||||
.explanation-panel {
|
||||
border-left: 4px solid;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 0 6px 6px 0;
|
||||
transition: border-left-color 0.4s;
|
||||
}
|
||||
|
||||
.exp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.exp-icon { font-size: 1.2rem; }
|
||||
.exp-title { font-weight: bold; font-size: 0.95rem; }
|
||||
|
||||
.exp-points { list-style: none; padding: 0; margin: 0 0 0.6rem 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
|
||||
.exp-point {
|
||||
font-size: 0.83rem;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.5;
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
.exp-point.visible { opacity: 1; transform: translateX(0); }
|
||||
|
||||
.exp-insight {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ctrl-btn.primary {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ctrl-btn.secondary { background: var(--vp-c-bg); }
|
||||
.ctrl-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.final-insight {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="storage-pyramid-demo">
|
||||
<div class="pyramid-area">
|
||||
<div
|
||||
v-for="(layer, i) in layers"
|
||||
:key="layer.name"
|
||||
class="pyramid-layer"
|
||||
:class="[layer.colorClass, { active: selectedLayer === i }]"
|
||||
:style="{ width: (40 + i * 15) + '%' }"
|
||||
@click="selectedLayer = i"
|
||||
>
|
||||
<span class="layer-icon">{{ layer.icon }}</span>
|
||||
<span class="layer-name">{{ layer.name }}</span>
|
||||
<span class="layer-speed">{{ layer.speedLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel" v-if="currentLayer">
|
||||
<div class="detail-header">
|
||||
<span class="detail-icon">{{ currentLayer.icon }}</span>
|
||||
<span class="detail-name">{{ currentLayer.name }}</span>
|
||||
<span class="detail-badge" :class="currentLayer.colorClass">{{ currentLayer.speedLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-bar-label">
|
||||
<span>访问速度</span>
|
||||
<span class="stat-val">{{ currentLayer.speed }}</span>
|
||||
</div>
|
||||
<div class="stat-bar-bg">
|
||||
<div class="stat-bar-fill" :class="currentLayer.colorClass" :style="{ width: currentLayer.speedPct + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-bar-label">
|
||||
<span>典型容量</span>
|
||||
<span class="stat-val">{{ currentLayer.capacity }}</span>
|
||||
</div>
|
||||
<div class="stat-bar-bg">
|
||||
<div class="stat-bar-fill cap-bar" :style="{ width: currentLayer.capacityPct + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-bar-label">
|
||||
<span>单价(每GB)</span>
|
||||
<span class="stat-val">{{ currentLayer.price }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analogy-box">
|
||||
<div>
|
||||
<strong>生活类比:</strong>{{ currentLayer.analogy }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="use-case-box">
|
||||
<strong>实际用途:</strong>{{ currentLayer.useCase }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insight-bar">
|
||||
<strong>提示:</strong>越快越贵,越慢越大。CPU 缓存极快但只有几 MB;机械硬盘虽慢但便宜又能存 TB。操作系统会自动在各层之间搬运数据——这叫<strong>存储层次结构</strong>。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const layers = [
|
||||
{
|
||||
name: 'CPU 寄存器',
|
||||
icon: 'L0',
|
||||
speedLabel: '极快',
|
||||
colorClass: 'tier-0',
|
||||
speed: '< 1 纳秒',
|
||||
speedPct: 98,
|
||||
capacity: '几百字节',
|
||||
capacityPct: 2,
|
||||
price: '极贵(集成在CPU)',
|
||||
analogy: '你大脑里当前正在「想」的那个数字——随取随用,但只能记住一两个。',
|
||||
useCase: 'CPU 内部运算时临时存放操作数和指令,程序员几乎不需要直接管理它。'
|
||||
},
|
||||
{
|
||||
name: 'CPU 缓存(Cache)',
|
||||
icon: 'L1',
|
||||
speedLabel: '很快',
|
||||
colorClass: 'tier-1',
|
||||
speed: '5–50 纳秒',
|
||||
speedPct: 82,
|
||||
capacity: '几 KB ~ 几十 MB',
|
||||
capacityPct: 5,
|
||||
price: '贵',
|
||||
analogy: '你办公桌上的便签纸——放最近用过的东西,翻找极快,但桌面面积有限。',
|
||||
useCase: '缓存最近频繁访问的内存数据,减少 CPU 等待时间。大多数性能敏感程序都会考虑「缓存友好」写法。'
|
||||
},
|
||||
{
|
||||
name: '内存(RAM)',
|
||||
icon: 'L2',
|
||||
speedLabel: '快',
|
||||
colorClass: 'tier-2',
|
||||
speed: '几十 ~ 100 纳秒',
|
||||
speedPct: 60,
|
||||
capacity: '几 GB ~ 几百 GB',
|
||||
capacityPct: 25,
|
||||
price: '适中(约 ¥30/GB)',
|
||||
analogy: '你打开的浏览器标签页——断电就没了,但当前工作全在这里。',
|
||||
useCase: '运行中的程序、操作系统、当前打开的文件都住在内存里。内存不够了→程序卡顿甚至崩溃。'
|
||||
},
|
||||
{
|
||||
name: 'SSD(固态硬盘)',
|
||||
icon: 'L3',
|
||||
speedLabel: '较快',
|
||||
colorClass: 'tier-3',
|
||||
speed: '~100 微秒',
|
||||
speedPct: 35,
|
||||
capacity: '几百 GB ~ 几 TB',
|
||||
capacityPct: 60,
|
||||
price: '便宜(约 ¥0.5/GB)',
|
||||
analogy: '你电脑里的文件夹——关机后数据还在,但比内存慢上千倍。',
|
||||
useCase: '存储操作系统、应用程序、用户文件。现在的 NVMe SSD 已经非常快了。'
|
||||
},
|
||||
{
|
||||
name: '机械硬盘(HDD)',
|
||||
icon: 'L4',
|
||||
speedLabel: '慢',
|
||||
colorClass: 'tier-4',
|
||||
speed: '~10 毫秒',
|
||||
speedPct: 15,
|
||||
capacity: '几 TB ~ 几十 TB',
|
||||
capacityPct: 90,
|
||||
price: '最便宜(约 ¥0.1/GB)',
|
||||
analogy: '仓库里的档案柜——容量巨大、便宜,但找东西要走过去翻,慢。',
|
||||
useCase: '存储大量冷数据、备份、视频录像。现在大多数笔记本已经换成 SSD 了。'
|
||||
}
|
||||
]
|
||||
|
||||
const selectedLayer = ref(2) // default: RAM
|
||||
|
||||
const currentLayer = computed(() => layers[selectedLayer.value])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.storage-pyramid-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.25rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pyramid-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pyramid-layer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pyramid-layer:hover { filter: brightness(1.05); transform: scaleX(1.01); }
|
||||
.pyramid-layer.active { border-color: var(--vp-c-text-1); filter: brightness(1.08); }
|
||||
|
||||
.tier-0 { background: linear-gradient(90deg, #7c3aed22, #7c3aed44); border-left: 4px solid #7c3aed; }
|
||||
.tier-1 { background: linear-gradient(90deg, #2563eb22, #2563eb44); border-left: 4px solid #2563eb; }
|
||||
.tier-2 { background: linear-gradient(90deg, #059669 22, #05966944); border-left: 4px solid #059669; }
|
||||
.tier-3 { background: linear-gradient(90deg, #d97706 22, #d9770644); border-left: 4px solid #d97706; }
|
||||
.tier-4 { background: linear-gradient(90deg, #dc262622, #dc262644); border-left: 4px solid #dc2626; }
|
||||
|
||||
.tier-0.active, .tier-0:hover { background: #7c3aed22; }
|
||||
.tier-1.active, .tier-1:hover { background: #2563eb22; }
|
||||
|
||||
.layer-icon { font-size: 1.1rem; }
|
||||
.layer-name { font-weight: bold; font-size: 0.88rem; flex: 1; margin-left: 0.5rem; }
|
||||
.layer-speed { font-size: 0.75rem; color: var(--vp-c-text-2); }
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-icon { font-size: 1.4rem; }
|
||||
.detail-name { font-size: 1rem; font-weight: bold; flex: 1; }
|
||||
|
||||
.detail-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
.tier-0.detail-badge { background: #7c3aed; }
|
||||
.tier-1.detail-badge { background: #2563eb; }
|
||||
.tier-2.detail-badge { background: #059669; }
|
||||
.tier-3.detail-badge { background: #d97706; }
|
||||
.tier-4.detail-badge { background: #dc2626; }
|
||||
|
||||
.detail-stats { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.stat-item { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
|
||||
.stat-bar-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.stat-val { font-weight: bold; color: var(--vp-c-text-1); }
|
||||
|
||||
.stat-bar-bg {
|
||||
height: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.tier-0.stat-bar-fill { background: #7c3aed; }
|
||||
.tier-1.stat-bar-fill { background: #2563eb; }
|
||||
.tier-2.stat-bar-fill { background: #059669; }
|
||||
.tier-3.stat-bar-fill { background: #d97706; }
|
||||
.tier-4.stat-bar-fill { background: #dc2626; }
|
||||
.cap-bar { background: var(--vp-c-text-3); }
|
||||
|
||||
.analogy-box {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.analogy-icon { font-size: 1.1rem; flex-shrink: 0; }
|
||||
|
||||
.use-case-box {
|
||||
font-size: 0.83rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.insight-bar {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user