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>
|
||||
@@ -1,207 +0,0 @@
|
||||
<template>
|
||||
<div class="branch-demo">
|
||||
<div class="panel">
|
||||
<div class="controls">
|
||||
<button
|
||||
:disabled="inited || mergePending"
|
||||
class="btn"
|
||||
@click="init"
|
||||
>
|
||||
初始化
|
||||
</button>
|
||||
<button
|
||||
:disabled="!inited || mergePending"
|
||||
class="btn"
|
||||
@click="commit"
|
||||
>
|
||||
提交
|
||||
</button>
|
||||
<button
|
||||
:disabled="!inited || hasBranch"
|
||||
class="btn"
|
||||
@click="branch"
|
||||
>
|
||||
创建分支
|
||||
</button>
|
||||
<button
|
||||
:disabled="!hasBranch || mergePending"
|
||||
class="btn"
|
||||
@click="prepareMerge"
|
||||
>
|
||||
准备合并
|
||||
</button>
|
||||
<button
|
||||
:disabled="!mergePending"
|
||||
class="btn"
|
||||
@click="finishMerge"
|
||||
>
|
||||
完成合并
|
||||
</button>
|
||||
<button
|
||||
class="btn secondary"
|
||||
@click="reset"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="graph">
|
||||
<svg viewBox="0 0 400 120">
|
||||
<line
|
||||
x1="50"
|
||||
y1="40"
|
||||
x2="350"
|
||||
y2="40"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<line
|
||||
v-if="hasBranch"
|
||||
x1="150"
|
||||
y1="40"
|
||||
x2="150"
|
||||
y2="80"
|
||||
stroke="#10b981"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<line
|
||||
v-if="hasBranch"
|
||||
x1="150"
|
||||
y1="80"
|
||||
x2="300"
|
||||
y2="80"
|
||||
stroke="#10b981"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<circle
|
||||
v-for="(c, i) in main"
|
||||
:key="i"
|
||||
:cx="60 + i * 50"
|
||||
cy="40"
|
||||
r="8"
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
<circle
|
||||
v-for="(c, i) in feat"
|
||||
:key="i"
|
||||
:cx="180 + i * 50"
|
||||
cy="80"
|
||||
r="8"
|
||||
fill="#10b981"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<span>提交: {{ main.length }}</span>
|
||||
<span>分支: {{ hasBranch ? 2 : 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 分支策略:</strong> 并行开发,互不干扰,最后合并</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const inited = ref(false)
|
||||
const hasBranch = ref(false)
|
||||
const mergePending = ref(false)
|
||||
const main = ref([])
|
||||
const feat = ref([])
|
||||
|
||||
const init = () => {
|
||||
inited.value = true
|
||||
main.value = [1]
|
||||
}
|
||||
const commit = () => {
|
||||
if (inited.value) main.value.push(1)
|
||||
}
|
||||
const branch = () => {
|
||||
if (inited.value) {
|
||||
hasBranch.value = true
|
||||
feat.value = [1]
|
||||
}
|
||||
}
|
||||
const prepareMerge = () => {
|
||||
if (!hasBranch.value) return
|
||||
mergePending.value = true
|
||||
}
|
||||
const finishMerge = () => {
|
||||
if (!mergePending.value) return
|
||||
main.value.push(1)
|
||||
hasBranch.value = false
|
||||
feat.value = []
|
||||
mergePending.value = false
|
||||
}
|
||||
const reset = () => {
|
||||
inited.value = false
|
||||
hasBranch.value = false
|
||||
mergePending.value = false
|
||||
main.value = []
|
||||
feat.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.branch-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.secondary {
|
||||
border-color: var(--vp-c-divider);
|
||||
}
|
||||
.graph {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.graph svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="gb-root">
|
||||
<!-- Terminal -->
|
||||
<div class="gb-terminal">
|
||||
<div class="term-bar">
|
||||
<span class="dot r" /><span class="dot y" /><span class="dot g" />
|
||||
<span class="term-title">~/project
|
||||
<span class="branch-tag">({{ branch }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div ref="termEl" class="term-body">
|
||||
<div v-for="(l, i) in lines" :key="i" class="t-line">
|
||||
<span v-if="l.kind === 'cmd'" class="t-ps">$ </span>
|
||||
<span :class="'t-' + l.kind">{{ l.text }}</span>
|
||||
</div>
|
||||
<div class="t-line">
|
||||
<span class="t-ps">$ </span>
|
||||
<span class="t-typing">{{ typing }}<span class="t-cur">▋</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="gb-btns">
|
||||
<button
|
||||
v-for="op in ops"
|
||||
:key="op.id"
|
||||
:disabled="running || !op.ok()"
|
||||
:class="['gb-btn', { 'gb-btn--on': active === op.id, 'gb-btn--dim': !op.ok() }]"
|
||||
@click="run(op)"
|
||||
>
|
||||
<code>{{ op.cmd }}</code>
|
||||
</button>
|
||||
<button class="gb-btn gb-btn--reset" :disabled="running" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<!-- SVG Graph -->
|
||||
<div class="gb-graph-wrap">
|
||||
<div class="gb-legend">
|
||||
<span class="leg-item"><span class="leg-dot main-c" />main 主分支</span>
|
||||
<span v-if="featLog.length" class="leg-item"><span class="leg-dot feat-c" />feature-login 功能分支</span>
|
||||
<span v-if="mergeNode" class="leg-item"><span class="leg-dot merge-c" />Merge 合并节点</span>
|
||||
<span class="leg-item head-leg"><span class="leg-head">HEAD</span> 你当前所在位置</span>
|
||||
</div>
|
||||
|
||||
<div class="svg-scroll">
|
||||
<svg :width="svgW" :height="svgH" class="gb-svg">
|
||||
<!-- ── 连接线 ── -->
|
||||
|
||||
<!-- main 主轨道横线 -->
|
||||
<line
|
||||
v-if="mainLog.length > 1"
|
||||
:x1="nodeX(0) + NODE_R"
|
||||
:y1="MAIN_Y"
|
||||
:x2="nodeX(mainLog.length - 1) - NODE_R"
|
||||
:y2="MAIN_Y"
|
||||
stroke="#5b9cf6" stroke-width="2.5"
|
||||
/>
|
||||
|
||||
<!-- 分叉弧线:从 main 最后一个原始节点向下弯到 feat 第一个节点 -->
|
||||
<path
|
||||
v-if="featLog.length"
|
||||
:d="forkPath"
|
||||
fill="none" stroke="#f9e2af" stroke-width="2.5" stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- feature 轨道横线 -->
|
||||
<line
|
||||
v-if="featLog.length > 1"
|
||||
:x1="featNodeX(0) + NODE_R"
|
||||
:y1="FEAT_Y"
|
||||
:x2="featNodeX(featLog.length - 1) - NODE_R"
|
||||
:y2="FEAT_Y"
|
||||
stroke="#f9e2af" stroke-width="2.5"
|
||||
/>
|
||||
|
||||
<!-- merge 收束弧线:从 feat 最后节点弯回 main merge 节点 -->
|
||||
<path
|
||||
v-if="mergeNode"
|
||||
:d="mergePath"
|
||||
fill="none" stroke="#a6e3a1" stroke-width="2.5" stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- ── 节点 ── -->
|
||||
|
||||
<!-- main 节点 -->
|
||||
<g v-for="(c, i) in mainLog" :key="'m'+i">
|
||||
<circle
|
||||
:cx="nodeX(i)"
|
||||
:cy="MAIN_Y"
|
||||
:r="c.merge ? NODE_R + 2 : NODE_R"
|
||||
:fill="c.merge ? '#a6e3a1' : '#5b9cf6'"
|
||||
stroke="#1a1a2e" stroke-width="2"
|
||||
/>
|
||||
<!-- HEAD 标签 -->
|
||||
<g v-if="branch === 'main' && i === mainLog.length - 1">
|
||||
<rect
|
||||
:x="nodeX(i) - 18"
|
||||
:y="MAIN_Y - NODE_R - 20"
|
||||
width="36" height="14"
|
||||
rx="3" fill="#5b9cf6" opacity="0.85"
|
||||
/>
|
||||
<text
|
||||
:x="nodeX(i)"
|
||||
:y="MAIN_Y - NODE_R - 10"
|
||||
text-anchor="middle" font-size="9"
|
||||
font-family="monospace" fill="white" font-weight="bold"
|
||||
>HEAD</text>
|
||||
</g>
|
||||
<!-- commit hash -->
|
||||
<text
|
||||
:x="nodeX(i)"
|
||||
:y="MAIN_Y + NODE_R + 14"
|
||||
text-anchor="middle" font-size="9"
|
||||
font-family="monospace" :fill="c.merge ? '#a6e3a1' : '#7f849c'"
|
||||
>{{ c.hash }}</text>
|
||||
<!-- commit msg -->
|
||||
<text
|
||||
:x="nodeX(i)"
|
||||
:y="MAIN_Y + NODE_R + 25"
|
||||
text-anchor="middle" font-size="9"
|
||||
fill="#64748b"
|
||||
>{{ c.shortMsg }}</text>
|
||||
</g>
|
||||
|
||||
<!-- feature 节点 -->
|
||||
<g v-for="(c, i) in featLog" :key="'f'+i">
|
||||
<circle
|
||||
:cx="featNodeX(i)"
|
||||
:cy="FEAT_Y"
|
||||
:r="NODE_R"
|
||||
fill="#f9e2af"
|
||||
stroke="#1a1a2e" stroke-width="2"
|
||||
/>
|
||||
<!-- HEAD 标签 -->
|
||||
<g v-if="branch === 'feature-login' && i === featLog.length - 1">
|
||||
<rect
|
||||
:x="featNodeX(i) - 18"
|
||||
:y="FEAT_Y + NODE_R + 4"
|
||||
width="36" height="14"
|
||||
rx="3" fill="#f9e2af" opacity="0.85"
|
||||
/>
|
||||
<text
|
||||
:x="featNodeX(i)"
|
||||
:y="FEAT_Y + NODE_R + 14"
|
||||
text-anchor="middle" font-size="9"
|
||||
font-family="monospace" fill="#1a1a2e" font-weight="bold"
|
||||
>HEAD</text>
|
||||
</g>
|
||||
<!-- hash & msg above -->
|
||||
<text
|
||||
:x="featNodeX(i)"
|
||||
:y="FEAT_Y - NODE_R - 14"
|
||||
text-anchor="middle" font-size="9"
|
||||
font-family="monospace" fill="#a89050"
|
||||
>{{ c.hash }}</text>
|
||||
<text
|
||||
:x="featNodeX(i)"
|
||||
:y="FEAT_Y - NODE_R - 3"
|
||||
text-anchor="middle" font-size="9"
|
||||
fill="#a89050"
|
||||
>{{ c.shortMsg }}</text>
|
||||
</g>
|
||||
|
||||
<!-- 分支名标签 -->
|
||||
<text
|
||||
:x="svgPad"
|
||||
:y="MAIN_Y - NODE_R - 26"
|
||||
font-size="10" font-family="monospace" fill="#5b9cf6" font-weight="bold"
|
||||
>main</text>
|
||||
<text
|
||||
v-if="featLog.length"
|
||||
:x="featNodeX(0)"
|
||||
:y="FEAT_Y + (branch==='feature-login' ? NODE_R + 26 : -NODE_R - 28)"
|
||||
font-size="10" font-family="monospace" fill="#f9e2af" font-weight="bold"
|
||||
text-anchor="middle"
|
||||
>feature-login</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hint" class="gb-hint">💡 {{ hint }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const NODE_R = 10
|
||||
const STEP = 100 // horizontal spacing between commits
|
||||
const svgPad = 50 // left padding
|
||||
const MAIN_Y = 70 // main track y
|
||||
const FEAT_Y = 170 // feature track y
|
||||
|
||||
const termEl = ref(null)
|
||||
const lines = ref([{ kind: 'dim', text: '# main 分支上已有 2 次提交,按步骤演示分支操作' }])
|
||||
const typing = ref('')
|
||||
const running = ref(false)
|
||||
const active = ref(null)
|
||||
const hint = ref('👆 依次点击上方命令按钮,观察下方分支图的变化')
|
||||
const branch = ref('main')
|
||||
|
||||
const mainLog = ref([
|
||||
{ hash: '9f3e1b2', shortMsg: 'init', merge: false },
|
||||
{ hash: 'c4d8a31', shortMsg: '首页', merge: false },
|
||||
])
|
||||
const featLog = ref([])
|
||||
const mergeNode = ref(false)
|
||||
let s = { created: false, c1: false, c2: false, merged: false }
|
||||
|
||||
// X position of the i-th main commit
|
||||
function nodeX(i) { return svgPad + i * STEP }
|
||||
|
||||
// fork point = last original main commit (before any merge)
|
||||
const forkIdx = computed(() => mainLog.value.filter(c => !c.merge).length - 1)
|
||||
|
||||
// X of feature commit i: starts one step after fork point
|
||||
function featNodeX(i) { return nodeX(forkIdx.value) + (i + 1) * STEP }
|
||||
|
||||
// SVG dimensions
|
||||
const svgW = computed(() => {
|
||||
const lastMain = nodeX(mainLog.value.length - 1)
|
||||
const lastFeat = featLog.value.length ? featNodeX(featLog.value.length - 1) : 0
|
||||
return Math.max(lastMain, lastFeat) + svgPad + 30
|
||||
})
|
||||
const svgH = computed(() => featLog.value.length ? 240 : 130)
|
||||
|
||||
// Arc from last original main node down to first feat node
|
||||
const forkPath = computed(() => {
|
||||
if (!featLog.value.length) return ''
|
||||
const x1 = nodeX(forkIdx.value)
|
||||
const y1 = MAIN_Y
|
||||
const x2 = featNodeX(0)
|
||||
const y2 = FEAT_Y
|
||||
// cubic bezier: go right then down
|
||||
return `M ${x1} ${y1} C ${x1 + 40} ${y1}, ${x2 - 20} ${y2}, ${x2} ${y2}`
|
||||
})
|
||||
|
||||
// Arc from last feat node back up to merge node on main
|
||||
const mergePath = computed(() => {
|
||||
if (!mergeNode.value || !featLog.value.length) return ''
|
||||
const x1 = featNodeX(featLog.value.length - 1)
|
||||
const y1 = FEAT_Y
|
||||
const mergeIdx = mainLog.value.length - 1
|
||||
const x2 = nodeX(mergeIdx)
|
||||
const y2 = MAIN_Y
|
||||
return `M ${x1} ${y1} C ${x1 + 30} ${y1}, ${x2 - 20} ${y2}, ${x2} ${y2}`
|
||||
})
|
||||
|
||||
const ops = [
|
||||
{
|
||||
id: 'create',
|
||||
cmd: 'git checkout -b feature-login',
|
||||
ok: () => !s.created,
|
||||
output: [
|
||||
{ kind: 'grn', text: "Switched to a new branch 'feature-login'" },
|
||||
],
|
||||
hint: '新分支创建了!它和 main 指向同一个提交,但是独立的"时间线"。现在你在 feature-login 上,main 的时间线不会动。',
|
||||
do: () => { s.created = true; branch.value = 'feature-login' },
|
||||
},
|
||||
{
|
||||
id: 'c1',
|
||||
cmd: 'git commit -m "feat: 登录表单"',
|
||||
ok: () => s.created && !s.c1,
|
||||
output: [
|
||||
{ kind: 'dim', text: '[feature-login e1a2b3c] feat: 登录表单' },
|
||||
{ kind: 'dim', text: ' 1 file changed, 38 insertions(+)' },
|
||||
],
|
||||
hint: '看图!feature-login 向右延伸了一个新节点,而 main 纹丝不动。这就是"平行宇宙"——两条线同时存在,互不影响。',
|
||||
do: () => { s.c1 = true; featLog.value.push({ hash: 'e1a2b3c', shortMsg: '登录表单' }) },
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
cmd: 'git commit -m "feat: 登录接口"',
|
||||
ok: () => s.c1 && !s.c2,
|
||||
output: [
|
||||
{ kind: 'dim', text: '[feature-login f4d5e6f] feat: 登录接口' },
|
||||
{ kind: 'dim', text: ' 1 file changed, 22 insertions(+)' },
|
||||
],
|
||||
hint: 'feature-login 又多了一个提交。此时它比 main 多了 2 个节点。功能开发完毕,准备合并回主线。',
|
||||
do: () => { s.c2 = true; featLog.value.push({ hash: 'f4d5e6f', shortMsg: '登录接口' }) },
|
||||
},
|
||||
{
|
||||
id: 'back',
|
||||
cmd: 'git checkout main',
|
||||
ok: () => s.c2 && branch.value !== 'main',
|
||||
output: [{ kind: 'grn', text: "Switched to branch 'main'" }],
|
||||
hint: '切回 main。HEAD 标签跳回到 main 最后的节点。feature-login 里写的代码,现在工作区完全看不到——两条线彻底隔离。',
|
||||
do: () => { branch.value = 'main' },
|
||||
},
|
||||
{
|
||||
id: 'merge',
|
||||
cmd: 'git merge feature-login',
|
||||
ok: () => s.c2 && branch.value === 'main' && !s.merged,
|
||||
output: [
|
||||
{ kind: 'dim', text: "Merge made by the 'ort' strategy." },
|
||||
{ kind: 'grn', text: ' login.js | 60 ++++++ 1 file changed' },
|
||||
],
|
||||
hint: '合并完成!看图:feature-login 的弧线收束回了 main,形成一个绿色合并节点。两条时间线重新汇合,登录功能进入主线。',
|
||||
do: () => {
|
||||
s.merged = true
|
||||
mergeNode.value = true
|
||||
mainLog.value.push({ hash: 'a9b8c7d', shortMsg: 'Merge', merge: true })
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
function scroll() { if (termEl.value) termEl.value.scrollTop = termEl.value.scrollHeight }
|
||||
|
||||
async function run(op) {
|
||||
if (running.value) return
|
||||
running.value = true; active.value = op.id; hint.value = ''; typing.value = ''
|
||||
for (const ch of op.cmd) { typing.value += ch; await sleep(22) }
|
||||
await sleep(80)
|
||||
lines.value.push({ kind: 'cmd', text: op.cmd }); typing.value = ''
|
||||
await nextTick(); scroll(); await sleep(150)
|
||||
for (const l of op.output) { lines.value.push(l); await nextTick(); scroll(); await sleep(50) }
|
||||
op.do(); await sleep(100); hint.value = op.hint; running.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lines.value = [{ kind: 'dim', text: '# main 分支上已有 2 次提交,按步骤演示分支操作' }]
|
||||
mainLog.value = [
|
||||
{ hash: '9f3e1b2', shortMsg: 'init', merge: false },
|
||||
{ hash: 'c4d8a31', shortMsg: '首页', merge: false },
|
||||
]
|
||||
featLog.value = []; branch.value = 'main'; mergeNode.value = false
|
||||
s = { created: false, c1: false, c2: false, merged: false }
|
||||
active.value = null
|
||||
hint.value = '👆 依次点击上方命令按钮,观察下方分支图的变化'
|
||||
typing.value = ''; running.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gb-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
background: var(--vp-c-bg-soft); margin: 1rem 0; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Terminal */
|
||||
.gb-terminal { background: #141420; }
|
||||
.term-bar {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 7px 12px; background: #1e1e2e;
|
||||
}
|
||||
.dot { width: 11px; height: 11px; border-radius: 50%; }
|
||||
.dot.r { background: #ff5f57; } .dot.y { background: #febc2e; } .dot.g { background: #28c840; }
|
||||
.term-title { margin-left: 8px; font-size: 0.72rem; color: #666; font-family: monospace; }
|
||||
.branch-tag { color: #cba6f7; font-weight: 600; }
|
||||
.term-body {
|
||||
min-height: 100px; max-height: 140px; overflow-y: auto;
|
||||
padding: 0.7rem 1rem;
|
||||
font-family: 'Menlo','Monaco',monospace; font-size: 0.76rem; line-height: 1.6; color: #cdd6f4;
|
||||
}
|
||||
.t-line { display: flex; }
|
||||
.t-ps { color: #a6e3a1; flex-shrink: 0; }
|
||||
.t-cmd { color: #cdd6f4; } .t-dim { color: #585b70; } .t-grn { color: #a6e3a1; }
|
||||
.t-typing { color: #cdd6f4; }
|
||||
.t-cur { animation: blink 1s step-end infinite; }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
|
||||
|
||||
/* Buttons */
|
||||
.gb-btns {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 8px 10px; background: #0d0d1a; border-top: 1px solid #2a2a3e;
|
||||
}
|
||||
.gb-btn {
|
||||
background: #1e1e2e; border: 1px solid #313244;
|
||||
border-radius: 5px; padding: 4px 9px; cursor: pointer; transition: border-color .2s;
|
||||
}
|
||||
.gb-btn code { font-size: 0.7rem; color: #7f849c; font-family: monospace; white-space: nowrap; }
|
||||
.gb-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); }
|
||||
.gb-btn--on { border-color: var(--vp-c-brand) !important; }
|
||||
.gb-btn--on code { color: var(--vp-c-brand); }
|
||||
.gb-btn--dim { opacity: 0.3; cursor: not-allowed; }
|
||||
.gb-btn--reset { background: transparent; border-color: #313244; margin-left: auto; }
|
||||
.gb-btn--reset::after { content: '重置'; font-size: 0.7rem; color: #585b70; }
|
||||
|
||||
/* Graph */
|
||||
.gb-graph-wrap {
|
||||
background: var(--vp-c-bg); border-top: 1px solid var(--vp-c-divider);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.gb-legend {
|
||||
display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 8px;
|
||||
font-size: 0.74rem; color: var(--vp-c-text-2);
|
||||
}
|
||||
.leg-item { display: flex; align-items: center; gap: 5px; }
|
||||
.leg-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.main-c { background: #5b9cf6; }
|
||||
.feat-c { background: #f9e2af; }
|
||||
.merge-c { background: #a6e3a1; }
|
||||
.leg-head {
|
||||
font-family: monospace; font-size: 0.68rem; font-weight: 700;
|
||||
background: #5b9cf655; color: #5b9cf6; padding: 1px 5px; border-radius: 3px;
|
||||
}
|
||||
.head-leg { gap: 4px; }
|
||||
|
||||
.svg-scroll { overflow-x: auto; }
|
||||
.gb-svg { display: block; overflow: visible; }
|
||||
|
||||
.gb-hint {
|
||||
padding: 8px 12px; background: var(--vp-c-bg-alt);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.8rem; color: var(--vp-c-text-2); line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="gcc-root">
|
||||
<p class="gcc-desc">把这张表存起来,遇到忘了的命令随时查:</p>
|
||||
<div class="gcc-chart-wrap">
|
||||
<div class="chart-header">
|
||||
<span class="y-axis-label">使用频率</span>
|
||||
<div class="chart-area">
|
||||
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${height}`" preserveAspectRatio="none" :width="chartWidth" :height="height">
|
||||
<!-- Grid lines (horizontal) -->
|
||||
<line v-for="y in gridY" :key="y" :x1="padding.left" :y1="y" :x2="chartWidth - padding.right" :y2="y" class="grid-line" />
|
||||
<!-- Y axis labels (1-5) -->
|
||||
<text v-for="label in yLabels" :key="label.val" :x="padding.left - 8" :y="label.y" class="y-label">{{ label.val }}</text>
|
||||
<!-- Bars -->
|
||||
<rect v-for="(row, i) in rows" :key="i" :x="barX(i)" :y="barY(row)" :width="barW" :height="barHeight(row)" class="bar-rect">
|
||||
<title>{{ row.cmd }} — {{ row.freqLabel || levelLabel(row.level) }}</title>
|
||||
</rect>
|
||||
<!-- X axis: 命令名 + 下方一行简短功能描述,旋转 -45° -->
|
||||
<g v-for="(row, i) in rows" :key="'label-'+i">
|
||||
<text
|
||||
:x="barX(i) + barW / 2"
|
||||
:y="labelY"
|
||||
class="x-label"
|
||||
text-anchor="end"
|
||||
:transform="`rotate(-45, ${barX(i) + barW / 2}, ${labelY})`"
|
||||
>
|
||||
{{ row.cmd }}
|
||||
</text>
|
||||
<text
|
||||
:x="barX(i) + barW / 2"
|
||||
:y="labelY + 26"
|
||||
class="x-desc"
|
||||
text-anchor="end"
|
||||
:transform="`rotate(-45, ${barX(i) + barW / 2}, ${labelY + 26})`"
|
||||
>
|
||||
{{ row.desc }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="x-axis-label">命令 <span class="scroll-hint">(可左右滑动查看)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const rawRows = [
|
||||
{ cmd: 'git init', desc: '在当前目录初始化 Git 仓库', level: 0, freqLabel: '项目开始时一次' },
|
||||
{ cmd: 'git status', desc: '查看工作区和暂存区的状态', level: 5, freqLabel: '极高频' },
|
||||
{ cmd: 'git add <文件>', desc: '把指定文件放入暂存区', level: 5, freqLabel: '每次提交前' },
|
||||
{ cmd: 'git add .', desc: '把所有修改放入暂存区', level: 5, freqLabel: '' },
|
||||
{ cmd: 'git commit -m "..."', desc: '提交暂存区内容,附上说明', level: 5, freqLabel: '' },
|
||||
{ cmd: 'git push', desc: '推送到远程仓库', level: 5, freqLabel: '' },
|
||||
{ cmd: 'git pull', desc: '拉取远程最新内容', level: 5, freqLabel: '' },
|
||||
{ cmd: 'git log --oneline', desc: '查看简洁的提交历史', level: 4, freqLabel: '' },
|
||||
{ cmd: 'git checkout -b <分支名>', desc: '创建并切换到新分支', level: 4, freqLabel: '' },
|
||||
{ cmd: 'git checkout <分支名>', desc: '切换到已有分支', level: 4, freqLabel: '' },
|
||||
{ cmd: 'git clone <url>', desc: '克隆远程仓库到本地', level: 4, freqLabel: '' },
|
||||
{ cmd: 'git branch', desc: '查看所有本地分支', level: 3, freqLabel: '' },
|
||||
{ cmd: 'git merge <分支名>', desc: '将指定分支合并到当前分支', level: 3, freqLabel: '' },
|
||||
{ cmd: 'git stash', desc: '临时保存未提交的改动(切换任务时用)', level: 3, freqLabel: '' },
|
||||
{ cmd: 'git stash pop', desc: '恢复之前 stash 的改动', level: 3, freqLabel: '' },
|
||||
{ cmd: 'git reset HEAD~1', desc: '撤销最近一次提交(保留改动)', level: 3, freqLabel: '' },
|
||||
{ cmd: 'git diff', desc: '查看工作区和暂存区的具体差异', level: 3, freqLabel: '' },
|
||||
{ cmd: 'git branch -d <分支名>', desc: '删除已合并的分支', level: 2, freqLabel: '' },
|
||||
{ cmd: 'git remote add origin <url>', desc: '关联远程仓库(只做一次)', level: 0, freqLabel: '项目初始时' },
|
||||
]
|
||||
|
||||
const rows = computed(() => [...rawRows].sort((a, b) => b.level - a.level))
|
||||
|
||||
function levelLabel(level) {
|
||||
const map = { 5: '极高频', 4: '高频', 3: '中频', 2: '低频', 1: '很少', 0: '一次性' }
|
||||
return map[level] || ''
|
||||
}
|
||||
|
||||
const barW = 24
|
||||
const slotWidth = 88
|
||||
const chartWidth = computed(() => rawRows.length * slotWidth + 44 + 24)
|
||||
const height = 320
|
||||
const padding = { top: 12, right: 24, bottom: 150, left: 44 }
|
||||
const labelY = height - padding.bottom + 16
|
||||
|
||||
function barX(index) {
|
||||
return padding.left + index * slotWidth + (slotWidth - barW) / 2
|
||||
}
|
||||
function barHeight(row) {
|
||||
const plotHeight = height - padding.top - padding.bottom
|
||||
return Math.max(4, (row.level / 5) * plotHeight)
|
||||
}
|
||||
function barY(row) {
|
||||
const plotHeight = height - padding.top - padding.bottom
|
||||
return height - padding.bottom - barHeight(row)
|
||||
}
|
||||
|
||||
const gridY = computed(() => {
|
||||
const plotHeight = height - padding.top - padding.bottom
|
||||
const step = plotHeight / 5
|
||||
return Array.from({ length: 6 }, (_, i) => padding.top + i * step)
|
||||
})
|
||||
|
||||
const yLabels = computed(() => {
|
||||
const plotHeight = height - padding.top - padding.bottom
|
||||
const step = plotHeight / 5
|
||||
return Array.from({ length: 6 }, (_, i) => ({
|
||||
val: 5 - i,
|
||||
y: padding.top + i * step + 4,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gcc-root {
|
||||
margin: 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gcc-desc {
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.gcc-chart-wrap {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
position: relative;
|
||||
}
|
||||
.y-axis-label {
|
||||
position: absolute;
|
||||
left: -26px;
|
||||
top: 50%;
|
||||
transform: rotate(-90deg) translateX(50%);
|
||||
transform-origin: left center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chart-area {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
min-height: 320px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.chart-svg {
|
||||
display: block;
|
||||
}
|
||||
.grid-line {
|
||||
stroke: var(--vp-c-divider);
|
||||
stroke-dasharray: 3 2;
|
||||
stroke-width: 1;
|
||||
}
|
||||
.y-label {
|
||||
font-size: 0.8rem;
|
||||
fill: var(--vp-c-text-3);
|
||||
text-anchor: end;
|
||||
}
|
||||
.bar-rect {
|
||||
fill: var(--vp-c-brand);
|
||||
rx: 2;
|
||||
transition: fill 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bar-rect:hover {
|
||||
fill: var(--vp-c-brand-2);
|
||||
}
|
||||
.x-label {
|
||||
font-size: 0.85rem;
|
||||
fill: var(--vp-c-text-2);
|
||||
}
|
||||
.x-desc {
|
||||
font-size: 0.72rem;
|
||||
fill: var(--vp-c-text-3);
|
||||
}
|
||||
.x-axis-label {
|
||||
margin-top: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
.scroll-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -1,455 +0,0 @@
|
||||
<template>
|
||||
<div class="command-demo">
|
||||
<div class="panel">
|
||||
<div class="terminal">
|
||||
<div class="output">
|
||||
<div
|
||||
v-for="(line, i) in output"
|
||||
:key="i"
|
||||
:class="line.type"
|
||||
>
|
||||
<span
|
||||
v-if="line.type === 'command'"
|
||||
class="prompt"
|
||||
>$</span>
|
||||
<span v-html="line.text" />
|
||||
</div>
|
||||
<div
|
||||
v-if="output.length === 0"
|
||||
class="welcome"
|
||||
>
|
||||
输入命令开始学习 Git(建议先点“制造改动”,再跑 git status)
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-line">
|
||||
<span class="prompt">$</span>
|
||||
<input
|
||||
v-model="cmd"
|
||||
placeholder="(默认安全模式)请用下方按钮执行命令"
|
||||
class="cmd-input"
|
||||
:disabled="!freeMode"
|
||||
@keyup.enter="execute({ fromQuick: false })"
|
||||
>
|
||||
<button
|
||||
class="run-btn"
|
||||
:disabled="!freeMode"
|
||||
@click="execute({ fromQuick: false })"
|
||||
>
|
||||
运行
|
||||
</button>
|
||||
<button
|
||||
class="run-btn secondary"
|
||||
@click="clearOutput"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
class="run-btn secondary"
|
||||
@click="toggleFreeMode"
|
||||
>
|
||||
{{ freeMode ? '切回安全模式' : '开启自由模式' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-cmds">
|
||||
<button
|
||||
class="cmd-btn"
|
||||
@click="makeChanges"
|
||||
>
|
||||
制造改动
|
||||
</button>
|
||||
<button
|
||||
class="cmd-btn"
|
||||
@click="runCmd('git init')"
|
||||
>
|
||||
git init
|
||||
</button>
|
||||
<button
|
||||
class="cmd-btn"
|
||||
@click="runCmd('git status')"
|
||||
>
|
||||
git status
|
||||
</button>
|
||||
<button
|
||||
class="cmd-btn"
|
||||
@click="runCmd('git add .')"
|
||||
>
|
||||
git add .
|
||||
</button>
|
||||
<button
|
||||
class="cmd-btn"
|
||||
@click="runCmd(`git commit -m 'msg'`)"
|
||||
>
|
||||
git commit
|
||||
</button>
|
||||
<button
|
||||
class="cmd-btn"
|
||||
@click="runCmd('git log --oneline')"
|
||||
>
|
||||
git log
|
||||
</button>
|
||||
<button
|
||||
class="cmd-btn"
|
||||
@click="runCmd('git switch -c feat/demo')"
|
||||
>
|
||||
新分支
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<strong>💡 建议练习顺序:</strong> 制造改动 → status → add → status →
|
||||
commit → log
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const cmd = ref('')
|
||||
const output = ref([])
|
||||
const freeMode = ref(false)
|
||||
|
||||
// Minimal in-memory git state for learning purposes.
|
||||
const state = ref({
|
||||
inited: false,
|
||||
branch: 'main',
|
||||
commits: { main: [] },
|
||||
working: [], // modified files (not staged)
|
||||
staged: [] // staged files
|
||||
})
|
||||
|
||||
const pushLine = (type, text) => {
|
||||
output.value.push({ type, text: escapeHtml(text).replace(/\n/g, '<br />') })
|
||||
// keep the terminal from growing forever
|
||||
if (output.value.length > 60) output.value.splice(0, output.value.length - 60)
|
||||
}
|
||||
|
||||
const escapeHtml = (s) =>
|
||||
s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('\"', '"')
|
||||
.replaceAll("'", ''')
|
||||
|
||||
const genHash = () => Math.random().toString(16).slice(2, 9)
|
||||
|
||||
const ensureRepo = () => {
|
||||
if (!state.value.inited) {
|
||||
pushLine(
|
||||
'error',
|
||||
'fatal: not a git repository (or any of the parent directories): .git'
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const statusText = () => {
|
||||
const s = state.value
|
||||
const lines = [`On branch ${s.branch}`]
|
||||
if (s.staged.length === 0 && s.working.length === 0) {
|
||||
lines.push('nothing to commit, working tree clean')
|
||||
return lines.join('\n')
|
||||
}
|
||||
if (s.staged.length) {
|
||||
lines.push('Changes to be committed:')
|
||||
s.staged.forEach((f) => lines.push(` modified: ${f}`))
|
||||
}
|
||||
if (s.working.length) {
|
||||
lines.push('Changes not staged for commit:')
|
||||
s.working.forEach((f) => lines.push(` modified: ${f}`))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const logText = () => {
|
||||
const s = state.value
|
||||
const list = s.commits[s.branch] || []
|
||||
if (!list.length)
|
||||
return 'fatal: your current branch does not have any commits yet'
|
||||
return list
|
||||
.slice()
|
||||
.reverse()
|
||||
.slice(0, 8)
|
||||
.map((c) => `${c.hash} ${c.msg}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const branchText = () => {
|
||||
const s = state.value
|
||||
return Object.keys(s.commits)
|
||||
.sort()
|
||||
.map((b) => (b === s.branch ? `* ${b}` : ` ${b}`))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const makeChanges = () => {
|
||||
if (!state.value.inited) {
|
||||
pushLine('info', '提示:先 git init,再制造改动效果更真实。')
|
||||
return
|
||||
}
|
||||
const base = ['src/app.js', 'README.md', 'src/utils.js']
|
||||
state.value.working = base.slice(0, 1 + Math.floor(Math.random() * 3))
|
||||
// staged changes are independent
|
||||
pushLine(
|
||||
'success',
|
||||
`Edited ${state.value.working.length} file(s) (simulated).`
|
||||
)
|
||||
}
|
||||
|
||||
const execute = ({ fromQuick }) => {
|
||||
if (!freeMode.value && !fromQuick) {
|
||||
pushLine(
|
||||
'info',
|
||||
'当前是安全模式:请用下方按钮执行预设命令,避免“想当然”操作造成误解。'
|
||||
)
|
||||
cmd.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const c = cmd.value.trim()
|
||||
if (!c) return
|
||||
|
||||
pushLine('command', c)
|
||||
|
||||
// Commands
|
||||
if (c === 'git init') {
|
||||
state.value.inited = true
|
||||
state.value.branch = 'main'
|
||||
state.value.commits = { main: [] }
|
||||
state.value.working = []
|
||||
state.value.staged = []
|
||||
pushLine('success', 'Initialized empty Git repository in ./.git/')
|
||||
} else if (c === 'git status') {
|
||||
if (!ensureRepo()) return
|
||||
pushLine('info', statusText())
|
||||
} else if (c === 'git add .' || c.startsWith('git add ')) {
|
||||
if (!ensureRepo()) return
|
||||
const s = state.value
|
||||
if (s.working.length === 0) {
|
||||
pushLine('info', 'Nothing specified, nothing added.')
|
||||
return
|
||||
}
|
||||
const toStage =
|
||||
c === 'git add .'
|
||||
? [...s.working]
|
||||
: [c.replace(/^git add\s+/, '').trim()].filter(Boolean)
|
||||
toStage.forEach((f) => {
|
||||
if (!s.staged.includes(f)) s.staged.push(f)
|
||||
s.working = s.working.filter((x) => x !== f)
|
||||
})
|
||||
pushLine('success', `Added ${toStage.length} path(s) to staging area.`)
|
||||
} else if (c.startsWith('git commit')) {
|
||||
if (!ensureRepo()) return
|
||||
const s = state.value
|
||||
if (s.staged.length === 0) {
|
||||
pushLine('error', 'nothing to commit (no changes added to commit)')
|
||||
return
|
||||
}
|
||||
const msgMatch = c.match(/-m\\s+\"([^\"]+)\"|-m\\s+'([^']+)'/)
|
||||
const msg = msgMatch?.[1] || msgMatch?.[2] || 'commit'
|
||||
const commit = { hash: genHash(), msg, files: [...s.staged] }
|
||||
if (!s.commits[s.branch]) s.commits[s.branch] = []
|
||||
s.commits[s.branch].push(commit)
|
||||
s.staged = []
|
||||
pushLine(
|
||||
'success',
|
||||
`[${s.branch} ${commit.hash}] ${msg}\\n ${commit.files.length} file(s) changed`
|
||||
)
|
||||
} else if (c === 'git log --oneline') {
|
||||
if (!ensureRepo()) return
|
||||
pushLine('info', logText())
|
||||
} else if (c === 'git branch') {
|
||||
if (!ensureRepo()) return
|
||||
pushLine('info', branchText())
|
||||
} else if (
|
||||
c.startsWith('git switch -c ') ||
|
||||
c.startsWith('git checkout -b ')
|
||||
) {
|
||||
if (!ensureRepo()) return
|
||||
const name = c.replace(/^git (switch -c|checkout -b)\s+/, '').trim()
|
||||
if (!name) {
|
||||
pushLine('error', 'fatal: you must specify a branch name')
|
||||
return
|
||||
}
|
||||
if (state.value.commits[name]) {
|
||||
pushLine('error', `fatal: A branch named '${name}' already exists.`)
|
||||
return
|
||||
}
|
||||
const base = state.value.commits[state.value.branch] || []
|
||||
state.value.commits[name] = [...base]
|
||||
state.value.branch = name
|
||||
pushLine('success', `Switched to a new branch '${name}'`)
|
||||
} else if (c.startsWith('git switch ') || c.startsWith('git checkout ')) {
|
||||
if (!ensureRepo()) return
|
||||
const name = c.replace(/^git (switch|checkout)\s+/, '').trim()
|
||||
if (!state.value.commits[name]) {
|
||||
pushLine(
|
||||
'error',
|
||||
`error: pathspec '${name}' did not match any file(s) known to git`
|
||||
)
|
||||
return
|
||||
}
|
||||
state.value.branch = name
|
||||
pushLine('success', `Switched to branch '${name}'`)
|
||||
} else if (c.startsWith('git restore')) {
|
||||
if (!ensureRepo()) return
|
||||
// Simplified restore for learning: clear working changes
|
||||
state.value.working = []
|
||||
pushLine('success', 'Restored working tree (simulated).')
|
||||
} else {
|
||||
pushLine(
|
||||
'error',
|
||||
'Unknown command (supported: init/status/add/commit/log/branch/switch/checkout/restore)'
|
||||
)
|
||||
}
|
||||
|
||||
cmd.value = ''
|
||||
}
|
||||
|
||||
const runCmd = (c) => {
|
||||
cmd.value = c
|
||||
execute({ fromQuick: true })
|
||||
}
|
||||
|
||||
const clearOutput = () => {
|
||||
output.value = []
|
||||
}
|
||||
|
||||
const toggleFreeMode = () => {
|
||||
freeMode.value = !freeMode.value
|
||||
cmd.value = ''
|
||||
pushLine(
|
||||
'info',
|
||||
freeMode.value
|
||||
? '已开启自由模式:现在可以手动输入命令(仍然只模拟,不会影响真实仓库)。'
|
||||
: '已切回安全模式:请使用下方按钮执行预设命令。'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.command-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.output {
|
||||
min-height: 150px;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.output .command {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.output .success {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.output .error {
|
||||
color: var(--vp-c-red-1, #ef4444);
|
||||
}
|
||||
.output .info {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
.output .welcome {
|
||||
color: var(--vp-c-text-2);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.input-line {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.cmd-input {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.875rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.cmd-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(var(--vp-c-brand-rgb), 0.45);
|
||||
box-shadow: 0 0 0 3px rgba(var(--vp-c-brand-rgb), 0.12);
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.run-btn.secondary {
|
||||
background: var(--vp-c-bg);
|
||||
border-color: var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.quick-cmds {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cmd-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cmd-btn:hover {
|
||||
background: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,529 @@
|
||||
<template>
|
||||
<div class="gc-root">
|
||||
<!-- Terminal -->
|
||||
<div class="gc-terminal">
|
||||
<div class="term-bar">
|
||||
<span class="dot r" /><span class="dot y" /><span class="dot g" />
|
||||
<span class="term-title">~/project (main)</span>
|
||||
</div>
|
||||
<div ref="termEl" class="term-body">
|
||||
<div v-for="(l, i) in lines" :key="i" class="t-line">
|
||||
<span v-if="l.kind === 'cmd'" class="t-ps">$ </span>
|
||||
<span :class="'t-' + l.kind">{{ l.text }}</span>
|
||||
</div>
|
||||
<div class="t-line">
|
||||
<span class="t-ps">$ </span>
|
||||
<span class="t-typing">{{ typing }}<span class="t-cur">▋</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="gc-btns">
|
||||
<button
|
||||
v-for="op in ops"
|
||||
:key="op.id"
|
||||
:disabled="running || !op.ok()"
|
||||
:class="['gc-btn', { 'gc-btn--on': active === op.id, 'gc-btn--dim': !op.ok() }]"
|
||||
@click="run(op)"
|
||||
>
|
||||
<code>{{ op.cmd }}</code>
|
||||
</button>
|
||||
<button class="gc-btn gc-btn--reset" :disabled="running" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<!-- 三区可视化 -->
|
||||
<div class="gc-three-areas">
|
||||
<div class="area-col area-work" :class="{ 'area-highlight': pulseArea === 'work' }">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">📝</span>
|
||||
<span class="area-title">工作区</span>
|
||||
<span class="area-desc">Working Directory<br/>你正在改的文件</span>
|
||||
</div>
|
||||
<div class="area-body">
|
||||
<div class="area-label">Changes not staged for commit:</div>
|
||||
<template v-if="workFiles.length">
|
||||
<div v-for="f in workFiles" :key="f.name" class="file-row file-mod">
|
||||
<span class="file-badge">M</span>
|
||||
<code class="file-name">{{ f.name }}</code>
|
||||
<span class="file-state">未暂存</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="area-empty">(无未暂存修改)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="area-arrow" :class="{ 'arrow-lit': addDone }">
|
||||
<code class="arrow-cmd">git add</code>
|
||||
<span class="arrow-symbol">→</span>
|
||||
</div>
|
||||
|
||||
<div class="area-col area-stage" :class="{ 'area-highlight': pulseArea === 'stage' }">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">📦</span>
|
||||
<span class="area-title">暂存区</span>
|
||||
<span class="area-desc">Staging Area<br/>准备这次提交的文件</span>
|
||||
</div>
|
||||
<div class="area-body">
|
||||
<div class="area-label">Changes to be committed:</div>
|
||||
<template v-if="stagedFiles.length">
|
||||
<div v-for="f in stagedFiles" :key="f.name" class="file-row file-staged">
|
||||
<span class="file-badge">A</span>
|
||||
<code class="file-name">{{ f.name }}</code>
|
||||
<span class="file-state">已暂存</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="area-empty">(空)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="area-arrow" :class="{ 'arrow-lit': commitDone }">
|
||||
<code class="arrow-cmd">git commit</code>
|
||||
<span class="arrow-symbol">→</span>
|
||||
</div>
|
||||
|
||||
<div class="area-col area-repo" :class="{ 'area-highlight': pulseArea === 'repo' }">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">🗄️</span>
|
||||
<span class="area-title">仓库</span>
|
||||
<span class="area-desc">Repository (.git)<br/>永久保存的版本</span>
|
||||
</div>
|
||||
<div class="area-body">
|
||||
<div class="area-label">已提交记录 (git log):</div>
|
||||
<template v-if="commits.length">
|
||||
<div v-for="(c, i) in commits" :key="c.hash" class="commit-row">
|
||||
<span class="commit-badge">✓</span>
|
||||
<code class="commit-hash">{{ c.hash }}</code>
|
||||
<span class="commit-msg">{{ c.msg }}</span>
|
||||
<span v-if="i === 0" class="commit-head">HEAD</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="area-empty">(无提交)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint -->
|
||||
<div v-if="hint" class="gc-hint">💡 {{ hint }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const termEl = ref(null)
|
||||
const lines = ref([{ kind: 'dim', text: '# 你刚改了 3 个文件,现在演示 add → commit 流程' }])
|
||||
const typing = ref('')
|
||||
const running = ref(false)
|
||||
const active = ref(null)
|
||||
const hint = ref('点击下方命令按钮,按顺序执行。观察上方三区里文件如何随命令移动。')
|
||||
const pulseArea = ref(null)
|
||||
|
||||
const files = ref([
|
||||
{ name: 'login.js', staged: false, committed: false },
|
||||
{ name: 'style.css', staged: false, committed: false },
|
||||
{ name: 'debug.log', staged: false, committed: false },
|
||||
])
|
||||
const commits = ref([{ hash: '9f3e1b2', msg: 'init: 项目初始化' }])
|
||||
|
||||
// 工作区:未暂存且未提交的修改(git status 里红色的)
|
||||
const workFiles = computed(() =>
|
||||
files.value.filter(f => !f.staged && !f.committed)
|
||||
)
|
||||
// 暂存区:已暂存但还没提交的(git status 里绿色的)
|
||||
const stagedFiles = computed(() =>
|
||||
files.value.filter(f => f.staged && !f.committed)
|
||||
)
|
||||
|
||||
let addDone = false, commitDone = false
|
||||
|
||||
const ops = [
|
||||
{
|
||||
id: 'status',
|
||||
cmd: 'git status',
|
||||
ok: () => true,
|
||||
output: [
|
||||
{ kind: 'dim', text: 'On branch main' },
|
||||
{ kind: 'dim', text: '' },
|
||||
{ kind: 'dim', text: 'Changes not staged for commit:' },
|
||||
{ kind: 'red', text: ' modified: login.js' },
|
||||
{ kind: 'red', text: ' modified: style.css' },
|
||||
{ kind: 'red', text: ' modified: debug.log' },
|
||||
{ kind: 'dim', text: '' },
|
||||
{ kind: 'dim', text: 'no changes added to commit (use "git add")' },
|
||||
],
|
||||
hint: '红色 = 改了但还没暂存。三区里可以看到:3 个文件都在「工作区」,暂存区是空的。先用 git status 看清楚状态,再决定下一步。',
|
||||
do: () => { pulseArea.value = 'work' },
|
||||
},
|
||||
{
|
||||
id: 'add',
|
||||
cmd: 'git add login.js style.css',
|
||||
ok: () => !addDone,
|
||||
output: [
|
||||
{ kind: 'dim', text: '# git add 只加你指定的文件,debug.log 跳过' },
|
||||
{ kind: 'dim', text: '' },
|
||||
{ kind: 'dim', text: 'On branch main' },
|
||||
{ kind: 'dim', text: '' },
|
||||
{ kind: 'dim', text: 'Changes to be committed:' },
|
||||
{ kind: 'grn', text: ' modified: login.js' },
|
||||
{ kind: 'grn', text: ' modified: style.css' },
|
||||
{ kind: 'dim', text: '' },
|
||||
{ kind: 'red', text: 'Untracked files:' },
|
||||
{ kind: 'red', text: ' debug.log ← 没 add,不会提交' },
|
||||
],
|
||||
hint: '绿色 = 进入暂存区。观察:login.js 和 style.css 从工作区「搬进」了暂存区;debug.log 仍留在工作区(未暂存),不会参与这次提交。',
|
||||
do: () => {
|
||||
addDone = true
|
||||
files.value[0].staged = true
|
||||
files.value[1].staged = true
|
||||
pulseArea.value = 'stage'
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'commit',
|
||||
cmd: 'git commit -m "feat: 添加登录功能"',
|
||||
ok: () => addDone && !commitDone,
|
||||
output: [
|
||||
{ kind: 'dim', text: '[main a1b2c3d] feat: 添加登录功能' },
|
||||
{ kind: 'dim', text: ' 2 files changed, 47 insertions(+)' },
|
||||
{ kind: 'dim', text: ' create mode 100644 login.js' },
|
||||
{ kind: 'dim', text: ' create mode 100644 style.css' },
|
||||
],
|
||||
hint: 'commit 成功!暂存区里的内容被「封存」进仓库,形成新的一条提交记录。暂存区变空;debug.log 仍在工作区,不受影响。',
|
||||
do: () => {
|
||||
commitDone = true
|
||||
files.value[0].staged = false
|
||||
files.value[0].committed = true
|
||||
files.value[1].staged = false
|
||||
files.value[1].committed = true
|
||||
commits.value.unshift({ hash: 'a1b2c3d', msg: 'feat: 添加登录功能' })
|
||||
pulseArea.value = 'repo'
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'log',
|
||||
cmd: 'git log --oneline',
|
||||
ok: () => commitDone,
|
||||
output: [
|
||||
{ kind: 'yel', text: 'a1b2c3d (HEAD -> main) feat: 添加登录功能' },
|
||||
{ kind: 'yel', text: '9f3e1b2 init: 项目初始化' },
|
||||
],
|
||||
hint: '每行一个 commit,最新的在最上面。仓库区里可以看到完整的历史时间轴;工作区里只剩 debug.log(未提交的临时文件)。',
|
||||
do: () => { pulseArea.value = 'repo' },
|
||||
},
|
||||
{
|
||||
id: 'status2',
|
||||
cmd: 'git status',
|
||||
ok: () => commitDone,
|
||||
output: [
|
||||
{ kind: 'dim', text: 'On branch main' },
|
||||
{ kind: 'dim', text: '' },
|
||||
{ kind: 'dim', text: 'Changes not staged for commit:' },
|
||||
{ kind: 'red', text: ' modified: debug.log' },
|
||||
{ kind: 'dim', text: '' },
|
||||
{ kind: 'dim', text: 'no changes added to commit (use "git add")' },
|
||||
],
|
||||
hint: '提交后:login.js 和 style.css 已进仓库,工作区里只剩 debug.log 的修改。红色 = 改了但还没暂存,下次提交前可再 git add。',
|
||||
do: () => { pulseArea.value = 'work' },
|
||||
},
|
||||
]
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
async function run(op) {
|
||||
if (running.value) return
|
||||
running.value = true
|
||||
active.value = op.id
|
||||
hint.value = ''
|
||||
typing.value = ''
|
||||
pulseArea.value = null
|
||||
|
||||
for (const ch of op.cmd) {
|
||||
typing.value += ch
|
||||
await sleep(22)
|
||||
}
|
||||
await sleep(80)
|
||||
lines.value.push({ kind: 'cmd', text: op.cmd })
|
||||
typing.value = ''
|
||||
await nextTick()
|
||||
scroll()
|
||||
await sleep(150)
|
||||
|
||||
for (const l of op.output) {
|
||||
lines.value.push(l)
|
||||
await nextTick()
|
||||
scroll()
|
||||
await sleep(50)
|
||||
}
|
||||
|
||||
op.do()
|
||||
await sleep(120)
|
||||
hint.value = op.hint
|
||||
running.value = false
|
||||
setTimeout(() => { pulseArea.value = null }, 1500)
|
||||
}
|
||||
|
||||
function scroll() {
|
||||
if (termEl.value) termEl.value.scrollTop = termEl.value.scrollHeight
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lines.value = [{ kind: 'dim', text: '# 你刚改了 3 个文件,现在演示 add → commit 流程' }]
|
||||
files.value.forEach(f => { f.staged = false; f.committed = false })
|
||||
commits.value = [{ hash: '9f3e1b2', msg: 'init: 项目初始化' }]
|
||||
addDone = false
|
||||
commitDone = false
|
||||
active.value = null
|
||||
pulseArea.value = null
|
||||
hint.value = '点击下方命令按钮,按顺序执行。观察上方三区里文件如何随命令移动。'
|
||||
typing.value = ''
|
||||
running.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gc-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg-soft);
|
||||
margin: 1rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Terminal */
|
||||
.gc-terminal { background: #141420; }
|
||||
.term-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 12px;
|
||||
background: #1e1e2e;
|
||||
}
|
||||
.dot { width: 11px; height: 11px; border-radius: 50%; }
|
||||
.dot.r { background: #ff5f57; }
|
||||
.dot.y { background: #febc2e; }
|
||||
.dot.g { background: #28c840; }
|
||||
.term-title { margin-left: 8px; font-size: 0.72rem; color: #666; font-family: monospace; }
|
||||
|
||||
.term-body {
|
||||
min-height: 140px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.8rem 1rem;
|
||||
font-family: 'Menlo', 'Monaco', monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.65;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
.t-line { display: flex; }
|
||||
.t-ps { color: #a6e3a1; flex-shrink: 0; }
|
||||
.t-cmd { color: #cdd6f4; }
|
||||
.t-dim { color: #585b70; }
|
||||
.t-red { color: #f38ba8; }
|
||||
.t-grn { color: #a6e3a1; }
|
||||
.t-yel { color: #89b4fa; }
|
||||
.t-typing { color: #cdd6f4; }
|
||||
.t-cur { animation: blink 1s step-end infinite; }
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
|
||||
/* Buttons */
|
||||
.gc-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #0d0d1a;
|
||||
border-top: 1px solid #2a2a3e;
|
||||
}
|
||||
.gc-btn {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #313244;
|
||||
border-radius: 5px;
|
||||
padding: 4px 9px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.gc-btn code { font-size: 0.7rem; color: #7f849c; font-family: monospace; white-space: nowrap; }
|
||||
.gc-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); }
|
||||
.gc-btn--on { border-color: var(--vp-c-brand) !important; }
|
||||
.gc-btn--on code { color: var(--vp-c-brand); }
|
||||
.gc-btn--dim { opacity: 0.3; cursor: not-allowed; }
|
||||
.gc-btn--reset {
|
||||
background: transparent;
|
||||
border-color: #313244;
|
||||
margin-left: auto;
|
||||
}
|
||||
.gc-btn--reset code { display: none; }
|
||||
.gc-btn--reset::after { content: '重置'; font-size: 0.7rem; color: #585b70; }
|
||||
|
||||
/* 三区布局 */
|
||||
.gc-three-areas {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
padding: 12px 14px;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
min-height: 180px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.gc-three-areas {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto auto auto;
|
||||
}
|
||||
.area-arrow { transform: rotate(90deg); justify-self: center; }
|
||||
}
|
||||
|
||||
.area-col {
|
||||
border: 1.5px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
.area-col.area-highlight {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand) 14%, transparent);
|
||||
}
|
||||
.area-work { border-left: 4px solid #f38ba8; }
|
||||
.area-stage { border-left: 4px solid #a6e3a1; }
|
||||
.area-repo { border-left: 4px solid #5b9cf6; }
|
||||
|
||||
.area-header {
|
||||
padding: 6px 10px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.area-icon { font-size: 1rem; margin-right: 4px; }
|
||||
.area-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.area-desc {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.area-body {
|
||||
padding: 8px 10px;
|
||||
flex: 1;
|
||||
min-height: 72px;
|
||||
}
|
||||
.area-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 6px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.area-empty {
|
||||
font-size: 0.74rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.file-row,
|
||||
.commit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.76rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.file-row:last-child,
|
||||
.commit-row:last-child { margin-bottom: 0; }
|
||||
.file-mod {
|
||||
background: #f38ba818;
|
||||
border-left: 3px solid #f38ba8;
|
||||
}
|
||||
.file-staged {
|
||||
background: #a6e3a118;
|
||||
border-left: 3px solid #a6e3a1;
|
||||
}
|
||||
.file-badge {
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.file-mod .file-badge { color: #f38ba8; }
|
||||
.file-staged .file-badge { color: #a6e3a1; }
|
||||
.file-name { font-family: monospace; color: var(--vp-c-text-1); }
|
||||
.file-state {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
background: #5b9cf618;
|
||||
border-left: 3px solid #5b9cf6;
|
||||
}
|
||||
.commit-badge { color: #5b9cf6; font-weight: 700; flex-shrink: 0; }
|
||||
.commit-hash {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
color: #5b9cf6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.commit-msg {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.commit-head {
|
||||
font-size: 0.64rem;
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
background: #5b9cf6;
|
||||
color: #fff;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.area-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.area-arrow.arrow-lit { opacity: 1; }
|
||||
.arrow-cmd {
|
||||
font-size: 0.66rem;
|
||||
font-family: monospace;
|
||||
color: var(--vp-c-brand);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.arrow-symbol {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-brand);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gc-hint {
|
||||
padding: 10px 12px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,132 +0,0 @@
|
||||
<template>
|
||||
<div class="conflict-demo">
|
||||
<div class="panel">
|
||||
<div class="editor">
|
||||
<div class="line normal">
|
||||
<span class="ln">1</span>function greet() {
|
||||
</div>
|
||||
<div class="line normal">
|
||||
<span class="ln">2</span> console.log('Hi');
|
||||
</div>
|
||||
<div class="line conflict">
|
||||
<span class="ln">3</span><<<<<<< HEAD
|
||||
</div>
|
||||
<div class="line current">
|
||||
<span class="ln">4</span> console.log('Welcome') // 当前版本
|
||||
</div>
|
||||
<div class="line conflict">
|
||||
<span class="ln">5</span>=======
|
||||
</div>
|
||||
<div class="line incoming">
|
||||
<span class="ln">6</span> console.log('Greetings') // 传入版本
|
||||
</div>
|
||||
<div class="line conflict">
|
||||
<span class="ln">7</span>>>>>>>>> feature
|
||||
</div>
|
||||
<div class="line normal">
|
||||
<span class="ln">8</span> console.log('Bye');
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="resolve('current')"
|
||||
>
|
||||
保留当前
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="resolve('incoming')"
|
||||
>
|
||||
保留传入
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="resolve('manual')"
|
||||
>
|
||||
手动合并
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 解决冲突:</strong> 选择保留哪个版本,或手动编辑合并</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const resolved = ref(false)
|
||||
const resolve = (choice) => {
|
||||
resolved.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conflict-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.editor {
|
||||
background: #1f2937;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
font-family: monospace;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ln {
|
||||
color: #6b7280;
|
||||
min-width: 2rem;
|
||||
}
|
||||
.line.normal {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.line.conflict {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.line.current {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
.line.incoming {
|
||||
color: #a78bfa;
|
||||
background: rgba(167, 139, 250, 0.1);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
}
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,264 +0,0 @@
|
||||
<template>
|
||||
<div class="remote-demo">
|
||||
<div class="panel">
|
||||
<div class="repos">
|
||||
<div class="repo">
|
||||
<div class="header">
|
||||
💻 本地
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="badge">main</span>
|
||||
<span class="hint"> Ahead {{ ahead }} / Behind {{ behind }} </span>
|
||||
</div>
|
||||
<div class="commits">
|
||||
<div
|
||||
v-for="c in local"
|
||||
:key="c"
|
||||
class="commit-dot"
|
||||
>
|
||||
<span class="dot local" />
|
||||
<span class="hash">{{ c.substring(0, 6) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="local.length === 0"
|
||||
class="empty"
|
||||
>
|
||||
无
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sync">
|
||||
⇄
|
||||
</div>
|
||||
|
||||
<div class="repo">
|
||||
<div class="header">
|
||||
☁️ 远程
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="badge">origin/main</span>
|
||||
<span class="hint">模拟队友提交在这里发生</span>
|
||||
</div>
|
||||
<div class="commits">
|
||||
<div
|
||||
v-for="c in remote"
|
||||
:key="c"
|
||||
class="commit-dot"
|
||||
>
|
||||
<span class="dot remote" />
|
||||
<span class="hash">{{ c.substring(0, 6) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="remote.length === 0"
|
||||
class="empty"
|
||||
>
|
||||
无
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="btn"
|
||||
@click="localCommit"
|
||||
>
|
||||
本地提交
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
@click="remoteCommit"
|
||||
>
|
||||
远程新增提交
|
||||
</button>
|
||||
<button
|
||||
:disabled="local.length <= remote.length"
|
||||
class="btn"
|
||||
@click="push"
|
||||
>
|
||||
git push
|
||||
</button>
|
||||
<button
|
||||
:disabled="behind === 0"
|
||||
class="btn"
|
||||
@click="pull"
|
||||
>
|
||||
git pull
|
||||
</button>
|
||||
<button
|
||||
class="btn secondary"
|
||||
@click="reset"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<strong>💡 远程协作:</strong> 你本地落后(Behind)就
|
||||
pull,你本地领先(Ahead)就 push。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
const local = ref([])
|
||||
const remote = ref([])
|
||||
|
||||
const localCommit = () => {
|
||||
local.value.push(Math.random().toString(16).substr(2, 7))
|
||||
}
|
||||
|
||||
const remoteCommit = () => {
|
||||
remote.value.push(Math.random().toString(16).substr(2, 7))
|
||||
}
|
||||
|
||||
const push = () => {
|
||||
remote.value = [...local.value]
|
||||
}
|
||||
|
||||
const pull = () => {
|
||||
local.value = [...remote.value]
|
||||
}
|
||||
|
||||
const ahead = computed(() =>
|
||||
Math.max(0, local.value.length - remote.value.length)
|
||||
)
|
||||
const behind = computed(() =>
|
||||
Math.max(0, remote.value.length - local.value.length)
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
local.value = []
|
||||
remote.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remote-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.repos {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.repo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
.header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
.hint {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.commits {
|
||||
min-height: 80px;
|
||||
}
|
||||
.commit-dot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.local {
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
.dot.remote {
|
||||
background: rgba(var(--vp-c-brand-rgb), 0.5);
|
||||
}
|
||||
.hash {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
.sync {
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty {
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.secondary {
|
||||
border-color: var(--vp-c-divider);
|
||||
}
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.repos {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sync {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,561 +0,0 @@
|
||||
<template>
|
||||
<div class="git-scenarios-demo">
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<div class="h">
|
||||
常见场景:直接照抄的 Git 命令
|
||||
</div>
|
||||
<div class="sub">
|
||||
选一个场景,按步骤执行;每一步都解释“为什么这么做”。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="activeStepIndex === 0"
|
||||
@click="prevStep"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
:disabled="activeStepIndex >= activeScenario.steps.length - 1"
|
||||
@click="nextStep"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
@click="resetSteps"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="s in scenarios"
|
||||
:key="s.id"
|
||||
class="tab"
|
||||
:class="{ active: activeScenarioId === s.id }"
|
||||
@click="selectScenario(s.id)"
|
||||
>
|
||||
{{ s.title }}
|
||||
<span class="tag">{{ s.level }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="scenario-meta">
|
||||
<div class="scenario-desc">
|
||||
{{ activeScenario.desc }}
|
||||
</div>
|
||||
<div
|
||||
v-if="activeScenario.note"
|
||||
class="scenario-note"
|
||||
>
|
||||
{{ activeScenario.note }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-card">
|
||||
<div class="step-top">
|
||||
<div class="step-title">
|
||||
Step {{ activeStepIndex + 1 }} / {{ activeScenario.steps.length }}
|
||||
<span class="step-name">{{ activeStep.title }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="copy-btn"
|
||||
@click="copy(activeStep.cmd)"
|
||||
>
|
||||
{{ copied ? '已复制' : '复制命令' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cmd">
|
||||
<code>{{ activeStep.cmd }}</code>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeStep.output"
|
||||
class="output"
|
||||
>
|
||||
<div class="label">
|
||||
你通常会看到:
|
||||
</div>
|
||||
<pre><code>{{ activeStep.output }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="why">
|
||||
<div class="label">
|
||||
为什么:
|
||||
</div>
|
||||
<div class="text">
|
||||
{{ activeStep.why }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeStep.warn"
|
||||
class="warn"
|
||||
>
|
||||
<div class="label">
|
||||
注意:
|
||||
</div>
|
||||
<div class="text">
|
||||
{{ activeStep.warn }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
<div class="tips-title">
|
||||
最容易踩坑的 3 件事
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>先看状态再动手:</strong>每次操作前先跑一次
|
||||
<code>git status</code>。
|
||||
</li>
|
||||
<li>
|
||||
<strong>只提交“你想提交的东西”:</strong>用
|
||||
<code>git add path</code> 精准暂存,别习惯性
|
||||
<code>git add .</code>。
|
||||
</li>
|
||||
<li>
|
||||
<strong>撤销要分层:</strong>没进暂存 / 进了暂存 / 已经
|
||||
commit,命令完全不同。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
id: 'daily',
|
||||
title: '日常提交',
|
||||
level: '必会',
|
||||
desc: '在本地改代码并提交;这是你 90% 的 Git 使用场景。',
|
||||
steps: [
|
||||
{
|
||||
title: '看当前状态',
|
||||
cmd: 'git status',
|
||||
output:
|
||||
'On branch main\nChanges not staged for commit:\n modified: src/app.js',
|
||||
why: '先确认“你在哪个分支 + 改了哪些文件”,避免在错误分支提交。'
|
||||
},
|
||||
{
|
||||
title: '暂存你要提交的文件',
|
||||
cmd: 'git add src/app.js',
|
||||
output:
|
||||
'On branch main\nChanges to be committed:\n modified: src/app.js',
|
||||
why: '把“这次提交要包含的改动”放进暂存区,确保提交内容可控。'
|
||||
},
|
||||
{
|
||||
title: '提交并写清楚信息',
|
||||
cmd: 'git commit -m \"fix: handle empty input\"',
|
||||
output:
|
||||
'[main 1a2b3c4] fix: handle empty input\n 1 file changed, 3 insertions(+)',
|
||||
why: 'commit message 要能让未来的你/同事一眼看懂“改了什么 + 为什么”。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'new-project',
|
||||
title: '新项目推远程',
|
||||
level: '常用',
|
||||
desc: '把本地新项目推到 GitHub/GitLab(remote 一般叫 origin)。',
|
||||
note: '前提:你已经在远端创建了空仓库(不要勾选 README/License,以免产生冲突)。',
|
||||
steps: [
|
||||
{
|
||||
title: '初始化仓库',
|
||||
cmd: 'git init',
|
||||
output: 'Initialized empty Git repository in .../.git/',
|
||||
why: '让当前目录变成一个 Git 仓库。'
|
||||
},
|
||||
{
|
||||
title: '第一次提交',
|
||||
cmd: 'git add . && git commit -m \"chore: initial commit\"',
|
||||
output: '[main ...] chore: initial commit',
|
||||
why: '没有提交就无法 push;先把“初始状态”存档。'
|
||||
},
|
||||
{
|
||||
title: '绑定远程地址',
|
||||
cmd: 'git remote add origin <REMOTE_URL>',
|
||||
output: '',
|
||||
why: '告诉 Git 你的云端仓库在哪里(origin 只是一个名字)。'
|
||||
},
|
||||
{
|
||||
title: '推送并建立追踪关系',
|
||||
cmd: 'git push -u origin main',
|
||||
output: 'Branch \"main\" set up to track \"origin/main\".',
|
||||
why: '加 -u 后,以后可以直接用 git push / git pull。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'branch-pr',
|
||||
title: '开分支做功能',
|
||||
level: '必会',
|
||||
desc: '在 feature 分支开发,推送后提 PR;这是团队协作的基本功。',
|
||||
steps: [
|
||||
{
|
||||
title: '更新主分支',
|
||||
cmd: 'git switch main && git pull',
|
||||
output: '',
|
||||
why: '在开新分支前先把 main 更新到最新,减少未来合并冲突。'
|
||||
},
|
||||
{
|
||||
title: '创建并切到 feature 分支',
|
||||
cmd: 'git switch -c feat/login-form',
|
||||
output: "Switched to a new branch 'feat/login-form'",
|
||||
why: '把改动隔离在分支里,主分支保持可随时发布。'
|
||||
},
|
||||
{
|
||||
title: '提交并推送分支',
|
||||
cmd: 'git push -u origin feat/login-form',
|
||||
output: '',
|
||||
why: '推到远端后,才能在 GitHub/GitLab 上发起 PR/MR。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'undo',
|
||||
title: '撤销/回滚',
|
||||
level: '救命',
|
||||
desc: '写错了别慌:先判断“改动在哪一层”。',
|
||||
steps: [
|
||||
{
|
||||
title: '未 add:丢掉工作区改动',
|
||||
cmd: 'git restore <file>',
|
||||
output: '',
|
||||
why: '只撤销工作区的修改,不影响暂存区和提交历史。',
|
||||
warn: '会丢弃未提交的改动;不确定时先备份或用 stash。'
|
||||
},
|
||||
{
|
||||
title: '已 add:撤回暂存',
|
||||
cmd: 'git restore --staged <file>',
|
||||
output: '',
|
||||
why: '把文件从暂存区撤回到工作区,便于重新选择提交内容。'
|
||||
},
|
||||
{
|
||||
title: '已 commit:推荐用 revert',
|
||||
cmd: 'git revert <commit>',
|
||||
output: '',
|
||||
why: 'revert 会生成一个“反向提交”,对协作更安全(不会改写历史)。',
|
||||
warn: '不要在共享分支随意 reset --hard(会让别人同步困难)。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'conflict',
|
||||
title: '解决冲突',
|
||||
level: '常见',
|
||||
desc: '多人改同一段代码时,Git 需要你手动选择。',
|
||||
steps: [
|
||||
{
|
||||
title: '合并/拉取触发冲突',
|
||||
cmd: 'git merge <branch>',
|
||||
output: 'CONFLICT (content): Merge conflict in src/app.js',
|
||||
why: 'Git 无法自动决定保留哪一边的改动。'
|
||||
},
|
||||
{
|
||||
title: '打开冲突文件并解决标记',
|
||||
cmd: 'git status',
|
||||
output:
|
||||
'Unmerged paths:\n both modified: src/app.js\n\nfix conflicts and run \"git commit\"',
|
||||
why: '用 status 定位冲突文件,然后打开文件删掉 <<<<<<</=======/>>>>>>> 标记。'
|
||||
},
|
||||
{
|
||||
title: '标记冲突已解决并提交',
|
||||
cmd: 'git add src/app.js && git commit',
|
||||
output: '',
|
||||
why: 'add 表示“我已解决冲突”;commit 记录一次合并结果。'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const activeScenarioId = ref(scenarios[0].id)
|
||||
const activeStepIndex = ref(0)
|
||||
const copied = ref(false)
|
||||
|
||||
const activeScenario = computed(
|
||||
() => scenarios.find((s) => s.id === activeScenarioId.value) || scenarios[0]
|
||||
)
|
||||
|
||||
const activeStep = computed(
|
||||
() => activeScenario.value.steps[activeStepIndex.value]
|
||||
)
|
||||
|
||||
const resetSteps = () => {
|
||||
activeStepIndex.value = 0
|
||||
}
|
||||
|
||||
const selectScenario = (id) => {
|
||||
activeScenarioId.value = id
|
||||
resetSteps()
|
||||
}
|
||||
|
||||
const nextStep = () => {
|
||||
activeStepIndex.value = Math.min(
|
||||
activeScenario.value.steps.length - 1,
|
||||
activeStepIndex.value + 1
|
||||
)
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
activeStepIndex.value = Math.max(0, activeStepIndex.value - 1)
|
||||
}
|
||||
|
||||
const copy = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 800)
|
||||
} catch {
|
||||
copied.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.git-scenarios-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.title .h {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.title .sub {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-color: rgba(var(--vp-c-brand-rgb), 0.35);
|
||||
box-shadow: 0 0 0 3px rgba(var(--vp-c-brand-rgb), 0.12);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.scenario-meta {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.scenario-note {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.step-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.step-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.step-name {
|
||||
margin-left: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-1);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cmd {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cmd code {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.output {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.output pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.why,
|
||||
.warn {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.why .text {
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.warn {
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(var(--vp-c-brand-rgb), 0.18);
|
||||
background: rgba(var(--vp-c-brand-rgb), 0.06);
|
||||
}
|
||||
|
||||
.warn .text {
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.tips-title {
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tips ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,194 +0,0 @@
|
||||
<template>
|
||||
<div class="stash-demo">
|
||||
<div class="panel">
|
||||
<div class="areas">
|
||||
<div class="area">
|
||||
<div class="header">
|
||||
💻 工作区 ({{ work.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="f in work"
|
||||
:key="f"
|
||||
class="file"
|
||||
>
|
||||
📄 {{ f }}
|
||||
</div>
|
||||
<div
|
||||
v-if="work.length === 0"
|
||||
class="empty"
|
||||
>
|
||||
空
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="area">
|
||||
<div class="header">
|
||||
📚 Stash 栈 ({{ stash.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="(s, i) in stash"
|
||||
:key="i"
|
||||
class="stash-item"
|
||||
>
|
||||
<span class="num">{{ i + 1 }}</span>
|
||||
<span class="msg">{{ s }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stash.length === 0"
|
||||
class="empty"
|
||||
>
|
||||
空
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
:disabled="work.length > 0"
|
||||
class="btn"
|
||||
@click="doWork"
|
||||
>
|
||||
修改
|
||||
</button>
|
||||
<button
|
||||
:disabled="work.length === 0 || stash.length >= 3"
|
||||
class="btn"
|
||||
@click="save"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
:disabled="stash.length === 0"
|
||||
class="btn"
|
||||
@click="pop"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
class="btn secondary"
|
||||
@click="reset"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 Stash 用途:</strong> 临时保存工作现场,切换任务</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const work = ref([])
|
||||
const stash = ref([])
|
||||
const doWork = () => {
|
||||
work.value = ['file.js', 'style.css']
|
||||
}
|
||||
const save = () => {
|
||||
stash.value.push('WIP')
|
||||
work.value = []
|
||||
}
|
||||
const pop = () => {
|
||||
if (stash.value.length) {
|
||||
stash.value.pop()
|
||||
work.value = ['file.js']
|
||||
}
|
||||
}
|
||||
const reset = () => {
|
||||
work.value = []
|
||||
stash.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stash-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.areas {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.area {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
.header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.file,
|
||||
.stash-item {
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.stash-item .num {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.empty {
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.secondary {
|
||||
border-color: var(--vp-c-divider);
|
||||
}
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<div class="storage-demo">
|
||||
<div class="panel">
|
||||
<div class="comparison">
|
||||
<div class="mode-selector">
|
||||
<button
|
||||
:class="{ active: mode === 'full' }"
|
||||
class="mode-btn"
|
||||
@click="mode = 'full'"
|
||||
>
|
||||
完整备份
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'git' }"
|
||||
class="mode-btn"
|
||||
@click="mode = 'git'"
|
||||
>
|
||||
Git 增量
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization">
|
||||
<div class="bar-container">
|
||||
<div
|
||||
class="bar full"
|
||||
:style="{ height: fullSize + '%' }"
|
||||
>
|
||||
<span class="label">完整备份: {{ fullSize }}MB</span>
|
||||
</div>
|
||||
<div
|
||||
class="bar git"
|
||||
:style="{ height: gitSize + '%' }"
|
||||
>
|
||||
<span class="label">Git 存储: {{ gitSize }}MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ savedPercent }}%</span>
|
||||
<span class="label">节省空间</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ versionCount }}</span>
|
||||
<span class="label">版本数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 Git 增量存储:</strong> 只保存变更部分,大幅节省空间</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const mode = ref('git')
|
||||
const versionCount = ref(5)
|
||||
const fullSize = ref(500)
|
||||
const gitSize = ref(50)
|
||||
|
||||
const savedPercent = computed(() =>
|
||||
Math.round((1 - gitSize.value / fullSize.value) * 100)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.storage-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.mode-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: height 0.5s ease;
|
||||
}
|
||||
|
||||
.bar.full {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
}
|
||||
.bar.git {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stat-item .label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div class="gs-root">
|
||||
<!-- Terminal -->
|
||||
<div class="gs-terminal">
|
||||
<div class="term-bar">
|
||||
<span class="dot r" /><span class="dot y" /><span class="dot g" />
|
||||
<span class="term-title">~/project (main)</span>
|
||||
</div>
|
||||
<div ref="termEl" class="term-body">
|
||||
<div v-for="(l, i) in lines" :key="i" class="t-line">
|
||||
<span v-if="l.kind === 'cmd'" class="t-ps">$ </span>
|
||||
<span :class="'t-' + l.kind">{{ l.text }}</span>
|
||||
</div>
|
||||
<div class="t-line">
|
||||
<span class="t-ps">$ </span>
|
||||
<span class="t-typing">{{ typing }}<span class="t-cur">▋</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="gs-btns">
|
||||
<button
|
||||
v-for="op in ops"
|
||||
:key="op.id"
|
||||
:disabled="running || !op.ok()"
|
||||
:class="['gs-btn', { 'gs-btn--on': active === op.id, 'gs-btn--dim': !op.ok() }]"
|
||||
@click="run(op)"
|
||||
>
|
||||
<code>{{ op.cmd }}</code>
|
||||
</button>
|
||||
<button class="gs-btn gs-btn--reset" :disabled="running" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<!-- Dual-repo visual -->
|
||||
<div class="gs-repos">
|
||||
<div class="repo-card" :class="{ 'repo-pulse': pulse === 'local' }">
|
||||
<div class="repo-header">
|
||||
<span class="repo-icon">💻</span>
|
||||
<span class="repo-name">本地仓库</span>
|
||||
<span class="repo-path">~/project</span>
|
||||
</div>
|
||||
<div class="commit-col">
|
||||
<div v-if="!localLog.length" class="no-commits">(空)</div>
|
||||
<div
|
||||
v-for="(c, i) in localLog"
|
||||
:key="i"
|
||||
class="cmt-row"
|
||||
:class="{ 'cmt-new': c.isNew }"
|
||||
>
|
||||
<span class="cmt-dot local-dot" />
|
||||
<code class="cmt-hash">{{ c.hash }}</code>
|
||||
<span class="cmt-msg">{{ c.msg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-footer">
|
||||
<span v-if="localAhead > 0" class="badge-ahead">↑ {{ localAhead }} 个未推送</span>
|
||||
<span v-else-if="localLog.length" class="badge-sync">✓ 已同步</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow column -->
|
||||
<div class="arrow-col">
|
||||
<div class="arrow-row" :class="{ 'arrow-lit': pulse === 'push' }">
|
||||
<span class="arrow-label">push →</span>
|
||||
</div>
|
||||
<div class="arrow-row arrow-pull" :class="{ 'arrow-lit': pulse === 'pull' }">
|
||||
<span class="arrow-label">← pull</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-card repo-remote" :class="{ 'repo-pulse-remote': pulse === 'remote' }">
|
||||
<div class="repo-header">
|
||||
<span class="repo-icon">☁️</span>
|
||||
<span class="repo-name">远程仓库</span>
|
||||
<span class="repo-path">github.com/you/project</span>
|
||||
</div>
|
||||
<div class="commit-col">
|
||||
<div v-if="!remoteLog.length" class="no-commits">(空)</div>
|
||||
<div
|
||||
v-for="(c, i) in remoteLog"
|
||||
:key="i"
|
||||
class="cmt-row"
|
||||
:class="{ 'cmt-new': c.isNew }"
|
||||
>
|
||||
<span class="cmt-dot remote-dot" />
|
||||
<code class="cmt-hash">{{ c.hash }}</code>
|
||||
<span class="cmt-msg">{{ c.msg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-footer">
|
||||
<span v-if="remoteLog.length" class="badge-online">🌐 在线</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hint" class="gs-hint">💡 {{ hint }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
const termEl = ref(null)
|
||||
const lines = ref([{ kind: 'dim', text: '# 本地 2 次提交,还没关联远程仓库' }])
|
||||
const typing = ref('')
|
||||
const running = ref(false)
|
||||
const active = ref(null)
|
||||
const hint = ref('点击下方命令按钮,按顺序执行')
|
||||
const pulse = ref('')
|
||||
|
||||
const localLog = ref([
|
||||
{ hash: '9f3e1b2', msg: 'init: 初始化项目', isNew: false },
|
||||
{ hash: 'c4d8a31', msg: 'feat: 首页布局', isNew: false },
|
||||
])
|
||||
const remoteLog = ref([])
|
||||
const localAhead = ref(2)
|
||||
let s = { linked: false, pushed: false, committed: false, pushed2: false }
|
||||
|
||||
const ops = [
|
||||
{
|
||||
id: 'remote',
|
||||
cmd: 'git remote add origin https://github.com/you/project.git',
|
||||
ok: () => !s.linked,
|
||||
output: [
|
||||
{ kind: 'dim', text: '# 建立本地与远程的关联(只做一次)' },
|
||||
{ kind: 'grn', text: 'origin https://github.com/you/project.git (fetch)' },
|
||||
{ kind: 'grn', text: 'origin https://github.com/you/project.git (push)' },
|
||||
],
|
||||
hint: '"origin" 是远程仓库的别名,相当于给 GitHub 地址起个简短的联系人名字。',
|
||||
do: () => { s.linked = true },
|
||||
p: '',
|
||||
},
|
||||
{
|
||||
id: 'push1',
|
||||
cmd: 'git push -u origin main',
|
||||
ok: () => s.linked && !s.pushed,
|
||||
output: [
|
||||
{ kind: 'dim', text: 'Enumerating objects: 5, done.' },
|
||||
{ kind: 'grn', text: 'To https://github.com/you/project.git' },
|
||||
{ kind: 'grn', text: ' * [new branch] main -> main' },
|
||||
],
|
||||
hint: '第一次 push 加 -u,以后直接 git push 就行。本地提交现在上传到 GitHub 了。',
|
||||
do: () => {
|
||||
s.pushed = true; localAhead.value = 0
|
||||
remoteLog.value = localLog.value.map(c => ({ ...c, isNew: true }))
|
||||
setTimeout(() => remoteLog.value.forEach(c => c.isNew = false), 900)
|
||||
},
|
||||
p: 'push',
|
||||
},
|
||||
{
|
||||
id: 'commit',
|
||||
cmd: 'git commit -m "fix: 修复登录 Bug"',
|
||||
ok: () => s.pushed && !s.committed,
|
||||
output: [
|
||||
{ kind: 'dim', text: '[main b5e6f7a] fix: 修复登录 Bug' },
|
||||
{ kind: 'yel', text: "Your branch is 1 commit ahead of 'origin/main'." },
|
||||
],
|
||||
hint: '本地新增一个 commit,但还没 push。远程还是旧的,本地比它"快了一步"。',
|
||||
do: () => {
|
||||
s.committed = true; localAhead.value = 1
|
||||
localLog.value.unshift({ hash: 'b5e6f7a', msg: 'fix: 修复登录 Bug', isNew: true })
|
||||
setTimeout(() => localLog.value.forEach(c => c.isNew = false), 900)
|
||||
},
|
||||
p: 'local',
|
||||
},
|
||||
{
|
||||
id: 'push2',
|
||||
cmd: 'git push',
|
||||
ok: () => s.committed && !s.pushed2,
|
||||
output: [
|
||||
{ kind: 'grn', text: 'To https://github.com/you/project.git' },
|
||||
{ kind: 'grn', text: ' c4d8a31..b5e6f7a main -> main' },
|
||||
],
|
||||
hint: '第二次 push 不需要 -u,直接推。远程和本地又同步了。',
|
||||
do: () => {
|
||||
s.pushed2 = true; localAhead.value = 0
|
||||
remoteLog.value = localLog.value.map(c => ({ ...c, isNew: true }))
|
||||
setTimeout(() => remoteLog.value.forEach(c => c.isNew = false), 900)
|
||||
},
|
||||
p: 'push',
|
||||
},
|
||||
{
|
||||
id: 'pull',
|
||||
cmd: 'git pull',
|
||||
ok: () => s.pushed,
|
||||
output: [
|
||||
{ kind: 'grn', text: 'From https://github.com/you/project.git' },
|
||||
{ kind: 'grn', text: ' b5e6f7a..d8c9e0f main -> origin/main' },
|
||||
{ kind: 'dim', text: 'Fast-forward: readme.md | 5 +++++ 1 file changed' },
|
||||
],
|
||||
hint: 'pull = fetch + merge。队友推上去的提交,现在也同步到你本地了。',
|
||||
do: () => {
|
||||
const c = { hash: 'd8c9e0f', msg: '队友: 更新 README', isNew: true }
|
||||
remoteLog.value.unshift({ ...c })
|
||||
localLog.value.unshift({ ...c })
|
||||
setTimeout(() => {
|
||||
remoteLog.value.forEach(x => x.isNew = false)
|
||||
localLog.value.forEach(x => x.isNew = false)
|
||||
}, 900)
|
||||
},
|
||||
p: 'pull',
|
||||
},
|
||||
]
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
function scroll() { if (termEl.value) termEl.value.scrollTop = termEl.value.scrollHeight }
|
||||
|
||||
async function run(op) {
|
||||
if (running.value) return
|
||||
running.value = true; active.value = op.id; hint.value = ''; typing.value = ''; pulse.value = ''
|
||||
for (const ch of op.cmd) { typing.value += ch; await sleep(20) }
|
||||
await sleep(80)
|
||||
lines.value.push({ kind: 'cmd', text: op.cmd }); typing.value = ''
|
||||
await nextTick(); scroll(); await sleep(150)
|
||||
for (const l of op.output) { lines.value.push(l); await nextTick(); scroll(); await sleep(50) }
|
||||
op.do()
|
||||
pulse.value = op.p
|
||||
await sleep(100); hint.value = op.hint
|
||||
setTimeout(() => { if (pulse.value === op.p) pulse.value = '' }, 1200)
|
||||
running.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lines.value = [{ kind: 'dim', text: '# 本地 2 次提交,还没关联远程仓库' }]
|
||||
localLog.value = [
|
||||
{ hash: '9f3e1b2', msg: 'init: 初始化项目', isNew: false },
|
||||
{ hash: 'c4d8a31', msg: 'feat: 首页布局', isNew: false },
|
||||
]
|
||||
remoteLog.value = []; localAhead.value = 2
|
||||
s = { linked: false, pushed: false, committed: false, pushed2: false }
|
||||
active.value = null; hint.value = '点击下方命令按钮,按顺序执行'
|
||||
typing.value = ''; running.value = false; pulse.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gs-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
background: var(--vp-c-bg-soft); margin: 1rem 0; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Terminal */
|
||||
.gs-terminal { background: #141420; }
|
||||
.term-bar {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 7px 12px; background: #1e1e2e;
|
||||
}
|
||||
.dot { width: 11px; height: 11px; border-radius: 50%; }
|
||||
.dot.r { background: #ff5f57; } .dot.y { background: #febc2e; } .dot.g { background: #28c840; }
|
||||
.term-title { margin-left: 8px; font-size: 0.72rem; color: #666; font-family: monospace; }
|
||||
.term-body {
|
||||
min-height: 120px; max-height: 180px; overflow-y: auto;
|
||||
padding: 0.7rem 1rem;
|
||||
font-family: 'Menlo','Monaco',monospace; font-size: 0.76rem; line-height: 1.6; color: #cdd6f4;
|
||||
}
|
||||
.t-line { display: flex; }
|
||||
.t-ps { color: #a6e3a1; flex-shrink: 0; }
|
||||
.t-cmd { color: #cdd6f4; } .t-dim { color: #585b70; } .t-grn { color: #a6e3a1; } .t-yel { color: #89b4fa; }
|
||||
.t-typing { color: #cdd6f4; }
|
||||
.t-cur { animation: blink 1s step-end infinite; }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
|
||||
|
||||
/* Buttons */
|
||||
.gs-btns {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 8px 10px; background: #0d0d1a; border-top: 1px solid #2a2a3e;
|
||||
}
|
||||
.gs-btn {
|
||||
background: #1e1e2e; border: 1px solid #313244;
|
||||
border-radius: 5px; padding: 4px 9px; cursor: pointer; transition: border-color .2s;
|
||||
}
|
||||
.gs-btn code { font-size: 0.7rem; color: #7f849c; font-family: monospace; white-space: nowrap; }
|
||||
.gs-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); }
|
||||
.gs-btn--on { border-color: var(--vp-c-brand) !important; }
|
||||
.gs-btn--on code { color: var(--vp-c-brand); }
|
||||
.gs-btn--dim { opacity: 0.3; cursor: not-allowed; }
|
||||
.gs-btn--reset { background: transparent; border-color: #313244; margin-left: auto; }
|
||||
.gs-btn--reset::after { content: '重置'; font-size: 0.7rem; color: #585b70; }
|
||||
|
||||
/* Repos */
|
||||
.gs-repos {
|
||||
display: grid; grid-template-columns: 1fr auto 1fr;
|
||||
gap: 8px; padding: 10px 12px;
|
||||
background: var(--vp-c-bg); border-top: 1px solid var(--vp-c-divider);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.gs-repos { grid-template-columns: 1fr; }
|
||||
.arrow-col { flex-direction: row; justify-content: center; gap: 16px; }
|
||||
}
|
||||
|
||||
.repo-card {
|
||||
border: 1.5px solid var(--vp-c-divider); border-radius: 8px;
|
||||
padding: 8px 10px; background: var(--vp-c-bg-soft);
|
||||
transition: border-color .3s, box-shadow .3s;
|
||||
}
|
||||
.repo-remote { border-color: #60a5fa44; background: color-mix(in srgb, #60a5fa 4%, var(--vp-c-bg-soft)); }
|
||||
.repo-pulse { border-color: var(--vp-c-brand) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand) 12%, transparent); }
|
||||
.repo-pulse-remote { border-color: #60a5fa !important; box-shadow: 0 0 0 3px #60a5fa22; }
|
||||
|
||||
.repo-header {
|
||||
display: flex; align-items: center; gap: 5px; margin-bottom: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.repo-icon { font-size: 1rem; }
|
||||
.repo-name { font-weight: 700; font-size: 0.8rem; }
|
||||
.repo-path { font-family: monospace; font-size: 0.62rem; color: var(--vp-c-text-3); margin-left: auto; }
|
||||
|
||||
.commit-col { min-height: 48px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.no-commits { color: var(--vp-c-text-3); font-size: 0.72rem; }
|
||||
.cmt-row {
|
||||
display: flex; align-items: center; gap: 5px; font-size: 0.72rem;
|
||||
padding: 2px 4px; border-radius: 3px; transition: background .3s;
|
||||
}
|
||||
.cmt-new { background: color-mix(in srgb, var(--vp-c-brand) 10%, transparent); }
|
||||
.cmt-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.local-dot { background: var(--vp-c-brand); }
|
||||
.remote-dot { background: #60a5fa; }
|
||||
.cmt-hash { color: var(--vp-c-brand); font-size: 0.68rem; }
|
||||
.cmt-msg { color: var(--vp-c-text-2); }
|
||||
|
||||
.repo-footer { margin-top: 5px; font-size: 0.7rem; min-height: 16px; }
|
||||
.badge-ahead { color: var(--vp-c-brand); font-weight: 600; }
|
||||
.badge-sync { color: #a6e3a1; }
|
||||
.badge-online { color: #60a5fa; }
|
||||
|
||||
/* Arrows */
|
||||
.arrow-col {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 12px; padding-top: 32px;
|
||||
}
|
||||
.arrow-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
opacity: 0.25; transition: opacity .3s;
|
||||
}
|
||||
.arrow-row.arrow-lit { opacity: 1; }
|
||||
.arrow-label {
|
||||
font-size: 0.66rem; font-family: monospace;
|
||||
color: var(--vp-c-brand); white-space: nowrap;
|
||||
}
|
||||
.arrow-pull .arrow-label { color: #60a5fa; }
|
||||
|
||||
.gs-hint {
|
||||
padding: 8px 12px; background: var(--vp-c-bg-alt);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.8rem; color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,743 +0,0 @@
|
||||
<template>
|
||||
<div class="three-areas-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📂</span>
|
||||
<span class="title">Git 三区概念</span>
|
||||
<span class="subtitle">工作区 → 暂存区 → 仓库</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<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="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>
|
||||
</transition-group>
|
||||
<div
|
||||
v-if="workingFiles.length === 0"
|
||||
class="empty-state"
|
||||
>
|
||||
桌上很干净 ✨
|
||||
<button
|
||||
class="create-btn"
|
||||
@click="createNewFile"
|
||||
>
|
||||
新建文件 📝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flow-arrow">
|
||||
<div class="arrow-line" />
|
||||
<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 class="box-flap right" />
|
||||
</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 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 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>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="block">
|
||||
<div class="block-title">
|
||||
当前等价命令
|
||||
</div>
|
||||
<pre class="mono"><code>{{ historyText }}</code></pre>
|
||||
</div>
|
||||
<div class="block">
|
||||
<div class="block-title">
|
||||
git status(模拟)
|
||||
</div>
|
||||
<pre class="mono"><code>{{ statusText }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>Git 的三区就像餐厅——工作区是餐桌(随便放),暂存区是备菜盘(准备上菜),仓库是菜单(永久记录)。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const fileIdCounter = ref(1)
|
||||
|
||||
const createId = () => `file-${fileIdCounter.value++}`
|
||||
|
||||
const workingFiles = ref([
|
||||
{ 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 history = ref(['$ git status'])
|
||||
|
||||
const pushHistory = (line) => {
|
||||
history.value.push(line)
|
||||
if (history.value.length > 6)
|
||||
history.value.splice(0, history.value.length - 6)
|
||||
}
|
||||
|
||||
const historyText = computed(() => history.value.join('\n'))
|
||||
|
||||
const statusText = computed(() => {
|
||||
const lines = ['On branch main']
|
||||
if (stagedFiles.value.length === 0 && workingFiles.value.length === 0) {
|
||||
lines.push('nothing to commit, working tree clean')
|
||||
return lines.join('\n')
|
||||
}
|
||||
if (stagedFiles.value.length) {
|
||||
lines.push('Changes to be committed:')
|
||||
stagedFiles.value.forEach((f) => lines.push(` new file: ${f.name}`))
|
||||
}
|
||||
if (workingFiles.value.length) {
|
||||
lines.push('Untracked files:')
|
||||
workingFiles.value.forEach((f) => lines.push(` ${f.name}`))
|
||||
}
|
||||
return lines.join('\n')
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
pushHistory(`$ touch ${randomType.name}`)
|
||||
}
|
||||
|
||||
const addToStaging = (file) => {
|
||||
const index = workingFiles.value.findIndex((f) => f.id === file.id)
|
||||
if (index !== -1) {
|
||||
workingFiles.value.splice(index, 1)
|
||||
stagedFiles.value.push(file)
|
||||
pushHistory(`$ git add ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const unstageFile = (file) => {
|
||||
const index = stagedFiles.value.findIndex((f) => f.id === file.id)
|
||||
if (index !== -1) {
|
||||
stagedFiles.value.splice(index, 1)
|
||||
workingFiles.value.push(file)
|
||||
pushHistory(`$ git restore --staged ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
pushHistory(`$ git commit -m "${randomMsg}"`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.three-areas-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
|
||||
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.demo-header .icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.scene {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
/* Common Zone Styles */
|
||||
.zone {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.zone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 1. Working Desk */
|
||||
.zone.working {
|
||||
border-color: rgba(var(--vp-c-brand-rgb), 0.25);
|
||||
}
|
||||
.desk-surface {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
background-size: 10px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.file-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
width: 80px;
|
||||
height: 90px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.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(var(--vp-c-brand-rgb), 0.9);
|
||||
color: var(--vp-c-bg);
|
||||
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: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 2. Staging Box */
|
||||
.zone.staging {
|
||||
border-color: rgba(var(--vp-c-brand-rgb), 0.25);
|
||||
}
|
||||
.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: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
|
||||
}
|
||||
|
||||
.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: var(--vp-c-brand);
|
||||
}
|
||||
.file-card.mini .action-hint {
|
||||
background: rgba(var(--vp-c-brand-rgb), 0.9);
|
||||
}
|
||||
|
||||
.box-flap {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
width: 45%;
|
||||
height: 20px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
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: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg);
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.commit-btn:disabled {
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.commit-btn:hover:not(:disabled) {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* 3. Repo Cabinet */
|
||||
.zone.repo {
|
||||
border-color: rgba(var(--vp-c-brand-rgb), 0.25);
|
||||
}
|
||||
.cabinet-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.drawer-handle {
|
||||
width: 30px;
|
||||
height: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.commit-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.commit-hash {
|
||||
font-size: 0.6rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
.commit-msg {
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.commit-files {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.tiny-file {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mono {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
color: var(--vp-c-text-1);
|
||||
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) {
|
||||
.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;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,486 +0,0 @@
|
||||
<!--
|
||||
GitWorkflowDemo.vue
|
||||
Git 基础工作流演示 - 寄快递版
|
||||
|
||||
展示 Git 的基本工作流程:修改 → 暂存 → 提交
|
||||
高度控制:紧凑布局,确保在 600px 内
|
||||
-->
|
||||
<template>
|
||||
<div class="git-workflow-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📦</span>
|
||||
<span class="title">Git 工作流</span>
|
||||
<span class="subtitle">修改 → 暂存 → 提交,三步走</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<!-- 文件状态区域 -->
|
||||
<div class="file-area">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">📝</span>
|
||||
<span class="area-name">工作区</span>
|
||||
<span class="area-desc">你正在改的文件</span>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.name"
|
||||
class="file-item"
|
||||
:class="{
|
||||
'modified': file.status === 'modified',
|
||||
'staged': file.status === 'staged',
|
||||
'committed': file.status === 'committed'
|
||||
}"
|
||||
>
|
||||
<span class="file-icon">{{ getIcon(file.status) }}</span>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<span class="file-status">{{ getStatusText(file.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 箭头 -->
|
||||
<div
|
||||
v-if="!allCommitted"
|
||||
class="arrow-group"
|
||||
>
|
||||
<div
|
||||
class="arrow"
|
||||
:class="{ active: hasStaged }"
|
||||
>
|
||||
↓
|
||||
</div>
|
||||
<div
|
||||
v-if="hasStaged"
|
||||
class="arrow-label"
|
||||
>
|
||||
git add
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 暂存区 -->
|
||||
<div class="stage-area">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">📋</span>
|
||||
<span class="area-name">暂存区</span>
|
||||
<span class="area-desc">准备打包的文件</span>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div
|
||||
v-for="file in stagedFiles"
|
||||
:key="file.name"
|
||||
class="file-item staged"
|
||||
>
|
||||
<span class="file-icon">📌</span>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<span class="file-status">待提交</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stagedFiles.length === 0"
|
||||
class="empty-tip"
|
||||
>
|
||||
暂无文件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 箭头 -->
|
||||
<div
|
||||
v-if="hasStaged"
|
||||
class="arrow-group"
|
||||
>
|
||||
<div class="arrow active">
|
||||
↓
|
||||
</div>
|
||||
<div class="arrow-label">
|
||||
git commit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 仓库区 -->
|
||||
<div class="repo-area">
|
||||
<div class="area-header">
|
||||
<span class="area-icon">🏪</span>
|
||||
<span class="area-name">仓库</span>
|
||||
<span class="area-desc">已保存的版本</span>
|
||||
</div>
|
||||
<div class="commit-list">
|
||||
<div
|
||||
v-for="(commit, i) in commits"
|
||||
:key="i"
|
||||
class="commit-item"
|
||||
>
|
||||
<span class="commit-icon">✅</span>
|
||||
<span class="commit-msg">{{ commit.msg }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="commits.length === 0"
|
||||
class="empty-tip"
|
||||
>
|
||||
暂无提交
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-panel">
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="allModified"
|
||||
@click="modifyFile"
|
||||
>
|
||||
✏️ 修改文件
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="!hasModified || allStaged"
|
||||
@click="stageFiles"
|
||||
>
|
||||
📌 暂存修改
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="!hasStaged"
|
||||
@click="commitFiles"
|
||||
>
|
||||
✅ 提交版本
|
||||
</button>
|
||||
<button
|
||||
class="action-btn secondary"
|
||||
@click="reset"
|
||||
>
|
||||
🔄 重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>工作区修改 → 暂存区挑选 → 仓库永久保存
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const files = ref([
|
||||
{ name: 'index.html', status: 'unmodified' },
|
||||
{ name: 'app.js', status: 'unmodified' },
|
||||
{ name: 'style.css', status: 'unmodified' }
|
||||
])
|
||||
|
||||
const commits = ref([])
|
||||
|
||||
const hasModified = computed(() =>
|
||||
files.value.some(f => f.status === 'modified')
|
||||
)
|
||||
|
||||
const hasStaged = computed(() =>
|
||||
files.value.some(f => f.status === 'staged')
|
||||
)
|
||||
|
||||
const allCommitted = computed(() =>
|
||||
files.value.every(f => f.status === 'committed')
|
||||
)
|
||||
|
||||
const allModified = computed(() =>
|
||||
files.value.every(f => f.status === 'modified')
|
||||
)
|
||||
|
||||
const allStaged = computed(() =>
|
||||
files.value.every(f => f.status === 'staged' || f.status === 'committed')
|
||||
)
|
||||
|
||||
const stagedFiles = computed(() =>
|
||||
files.value.filter(f => f.status === 'staged')
|
||||
)
|
||||
|
||||
const getIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'modified': return '📝'
|
||||
case 'staged': return '📌'
|
||||
case 'committed': return '✅'
|
||||
default: return '📄'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'modified': return '已修改'
|
||||
case 'staged': return '已暂存'
|
||||
case 'committed': return '已提交'
|
||||
default: return '未修改'
|
||||
}
|
||||
}
|
||||
|
||||
const modifyFile = () => {
|
||||
const unmodified = files.value.filter(f => f.status === 'unmodified' || f.status === 'committed')
|
||||
if (unmodified.length > 0) {
|
||||
const file = unmodified[0]
|
||||
file.status = 'modified'
|
||||
}
|
||||
}
|
||||
|
||||
const stageFiles = () => {
|
||||
files.value.forEach(f => {
|
||||
if (f.status === 'modified') {
|
||||
f.status = 'staged'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const commitFiles = () => {
|
||||
const staged = files.value.filter(f => f.status === 'staged')
|
||||
if (staged.length > 0) {
|
||||
files.value.forEach(f => {
|
||||
if (f.status === 'staged') {
|
||||
f.status = 'committed'
|
||||
}
|
||||
})
|
||||
commits.value.push({
|
||||
msg: `提交了 ${staged.length} 个文件`,
|
||||
files: staged.map(f => f.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
files.value.forEach(f => {
|
||||
f.status = 'unmodified'
|
||||
})
|
||||
commits.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.git-workflow-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
max-height: 550px;
|
||||
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.demo-header .icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.area-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.area-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.area-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.area-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.file-area,
|
||||
.stage-area,
|
||||
.repo-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.file-list,
|
||||
.commit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.file-item,
|
||||
.commit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.file-item.modified {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-left: 3px solid var(--vp-c-warning);
|
||||
}
|
||||
|
||||
.file-item.staged {
|
||||
background: var(--vp-c-brand-soft);
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.file-item.committed {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-left: 3px solid var(--vp-c-success);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.file-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.commit-item {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.commit-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.commit-msg {
|
||||
flex: 1;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.arrow-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.arrow.active {
|
||||
color: var(--vp-c-brand);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.arrow-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.action-panel {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -92,15 +92,11 @@ import EmotionControlDemo from './components/appendix/audio-intro/EmotionControl
|
||||
import WebTechTriad from './components/appendix/web-basics/WebTechTriad.vue'
|
||||
import UrlToBrowserDemo from './components/appendix/web-basics/UrlToBrowserDemo.vue'
|
||||
// Git Intro Components
|
||||
import GitWorkflowDemo from './components/appendix/git-intro/GitWorkflowDemo.vue'
|
||||
import GitThreeAreasDemo from './components/appendix/git-intro/GitThreeAreasDemo.vue'
|
||||
import GitStorageDemo from './components/appendix/git-intro/GitStorageDemo.vue'
|
||||
import GitCommandDemo from './components/appendix/git-intro/GitCommandDemo.vue'
|
||||
import GitBranchMergeDemo from './components/appendix/git-intro/GitBranchMergeDemo.vue'
|
||||
import GitConflictDemo from './components/appendix/git-intro/GitConflictDemo.vue'
|
||||
import GitStashDemo from './components/appendix/git-intro/GitStashDemo.vue'
|
||||
import GitRemoteDemo from './components/appendix/git-intro/GitRemoteDemo.vue'
|
||||
import GitScenariosDemo from './components/appendix/git-intro/GitScenariosDemo.vue'
|
||||
import GitCommitFlow from './components/appendix/git-intro/GitCommitFlow.vue'
|
||||
import GitBranchVisual from './components/appendix/git-intro/GitBranchVisual.vue'
|
||||
import GitSyncDemo from './components/appendix/git-intro/GitSyncDemo.vue'
|
||||
import GitCommandCheatsheet from './components/appendix/git-intro/GitCommandCheatsheet.vue'
|
||||
|
||||
// (保留网络相关,未修改)
|
||||
import NetworkLayers from './components/appendix/web-basics/NetworkLayers.vue'
|
||||
import TcpUdpComparison from './components/appendix/web-basics/TcpUdpComparison.vue'
|
||||
@@ -162,6 +158,15 @@ import GraphStructureDemo from './components/appendix/computer-fundamentals/Grap
|
||||
import LanguageTypeModelDemo from './components/appendix/computer-fundamentals/LanguageTypeModelDemo.vue'
|
||||
import CompilationPracticeDemo from './components/appendix/computer-fundamentals/CompilationPracticeDemo.vue'
|
||||
|
||||
// Data Encoding Components
|
||||
import GarbledTextDemo from './components/appendix/data-encoding/GarbledTextDemo.vue'
|
||||
import CharacterEncodingExplorer from './components/appendix/data-encoding/CharacterEncodingExplorer.vue'
|
||||
import StoragePyramidDemo from './components/appendix/data-encoding/StoragePyramidDemo.vue'
|
||||
import DataTransmissionDemo from './components/appendix/data-encoding/DataTransmissionDemo.vue'
|
||||
import PhotoUploadJourneyDemo from './components/appendix/data-encoding/PhotoUploadJourneyDemo.vue'
|
||||
import ImageEncodingDemo from './components/appendix/data-encoding/ImageEncodingDemo.vue'
|
||||
import AudioEncodingDemo from './components/appendix/data-encoding/AudioEncodingDemo.vue'
|
||||
|
||||
// Deployment appendix components
|
||||
import DeploymentOverviewDemo from './components/appendix/deployment/DeploymentOverviewDemo.vue'
|
||||
import DeploymentBuildDemo from './components/appendix/deployment/DeploymentBuildDemo.vue'
|
||||
@@ -686,15 +691,12 @@ export default {
|
||||
app.component('WebTechTriad', WebTechTriad)
|
||||
app.component('UrlToBrowserDemo', UrlToBrowserDemo)
|
||||
app.component('UrlToBrowserQuickStart', UrlToBrowserQuickStart)
|
||||
app.component('GitWorkflowDemo', GitWorkflowDemo)
|
||||
app.component('GitThreeAreasDemo', GitThreeAreasDemo)
|
||||
app.component('GitStorageDemo', GitStorageDemo)
|
||||
app.component('GitCommandDemo', GitCommandDemo)
|
||||
app.component('GitBranchMergeDemo', GitBranchMergeDemo)
|
||||
app.component('GitConflictDemo', GitConflictDemo)
|
||||
app.component('GitStashDemo', GitStashDemo)
|
||||
app.component('GitRemoteDemo', GitRemoteDemo)
|
||||
app.component('GitScenariosDemo', GitScenariosDemo)
|
||||
|
||||
app.component('GitCommitFlow', GitCommitFlow)
|
||||
app.component('GitBranchVisual', GitBranchVisual)
|
||||
app.component('GitSyncDemo', GitSyncDemo)
|
||||
app.component('GitCommandCheatsheet', GitCommandCheatsheet)
|
||||
|
||||
app.component('NetworkLayers', NetworkLayers)
|
||||
app.component('TcpUdpComparison', TcpUdpComparison)
|
||||
app.component('SubnetCalculator', SubnetCalculator)
|
||||
@@ -761,6 +763,15 @@ export default {
|
||||
app.component('LanguageTypeModelDemo', LanguageTypeModelDemo)
|
||||
app.component('CompilationPracticeDemo', CompilationPracticeDemo)
|
||||
|
||||
// Data Encoding Components Registration
|
||||
app.component('GarbledTextDemo', GarbledTextDemo)
|
||||
app.component('CharacterEncodingExplorer', CharacterEncodingExplorer)
|
||||
app.component('StoragePyramidDemo', StoragePyramidDemo)
|
||||
app.component('DataTransmissionDemo', DataTransmissionDemo)
|
||||
app.component('PhotoUploadJourneyDemo', PhotoUploadJourneyDemo)
|
||||
app.component('ImageEncodingDemo', ImageEncodingDemo)
|
||||
app.component('AudioEncodingDemo', AudioEncodingDemo)
|
||||
|
||||
// Deployment appendix
|
||||
app.component('DeploymentOverviewDemo', DeploymentOverviewDemo)
|
||||
app.component('DeploymentBuildDemo', DeploymentBuildDemo)
|
||||
|
||||
@@ -1,244 +1,237 @@
|
||||
# 数据的编码、存储与传输
|
||||
# 什么是数据的编码与传输?
|
||||
|
||||
::: tip 🎯 核心问题
|
||||
**计算机如何表示和存储各种数据?** 文字、图片、视频、声音...这些在现实世界中形态各异的信息,是如何变成 0 和 1 的?又是如何存储和传输的?本章带你理解数据的编码、存储和传输原理。
|
||||
:::
|
||||
> 💡 **学习指南**:当你给朋友发一张照片、发一条微信,或者下载一个几 GB 的游戏时,这些信息是怎么穿过大半个地球、完好无损地出现在你的屏幕上的?
|
||||
>
|
||||
> 本章节会围绕一个经常困扰新手的问题展开:**为什么我收到的文件变成了乱码?**
|
||||
>
|
||||
> 顺着这个问题,我们将彻底揭开计算机底层最核心的三大基石:**编码、存储与传输**。
|
||||
|
||||
在开始之前,我们需要先明确一个经常被新手忽略的物理事实:
|
||||
|
||||
计算机其实极其“死板”。它不认识汉字,不认得色彩,也听不懂周杰伦的歌。
|
||||
|
||||
它的底层全是由无数个微小的半导体开关组成的,**它只能一次又一次地判断“通电(1)”或“断电(0)”**。
|
||||
|
||||
既然计算机只认识 0 和 1,那我们怎么让它显示五颜六色的图片和复杂的文字呢?
|
||||
|
||||
答案就是:**规定一本“密码本”**。
|
||||
|
||||
我们和计算机约定好:如果底层发来 `01000001` 这串微小的电信号,它在屏幕上就专门画出英文字母 `A`;如果发来另外一串信号,就专门显示红色。
|
||||
|
||||
这个**制定并使用密码本进行来回翻译的过程,就叫做“编码(Encoding)”**。
|
||||
|
||||
明白了“计算机里的一切本质上都是密码”这个逻辑起点,你就能瞬间明白日常最容易碰到的一个见鬼现象——乱码,到底是怎么产生的了。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:数据的生命周期
|
||||
## 0. 引言:为什么文件会变成“天书”?
|
||||
|
||||
想象你要寄一封信给朋友:
|
||||
想象一下,你收到一份重要的同事发来的文件,双击打开一看,里面全是类似“浣犲ソ”或“ä½ å¥½”这种奇怪的文字。
|
||||
|
||||
1. **编码**:把想法变成文字(信息编码)
|
||||
2. **存储**:写在纸上(数据存储)
|
||||
3. **传输**:通过邮局寄出(数据传输)
|
||||
直觉上,你肯定觉得:是不是文件在发送的过程中损坏了?是不是丢包了?
|
||||
|
||||
计算机处理数据也是类似的过程:
|
||||
但实际上,绝大多数所谓的“文件损坏”,真相只有一个——**你的电脑“没找对阅读规则”**。
|
||||
|
||||
| 阶段 | 做什么 | 核心问题 | 类比 |
|
||||
|------|--------|---------|------|
|
||||
| **编码** | 把信息变成 0 和 1 | 如何用二进制表示各种数据? | 把想法变成文字 |
|
||||
| **存储** | 把数据保存起来 | 数据存在哪里?怎么组织? | 写在纸上 |
|
||||
| **传输** | 把数据送到别处 | 如何可靠、高效地传输? | 邮局寄信 |
|
||||
👇 **动手点点看**:
|
||||
|
||||
::: tip 📊 逐行解读这张表
|
||||
**编码**:计算机只认识 0 和 1,所以所有数据都要"翻译"成二进制。文字用 ASCII 或 Unicode 编码,数字用二进制表示,图片用像素值,声音用采样值。
|
||||
试着在下方的模拟器里,切换不同的“解码密码本”,来读取同一串底层的电信号字节。
|
||||
|
||||
**存储**:编码后的数据需要保存起来。存储介质从快到慢有:寄存器 → 缓存 → 内存 → SSD → 硬盘 → 云存储。越快的存储越贵、容量越小。
|
||||
<GarbledTextDemo />
|
||||
|
||||
**传输**:数据需要在不同设备间流动。传输方式有串行(一位一位传)和并行(多位同时传)。现代高速接口(USB、PCIe)多采用串行方式。
|
||||
:::
|
||||
**🎯 核心领悟:没对齐的密码本**
|
||||
|
||||
字节(0和1序列)本身是没有绝对意义的,是人类制定的**「编码规则」**赋予了它们意义。
|
||||
|
||||
这就像是一串摩斯密码“滴滴答”,如果你用中文电报密码本去查,它是一个字;如果用美军密码本去查,它是另一个字。
|
||||
|
||||
**发件人用 UTF-8 密码本把汉字翻译成了数字发给你,你如果硬要用 GBK 密码本去解读这些数字,拼出来的当然全是乱码。**
|
||||
|
||||
要彻底搞懂为什么没损坏的数据会变乱码,我们需要了解数据处理的完整链条。即数据的“一生”:**编码**、**存储**、**传输**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据编码:用 0 和 1 表示一切
|
||||
## 1. 什么是数据编码?(把万物变成数字)
|
||||
|
||||
### 1.1 文本编码
|
||||
简单来说:
|
||||
|
||||
<EncodingDemo />
|
||||
> **数据编码(Encoding)**,就是建立一本“双向翻译词典”,把现实世界中复杂多样的信息(文字、色彩、声音),强制映射成计算机能理解的 0 和 1 的规则。
|
||||
|
||||
::: tip 💡 字符编码的演变
|
||||
**ASCII(1963年)**:
|
||||
- 用 7 位二进制表示 128 个字符
|
||||
- 包括英文字母、数字、常用符号
|
||||
- 问题:只能表示英语,无法表示中文等
|
||||
### 1.1 把文字变成数字:从 ASCII 到万国码
|
||||
|
||||
**Unicode(1991年)**:
|
||||
- 统一编码标准,覆盖世界上所有文字
|
||||
- 目前已收录超过 14 万个字符
|
||||
- 常用编码方式:UTF-8(变长编码,1-4 字节)
|
||||
我们每天在微信里打字,每按下一个键,计算机其实暗中都在做一件事:**查表替换**。
|
||||
|
||||
**UTF-8 的巧妙设计**:
|
||||
- ASCII 字符(0-127)只用 1 字节,完全兼容
|
||||
- 常用汉字用 3 字节
|
||||
- 根据"前导位"判断一个字符占几个字节
|
||||
:::
|
||||
**第一阶段:ASCII 的小天地**
|
||||
|
||||
**常见字符编码对比:**
|
||||
发明电脑初期,美国人觉得世界上只有 26 个英文字母、数字和一些标点符号,于是制定了一本很薄的密码本叫做 **ASCII 码**。
|
||||
|
||||
| 编码 | 字节数 | 支持字符 | 特点 |
|
||||
|------|--------|---------|------|
|
||||
| **ASCII** | 1 字节 | 128 个 | 仅英语,兼容性好 |
|
||||
| **UTF-8** | 1-4 字节 | 所有文字 | 变长编码,主流标准 |
|
||||
| **UTF-16** | 2-4 字节 | 所有文字 | 定长为主,Windows 常用 |
|
||||
| **GBK** | 1-2 字节 | 中英文 | 中文专用,不推荐新项目使用 |
|
||||
它只规定了 128 个符号,比如规定数字 `65` 代表大写字母 `A`。由于字符很少,**1 个字节(Byte,等于 8 个比特位 Bit)** 的空间能容纳 256 种变化,绰绰有余。
|
||||
|
||||
### 1.2 数字编码
|
||||
**第二阶段:群雄割据的战国时代**
|
||||
|
||||
::: tip 💡 整数如何用二进制表示?
|
||||
**无符号整数**:直接用二进制表示
|
||||
- 8 位可以表示 0-255
|
||||
- 32 位可以表示 0 到约 42 亿
|
||||
但后来,电脑走向了世界。大家发现:**汉字有几万个,日本还有假名,光靠 1 个字节根本装不下!**
|
||||
|
||||
**有符号整数**:用补码表示
|
||||
- 最高位是符号位(0 正 1 负)
|
||||
- 正数:直接用二进制
|
||||
- 负数:正数的二进制取反加 1
|
||||
于是,中国搞了 GBK 密码本(用 2 个字节存一个汉字),日本搞了 Shift_JIS……世界陷入了混乱。你在中国做好的网页,发给美国客户,他们电脑里没有 GBK 词典,打开全是一堆乱码。
|
||||
|
||||
**为什么用补码?**
|
||||
- 加法减法统一处理
|
||||
- 0 的表示唯一
|
||||
- 硬件实现简单
|
||||
:::
|
||||
**第三阶段:天下一统的 Unicode(万国码)**
|
||||
|
||||
**浮点数表示(IEEE 754 标准):**
|
||||
最后,计算机界的大神们坐在一起商量:“大家别各玩各的了,我们做一本收录地球上所有符号的超级大字典吧!”这就是大名鼎鼎的 **Unicode(万国码)**。它给世界上每一个文字、甚至你常用的每个 Emoji 表情都分配了一个独一无二的编号。
|
||||
|
||||
| 部分 | 作用 | 位数(32位浮点) |
|
||||
|------|------|-----------------|
|
||||
| **符号位** | 正负 | 1 位 |
|
||||
| **指数位** | 决定大小范围 | 8 位 |
|
||||
| **尾数位** | 决定精度 | 23 位 |
|
||||
而你经常听到的 **UTF-8**,就是 Unicode 字典目前最流行的一套“存储规则”。它最聪明的点在于它是**变长**的:遇到英文只用 1 个字节,遇到中文用 3 个字节,非常节省空间。
|
||||
|
||||
### 1.3 多媒体编码
|
||||
👇 **动手点点看**:
|
||||
|
||||
**图像编码**:
|
||||
- **位图**:每个像素用 RGB 值表示(红绿蓝各 8 位)
|
||||
- **压缩**:JPEG(有损)、PNG(无损)
|
||||
- **矢量图**:用数学公式描述形状(SVG)
|
||||
在下面的输入框里随便打几个中英文或 Emoji(比如:`你好 Hello 🎉`),看看计算机底层是怎么“查表”占用空间的。
|
||||
|
||||
**音频编码**:
|
||||
- **采样**:把连续声波变成离散点
|
||||
- **量化**:把采样值变成数字
|
||||
- **压缩**:MP3(有损)、FLAC(无损)
|
||||
<CharacterEncodingExplorer />
|
||||
|
||||
**视频编码**:
|
||||
- 视频是一帧帧图像
|
||||
- 关键技术:帧间压缩(只记录变化部分)
|
||||
- 常见格式:H.264、H.265、VP9
|
||||
**💡 惊奇发现**:
|
||||
|
||||
- 一个英文字母在 UTF-8 里只占 **1 个字节**。
|
||||
- 一个普通汉字通常占 **3 个字节**。
|
||||
- 一个 Emoji 表情(🎉),竟然需要 **4 个字节**!
|
||||
|
||||
> **冷知识**:为什么很多人觉得发同样长度的短信,纯英文能发好长一段,纯中文只能发几句?因为在底层的电信号序列里,中文的物理尺寸足足是英文的 3 倍大!
|
||||
|
||||
### 1.2 颜色和声音怎么变数字?
|
||||
|
||||
文字可以查表,那蒙娜丽莎的微笑、周杰伦的歌声怎么变成 0 和 1 呢?
|
||||
|
||||
方法同样是:**切割与映射**。
|
||||
|
||||
* **图片的编码**:
|
||||
把一张照片无限放大,它其实是由几百万个发光的小方块(像素)组成的。我们只要规定每个颜色的编号(比如 `#FF0000` 代表红色),然后把几百万个方块的编号存下来,照片就变成了数字。
|
||||
|
||||
👇 **动手点点看**:悬停在左侧画布的小格子上,看看图像颜色是怎么映射成十六进制代码的。
|
||||
<ImageEncodingDemo />
|
||||
|
||||
* **声音的编码**:
|
||||
声音本质是空气的震荡波。如果我们每秒去测量这个波浪的高度 44100 次(采样),记录下代表高度的数值。连续存下来,连通的声波就变成了离散的数字数组。
|
||||
|
||||
👇 **动手点点看**:拖动滑块,看看连续的模拟声波是怎么被“切片”成数字音频的。
|
||||
<AudioEncodingDemo />
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据存储:速度与容量的权衡
|
||||
## 2. 存储桥梁:发出去之前,总得先放个地方
|
||||
|
||||
### 2.1 存储层次结构
|
||||
数据编完码之后,准备发给别人。但在这之前,必须要先把它放在电脑的物理介质里。这就涉及到一个不可避免的硬件铁律。
|
||||
|
||||
<StorageDemo />
|
||||
你可能会想:**“既然都要存,全存在读写最快的地方不就好了吗?”**
|
||||
|
||||
### 2.2 存储器类型
|
||||
然而在硬件世界里,永远有个鱼和熊掌不可兼得的魔咒:**速度越快的存储介质,通常造价越贵,能做出的容量也越小。**
|
||||
|
||||
| 类型 | 原理 | 特点 | 应用 |
|
||||
|------|------|------|------|
|
||||
| **SRAM** | 触发器 | 极快,但昂贵 | CPU 缓存 |
|
||||
| **DRAM** | 电容充放电 | 较快,需刷新 | 内存 |
|
||||
| **Flash** | 浮栅晶体管 | 断电不丢失,有写入寿命 | SSD、U 盘 |
|
||||
| **HDD** | 磁盘磁性记录 | 容量大,有机械延迟 | 机械硬盘 |
|
||||
为了用尽可能少的钱换取尽可能快的电脑运行速度,计算机科学家不得已设计了**「存储层次结构」**(也就是存储金字塔)。
|
||||
|
||||
### 2.3 存储的关键指标
|
||||
👇 **动手点点看**:
|
||||
|
||||
::: tip 💡 如何评估存储性能?
|
||||
**访问时间**:从发出请求到获得数据的时间
|
||||
- 内存:约 100 纳秒
|
||||
- SSD:约 100 微秒
|
||||
- HDD:约 10 毫秒
|
||||
点击金字塔的不同层级,看看现代计算机是怎么精打细算的。
|
||||
|
||||
**吞吐量**:单位时间能传输的数据量
|
||||
- 内存:几十 GB/s
|
||||
- SSD:几 GB/s
|
||||
- HDD:100-200 MB/s
|
||||
<StoragePyramidDemo />
|
||||
|
||||
**IOPS**:每秒能进行的读写操作次数
|
||||
- SSD:几万到几十万
|
||||
- HDD:几百
|
||||
:::
|
||||
**🎯 核心领悟:操作系统的搬运工哲学**
|
||||
|
||||
世界上没有完美的存储器。因此,操作系统(如 Windows, macOS)就像一个极度聪明、一刻不停的仓库管理员:
|
||||
|
||||
1. 它把海量的电影、游戏塞在速度慢、容量大(便宜)的仓库——**SSD 或机械硬盘**里。
|
||||
2. 当你要玩游戏时,它赶紧把相关的高清贴图文件,从硬盘搬运到速度极快但容量有限的操作台——**内存(RAM)**上。
|
||||
3. 当你关闭游戏时,它再把内存清空,腾出操作台给别的文件用。
|
||||
|
||||
> **解惑**:当你玩大型开放世界游戏时,遇到场景切换要黑屏很久(读条),本质上就是因为硬盘仓库太慢,搬运工(系统)正在玩命地把下一张地图的数据搬到内存操作台上呢。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据传输:从串行到并行
|
||||
## 3. 什么是数据传输?(让 0 和 1 出发旅行)
|
||||
|
||||
### 3.1 传输方式
|
||||
数据编完码、存在了内存里,接下来就是发给朋友了。
|
||||
|
||||
<TransmissionDemo />
|
||||
> **数据传输**,就是把代表 0 和 1 的电信号(或光信号),顺着网线、电缆或无线电波,准确无误地从一台机器送到另一台机器的过程。
|
||||
|
||||
### 3.2 常见接口标准
|
||||
### 3.1 硬件与局域网传输:一条导线的物理极限
|
||||
|
||||
| 接口 | 类型 | 速度 | 应用 |
|
||||
|------|------|------|------|
|
||||
| **USB 3.0** | 串行 | 5 Gbps | 外设连接 |
|
||||
| **USB 4** | 串行 | 40 Gbps | 高速外设 |
|
||||
| **SATA III** | 串行 | 6 Gbps | 硬盘接口 |
|
||||
| **PCIe 4.0 x16** | 串行(多通道) | 32 GB/s | 显卡、SSD |
|
||||
| **以太网** | 串行 | 1-100 Gbps | 网络传输 |
|
||||
在机箱内部,或者两台靠得很近的电脑之间发数据,我们面临的是**纯粹的物理挑战**。
|
||||
|
||||
### 3.3 传输的可靠性
|
||||
很多人第一个想到的点子是:“一根电线一次发 1 个信号,那我并排接 8 根线,速度不就是 8 倍吗?”
|
||||
这就是早期用来插硬盘的**并行传输(Parallel)**思路。
|
||||
|
||||
::: tip 💡 如何保证传输不出错?
|
||||
**校验机制**:
|
||||
- **奇偶校验**:简单的错误检测
|
||||
- **CRC 校验**:更强的错误检测能力
|
||||
- **校验和**:快速检测数据完整性
|
||||
然而,今天手机的 Type-C、外部的 USB 和主板内部的 PCIe 接口,用的全都是**串行传输(Serial,只有一根主通道发数据)**。
|
||||
|
||||
**纠错机制**:
|
||||
- **重传**:发现错误就重新发送
|
||||
- **前向纠错**:发送冗余信息,接收方能自动纠正
|
||||
👇 **动手点点看**:
|
||||
比较一下串行和并行传输的动画。
|
||||
|
||||
**流量控制**:
|
||||
- 防止发送方发太快,接收方来不及处理
|
||||
- 类似"确认收到再发下一个"
|
||||
:::
|
||||
<DataTransmissionDemo />
|
||||
|
||||
**💡 为什么“一条小路”击败了“八车道”?**
|
||||
|
||||
在速度不快时,8 根线确实强。但当我们需要每秒发几十亿次信号时,问题出现了:
|
||||
并排的几根线上的微弱电流会产生极强的电磁波互相干扰(串扰 Crosstalk);而且你根本无法保证发送端同时发出的 8 个信号,能完美**同时**到达终点线。只要有一根线因为杂质阻抗慢了一丝拉,8 个拼在一起的字就彻底乱了。
|
||||
|
||||
所以,与其花天价去调平 8 条赛道,不如把所有技术资源砸在 1 辆跑车上,把它拉到光速。这就是串行接口一统天下的物理真相。
|
||||
|
||||
### 3.2 广域网与互联网传输:漂洋过海的防丢艺术
|
||||
|
||||
如果你的数据不是发给机箱里一寸外的显卡,而是要发给大洋彼岸美国服务器呢?
|
||||
|
||||
一根连续的导线是不可能的。数据要穿过光缆、海底基站、无数个破旧的路由器。这时候,面临的不再是物理极限,而是**容错保全挑战**。
|
||||
|
||||
当你用微信发送 1GB 的超大视频时,底层的逻辑像极了国际搬家——你不可能整个集装箱直接扔给邮政。
|
||||
|
||||
1. **分包(Packetization)**:网络会把视频切成几万个信封大小的“数据包”(通常是 1500 字节)。
|
||||
2. **校验(Checksum)**:为防止途中海底光缆被鲨鱼咬断一根线,导致某个包里的 `0` 翻转成了 `1`,系统会在发件前,用复杂的数学公式对信封里的信件算出一个“特征码”贴在上面。
|
||||
3. **TCP重发与确认**:接收方拿到信封,先自己在纸上验算一遍特征码。如果不对(沿途受损),或者发现序号从 31 直接跳到了 33(丢包),就会通过网络大喊一声:**“我没收到 32 号,请你再重发一遍 32 号!”**
|
||||
|
||||
正因为有了这种底层叫做 **TCP(传输控制协议)** 的极其严密的切包对账机制,你在地下室或者极不稳定的 WiFi 下下载微信文件,就算下了半小时,下载完的那一瞬间,文件也必定是 100% 完整、0 损坏的。
|
||||
|
||||
---
|
||||
|
||||
## 4. 编码、存储、传输的协作
|
||||
## 4. 终局实战:从拍下快门到发朋友圈的全流程
|
||||
|
||||
让我们看一个完整的例子:**保存一张照片到云端**
|
||||
前面我们将“如何翻译成数字(编码)”、“放在哪里保管(存储)”、“如何完好地走完旅途(传输)”都分块讲了一遍。
|
||||
|
||||
```
|
||||
1. 编码阶段
|
||||
- 相机传感器捕捉光线 → 模拟信号
|
||||
- ADC 转换 → 数字信号(RAW 格式)
|
||||
- JPEG 编码 → 压缩后的二进制数据
|
||||
现在,让我们把这些积木搭起来,沉浸式观看一个日常中再普通不过的操作:**拍一张照片自动备份到云端。**
|
||||
|
||||
2. 存储阶段
|
||||
- 写入手机内存(RAM)→ 临时存储
|
||||
- 写入手机闪存(Flash)→ 持久存储
|
||||
当你按下快门的那一秒钟,手机内部其实已经打响了一场极其恢弘的数字战争。
|
||||
|
||||
3. 传输阶段
|
||||
- 读取闪存数据 → 内存
|
||||
- 通过 Wi-Fi/4G 发送 → 网络传输
|
||||
- 云端接收 → 写入云端存储
|
||||
```
|
||||
👇 **动手点点看**:
|
||||
|
||||
::: tip 💡 理解这个流程
|
||||
每一步都涉及编码、存储、传输:
|
||||
点击“执行这一步”,追踪这笔数据惊险的完整生命旅程。
|
||||
|
||||
1. **编码**:把图像变成二进制数据
|
||||
2. **存储**:在本地保存
|
||||
3. **传输**:通过网络发送到云端
|
||||
|
||||
这三个环节紧密配合,才能完成"保存照片到云端"这个看似简单的操作。
|
||||
:::
|
||||
<PhotoUploadJourneyDemo />
|
||||
|
||||
---
|
||||
|
||||
## 5. 总结:数据的三重奏
|
||||
## 5. 名词对照表
|
||||
|
||||
让我们用一个比喻总结编码、存储、传输:
|
||||
当你阅读其他文档时,可能会遇到下面这些行话,这里为你准备了一张速查表:
|
||||
|
||||
| 概念 | 比喻 | 核心任务 |
|
||||
|------|------|---------|
|
||||
| **编码** | 翻译 | 把信息变成 0 和 1 |
|
||||
| **存储** | 仓库 | 把数据保存起来 |
|
||||
| **传输** | 快递 | 把数据送到目的地 |
|
||||
|
||||
::: tip 💡 核心启示
|
||||
**数据处理的本质是"转换、保存、移动"**。
|
||||
|
||||
- 编码解决"如何表示"的问题
|
||||
- 存储解决"如何保存"的问题
|
||||
- 传输解决"如何传递"的问题
|
||||
|
||||
理解了这三点,你就会明白:
|
||||
- 为什么不同文件格式要选择不同的编码方式
|
||||
- 为什么需要不同层次的存储介质
|
||||
- 为什么传输速度和可靠性需要平衡
|
||||
:::
|
||||
| 术语 / 缩写 | 中文对照 | 简单解释 |
|
||||
| :--- | :--- | :--- |
|
||||
| **Bit (b)** | 比特 / 位 | 计算机世界最小的单位,只能是 0 或者 1。 |
|
||||
| **Byte (B)** | 字节 | 8 个 Bit 捆在一起就是一个 Byte。它是文件大小最基础的衡量单位。 |
|
||||
| **Character Set** | 字符集 | 就像是“字典的目录”,规定了某个文字存在,并没有规定在硬盘里具体怎么写。 |
|
||||
| **Encoding** | 编码 | 具体的“存储规则”,决定了字典里的那个字,对应底层到底是哪几个字节(如 UTF-8)。 |
|
||||
| **RAM** | 内存 / 运行内存 | 极其快速但断电就清空的工作台。你手机的 8G/16G 运存指的就是这个。 |
|
||||
| **SSD** | 固态硬盘 | 现代电脑负责永久保存数据的仓库,基于闪存芯片,比老式机械硬盘快几十倍。 |
|
||||
| **Serial / Parallel** | 串行 / 并行 | 串行是一条通道挨个排队飞奔;并行是多条通道齐头并进(但不适合极高频率)。 |
|
||||
| **Checksum** | 校验和 | 传输数据时附带的验证码。收件人算一遍,如果和包裹上写的一致,说明没坏。 |
|
||||
| **TCP** | 传输控制协议 | 互联网的基石协议。负责把大文件切片、贴序号、丢包重发,保证数据 100% 完整送达。 |
|
||||
|
||||
---
|
||||
|
||||
## 延伸阅读
|
||||
## 总结
|
||||
|
||||
- **字符编码详解**:深入学习 ASCII、Unicode、UTF-8 的设计原理
|
||||
- **存储技术发展**:了解从磁带到 SSD 的技术演进
|
||||
- **网络传输协议**:学习 TCP/IP 如何保证可靠传输
|
||||
- **数据压缩算法**:了解 ZIP、JPEG、MP3 等压缩原理
|
||||
文章一开始提出的诸多疑惑,现在你已经站在了系统底层的视角有了答案:
|
||||
|
||||
- **为什么同样的文件你收到后变乱码了?**
|
||||
数据没坏,只是你的阅读软件没选对密码本(编码问题)。
|
||||
|
||||
- **为什么现在电脑背后的线大多是一根小小的 Type-C,却比以前很宽的线传输还要快?**
|
||||
因为以前是几辆马车并排慢跑容易撞车(并行),现在是一列高铁在专线上极速狂飙(串行)。
|
||||
|
||||
- **为什么大型游戏在读取场景时要黑屏很久?**
|
||||
因为它需要把动辄几十 GB 的大文件,从速度慢的硬盘(仓储区),拼命搬运拼接到速度快但昂贵的内存(核心工作台)里。
|
||||
|
||||
计算机的本质其实非常朴素:
|
||||
|
||||
**它不过是一个擅长把所有的光影文字“转换(编码)”、放在某个硅片里“保管(存储)”、然后再把它切碎成电平脉冲“邮寄出去(传输)”的机器**。
|
||||
|
||||
读懂了这个循环往复的过程,你就真正握住了推开计算机底层原理大门的那把钥匙。
|
||||
|
||||
@@ -1,348 +1,558 @@
|
||||
# Git:代码的时光机
|
||||
::: tip 🎯 核心问题
|
||||
**写代码时最怕什么?** 写错了想回退、改崩了想重来、多人同时改同一个文件...这些头疼的事,Git 都能帮你搞定!它就像是代码世界的"时光机",让你随时回到过去,又能和队友在各自的"平行宇宙"里安全开发。
|
||||
:::
|
||||
|
||||
> 💡 **学习指南**:这一章专门写给完全没用过 Git 的人。我们不会上来就让你背命令,而是先搞清楚"Git 到底在帮你解决什么问题",再一步步把命令和概念串起来。读完后,你应该能独立完成:本地提交、创建分支、推送到 GitHub。
|
||||
|
||||
---
|
||||
|
||||
## 0. 最常用的 5 个场景(直接照抄)
|
||||
## 0. 先问一个问题:你有没有经历过这些噩梦?
|
||||
|
||||
如果你只想"立刻能用",先把这块过一遍:每个场景都是现实工作中最常见的 Git 流程。
|
||||
**场景一:版本地狱**
|
||||
|
||||
<GitScenariosDemo />
|
||||
你写论文或者写代码,改到一半发现改错了,想回到三天前的版本——但你找不到了。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要学 Git?三大痛点
|
||||
|
||||
### 1.1 痛点一:版本混乱
|
||||
|
||||
你是否经历过这种绝望?
|
||||
|
||||
```text
|
||||
论文_初稿.doc
|
||||
论文_修改版.doc
|
||||
论文_最终版.doc
|
||||
论文_最终版_打死不改版.doc
|
||||
论文_绝对是最后一次修改版.doc
|
||||
```
|
||||
项目_v1.zip
|
||||
项目_v2_修改版.zip
|
||||
项目_v3_最终版.zip
|
||||
项目_v3_最终版_真的最终版.zip
|
||||
项目_v3_最终版_打死不改了.zip
|
||||
```
|
||||
|
||||
**Git 的解决方案**:不需要复制副本,一个文件夹搞定所有历史版本。想回到哪次修改,一键恢复。
|
||||
每次存一个新副本,硬盘越来越乱,而且你根本记不住哪个版本改了什么。
|
||||
|
||||
### 1.2 痛点二:无法后悔
|
||||
**场景二:协作噩梦**
|
||||
|
||||
::: tip 💡 这个场景你一定遇到过
|
||||
写代码写了 3 小时,突然发现之前的思路更好,但已经改不回去了...或者删错了一段代码,想找回原来的版本。
|
||||
你和队友同时改同一个文件:
|
||||
- 你改了第 10 行,添加了登录功能
|
||||
- 队友改了第 10 行,修复了一个 Bug
|
||||
- 你们用邮件互发代码,结果合并时一个人的改动被另一个人覆盖了
|
||||
- 没人知道最后哪段代码是对的
|
||||
|
||||
有了 Git,这种情况永远不会发生。每次重要节点都能"存档",出问题随时"读档"重来。
|
||||
:::
|
||||
**场景三:没有"后悔药"**
|
||||
|
||||
### 1.3 痛点三:协作冲突
|
||||
|
||||
你和队友同时改同一个文件:
|
||||
|
||||
- 你改了 A 文件的第 10 行
|
||||
- 队友改了 A 文件的第 15 行
|
||||
- 怎么合并?谁覆盖谁?
|
||||
|
||||
**Git 的解决方案**:智能合并,自动处理大部分冲突。只有当你们真的改了同一行代码时,才需要手动决定用谁的。
|
||||
你在生产环境部署了新代码,结果出 Bug 了,想紧急回退到上一个稳定版本——但你不知道怎么回退,只能手忙脚乱地找备份。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心概念:三区模型
|
||||
**Git 就是为了解决这三个问题而生的。**
|
||||
|
||||
Git 的设计哲学其实很像**寄快递**。
|
||||
Git 是一个**版本控制系统**(Version Control System)。它的本质是:**把你每一次"存档"操作都记录下来,形成一条完整的历史时间线,让你可以随时回到任意一个历史节点。**
|
||||
|
||||
<GitThreeAreasDemo />
|
||||
|
||||
### 2.1 三个区域是什么?
|
||||
|
||||
::: tip 📦 用快递理解 Git
|
||||
想象你在寄快递:
|
||||
|
||||
- **工作区(Working Dir)** = 你的**书桌**。你在这里整理要寄的东西,想怎么乱改都行。
|
||||
- **暂存区(Staging Area)** = **快递盒**。你把要寄的文件放进去(`git add`),准备打包。
|
||||
- **仓库(Repository)** = **快递柜**。一旦你封箱寄出(`git commit`),这个版本就被永久记录下来了。
|
||||
:::
|
||||
|
||||
| 区域 | 作用 | 对应命令 | 状态 |
|
||||
| ---------- | ------------------ | --------------------- | ------------- |
|
||||
| **工作区** | 你当前正在改的代码 | `git status` 查看修改 | 红色 = 未暂存 |
|
||||
| **暂存区** | 准备提交的文件 | `git add` 添加 | 绿色 = 已暂存 |
|
||||
| **仓库** | 永久保存的历史版本 | `git commit` 提交 | 只读,不能改 |
|
||||
|
||||
::: tip 💡 关键理解
|
||||
只有提交到**仓库**的内容才是安全的。工作区里没提交的内容,丢了就真丢了。所以经常`git commit`是好习惯!
|
||||
:::
|
||||
不夸张地说,Git 是现代软件开发最重要的工具之一。几乎所有的公司、所有的开源项目都在用它。
|
||||
|
||||
---
|
||||
|
||||
## 3. 基础工作流:存档三步走
|
||||
## 1. Git 和 GitHub 是一回事吗?
|
||||
|
||||
日常开发中,你 90% 的时间都在重复这三个动作。
|
||||
很多初学者会混淆这两个概念,先澄清一下:
|
||||
|
||||
<GitWorkflowDemo />
|
||||
| | Git | GitHub |
|
||||
| :--- | :--- | :--- |
|
||||
| **是什么** | 一个运行在你电脑上的版本控制工具 | 一个存放 Git 仓库的网站(云端) |
|
||||
| **在哪里** | 你的本地电脑 | 互联网上 |
|
||||
| **能独立使用吗** | ✅ 可以,只管理本地历史 | ❌ 需要配合 Git 使用 |
|
||||
| **类比** | 你本地的日记本 | 存日记的云盘 |
|
||||
|
||||
### 3.1 第一步:修改代码(工作区)
|
||||
简单说:**Git 是工具,GitHub 是托管服务。** 就像 Word 是工具,OneDrive 是云盘一样,两者配合使用,但并不是同一个东西。
|
||||
|
||||
在工作区写写画画,想怎么改就怎么改。这时候修改只在你本地,还没记录。
|
||||
除了 GitHub,类似的服务还有 GitLab、Gitee(国内)等。
|
||||
|
||||
### 3.2 第二步:挑选文件(git add → 暂存区)
|
||||
---
|
||||
|
||||
::: tip 🤔 为什么要先 add 再 commit?
|
||||
你可能问:为什么不能直接 commit 所有修改?
|
||||
## 2. 核心概念:三个区域
|
||||
|
||||
**答案**:因为有时候你不想一次性提交所有改动。
|
||||
这是整个 Git 最重要的设计,理解了这三个区域,你就理解了 Git 的灵魂。
|
||||
|
||||
- 今天改了 5 个文件,但只想提交其中 3 个(完成了一个功能)
|
||||
- 另外 2 个文件还在调试中,不想现在提交
|
||||
Git 把你的文件状态分成三层:
|
||||
|
||||
`git add` 让你有选择权:决定这次提交包含哪些文件。
|
||||
:::
|
||||
**工作区(Working Directory)**
|
||||
就是你的**普通文件夹**,你现在看到的、正在编辑的所有文件都在这里。你随便改,Git 会感知到你改了什么,但不会做任何记录。
|
||||
|
||||
**常用命令**:
|
||||
**暂存区(Staging Area / Index)**
|
||||
这是一个**"预备提交"的中转站**。你可以把工作区里想要保存的文件"放进"暂存区,就像把快递放进快递盒——还没寄出去,但已经选好了要寄什么。
|
||||
|
||||
**仓库(Repository)**
|
||||
这是**永久存档的历史记录库**,藏在 `.git` 文件夹里。每次你执行 `git commit`,暂存区里的内容就会被封存进仓库,形成一条不可篡改的历史记录。
|
||||
|
||||
👇 **动手点点看**:依次点击命令按钮,观察文件在三个区域之间的流转。
|
||||
|
||||
<GitCommitFlow />
|
||||
|
||||
### 为什么要"两步走"(add + commit)?
|
||||
|
||||
很多初学者会问:为什么不能直接一键保存,非要先 `add` 再 `commit`?
|
||||
|
||||
**因为现实开发中,你经常不想把所有改动都一起提交。**
|
||||
|
||||
举个例子:你今天改了 5 个文件:
|
||||
- `login.js`:完成了登录功能(想提交)
|
||||
- `style.css`:调整了登录页样式(想提交)
|
||||
- `debug.log`:临时调试输出(**不想**提交)
|
||||
- `experiment.js`:正在测试的新功能,还没完成(**不想**提交)
|
||||
- `todo.txt`:你的个人备忘(**不想**提交)
|
||||
|
||||
如果没有暂存区,你要么把这 5 个文件全部提交(提交记录很混乱),要么一个都不提交。
|
||||
|
||||
有了暂存区,你可以精确控制:`git add login.js style.css`,只把这两个文件放进快递盒,然后 `commit`,这次提交就清清楚楚地记录"登录功能完成"。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第一次使用 Git:初始化和基础工作流
|
||||
|
||||
### 3.1 安装和初始化
|
||||
|
||||
安装好 Git 后(macOS 自带,Windows 去 git-scm.com 下载),打开终端,进入你的项目文件夹:
|
||||
|
||||
```bash
|
||||
# 添加单个文件
|
||||
git add index.html
|
||||
# 在当前文件夹初始化一个 Git 仓库
|
||||
git init
|
||||
|
||||
# 添加所有修改
|
||||
git add .
|
||||
# Git 会创建一个隐藏的 .git 文件夹,所有历史记录存在里面
|
||||
# 输出:Initialized empty Git repository in .../your-project/.git/
|
||||
```
|
||||
|
||||
# 查看哪些文件被暂存了
|
||||
第一次使用还需要告诉 Git 你是谁(这个信息会附在每次提交记录上):
|
||||
|
||||
```bash
|
||||
git config --global user.name "你的名字"
|
||||
git config --global user.email "你的邮箱"
|
||||
```
|
||||
|
||||
### 3.2 日常工作流:三步存档
|
||||
|
||||
初始化之后,日常开发 90% 的操作就是反复执行这三步:
|
||||
|
||||
**第一步:查看状态**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
### 3.3 第三步:封箱提交(git commit → 仓库)
|
||||
这是你用得最多的命令,没有之一。它告诉你:
|
||||
- 你在哪个分支上
|
||||
- 哪些文件被修改了(红色 = 未暂存)
|
||||
- 哪些文件在暂存区里(绿色 = 已暂存,等待提交)
|
||||
|
||||
给这次修改起个名字(比如"修复了登录 Bug"),永久存档。
|
||||
|
||||
**重要:commit message 要写清楚!**
|
||||
**第二步:把文件放进暂存区**
|
||||
|
||||
```bash
|
||||
# ❌ 不好的写法
|
||||
git commit -m "update"
|
||||
# 添加单个文件
|
||||
git add login.js
|
||||
|
||||
# ✅ 好的写法
|
||||
git commit -m "feat: 添加用户登录功能"
|
||||
git commit -m "fix: 修复首页在 iOS 的显示问题"
|
||||
git commit -m "docs: 更新 README 的部署说明"
|
||||
# 添加多个文件
|
||||
git add login.js style.css
|
||||
|
||||
# 添加当前文件夹里所有修改过的文件(用 . 表示"全部")
|
||||
git add .
|
||||
```
|
||||
|
||||
::: tip 💡 commit message 规范
|
||||
推荐用**类型+描述**的格式:
|
||||
> ⚠️ 初学者常见误区:`git add .` 非常方便,但会把所有修改都加进去,包括你不想提交的临时文件。养成精确 add 的习惯,或者用 `.gitignore` 排除不想追踪的文件(后面会讲)。
|
||||
|
||||
- `feat:` 新功能
|
||||
- `fix:` 修复 bug
|
||||
- `docs:` 文档更新
|
||||
- `style:` 代码格式(不影响功能)
|
||||
- `refactor:` 重构(不改变功能)
|
||||
- `test:` 测试相关
|
||||
- `chore:` 构建/工具相关
|
||||
**第三步:提交,写上说明**
|
||||
|
||||
这样以后翻历史记录,一眼就知道每次提交做了什么。
|
||||
:::
|
||||
```bash
|
||||
git commit -m "feat: 添加用户登录功能"
|
||||
```
|
||||
|
||||
`-m` 后面引号里的内容叫做 **commit message**(提交说明)。这是写给未来的自己和队友看的,要写得有意义。
|
||||
|
||||
### 3.3 Commit Message 怎么写才专业?
|
||||
|
||||
```bash
|
||||
# ❌ 没用的写法——看了不知道做了什么
|
||||
git commit -m "update"
|
||||
git commit -m "fix"
|
||||
git commit -m "改了一些东西"
|
||||
|
||||
# ✅ 好的写法:类型 + 冒号 + 一句话描述
|
||||
git commit -m "feat: 添加用户登录功能"
|
||||
git commit -m "fix: 修复首页在 iOS Safari 上的白屏问题"
|
||||
git commit -m "docs: 更新 README 中的部署说明"
|
||||
git commit -m "refactor: 将 UserService 拆分为独立模块"
|
||||
git commit -m "style: 统一代码缩进为 2 空格"
|
||||
```
|
||||
|
||||
**常用前缀含义:**
|
||||
|
||||
| 前缀 | 含义 |
|
||||
| :--- | :--- |
|
||||
| `feat:` | 新功能(feature) |
|
||||
| `fix:` | 修复 Bug |
|
||||
| `docs:` | 文档改动 |
|
||||
| `style:` | 代码格式调整(不影响功能) |
|
||||
| `refactor:` | 代码重构(功能不变,结构优化) |
|
||||
| `chore:` | 构建、工具、依赖相关 |
|
||||
| `test:` | 测试相关 |
|
||||
|
||||
养成这个习惯,几个月后翻历史记录,一眼就知道每次提交做了什么。这在团队协作中尤其重要。
|
||||
|
||||
### 3.4 查看历史记录
|
||||
|
||||
```bash
|
||||
# 详细格式(每次提交的完整信息)
|
||||
git log
|
||||
|
||||
# 简洁格式(每行一条,推荐日常使用)
|
||||
git log --oneline
|
||||
|
||||
# 示例输出:
|
||||
# a1b2c3d (HEAD -> main) feat: 添加用户登录功能
|
||||
# 9f3e1b2 init: 项目初始化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 平行宇宙:分支(Branch)的魔法
|
||||
## 4. 平行宇宙:分支(Branch)
|
||||
|
||||
这是 Git 最强大的功能!
|
||||
**分支**是 Git 最强大、也是最让初学者困惑的功能。但理解了它之后,你会发现这个设计非常优雅。
|
||||
|
||||
::: tip 🌌 用游戏理解分支
|
||||
想象你在玩游戏,前面有个大 Boss(上线新功能),你怕打不过导致游戏结束(系统崩溃)。
|
||||
### 4.1 分支是什么?用"平行宇宙"来理解
|
||||
|
||||
这时候,你可以开一个**分支(Branch)**,相当于**复制了一个平行世界**:
|
||||
想象你在玩一个角色扮演游戏,游戏里有一个关键选择:
|
||||
- 选择 A:去挑战大 Boss(开发新功能)
|
||||
- 选择 B:继续稳定当前局面(主线不动)
|
||||
|
||||
- 在**平行世界**(新分支)里打 Boss,输了也不怕,因为主世界(主分支)没影响
|
||||
- 打赢了就把成果"合并"回主世界
|
||||
- 多个队友可以在各自的平行世界开发,互不干扰
|
||||
:::
|
||||
如果你直接在主存档上做选择 A,万一失败了,整个游戏进度就毁了。
|
||||
|
||||
<GitBranchMergeDemo />
|
||||
但如果你**复制一个存档**,在副本里去挑战 Boss:
|
||||
- 打赢了?把副本的成果合并回主存档
|
||||
- 打输了?主存档完全没有影响,删掉副本重来
|
||||
|
||||
### 4.1 主分支 vs 开发分支
|
||||
**Git 分支就是这个"副本存档"机制。**
|
||||
|
||||
| 分支类型 | 作用 | 特点 |
|
||||
| ------------------- | -------------- | ------------------------------------ |
|
||||
| **main/master** | 稳定的线上版本 | 只有测试通过的代码才能进来 |
|
||||
| **dev/feature-xxx** | 你的试验田 | 这里炸了地球也没关系,不影响主分支 |
|
||||
| **hotfix-xxx** | 紧急修复 | 生产出 bug 时,从 main 开分支快速修复 |
|
||||
在 Git 里,`main`(或 `master`)分支是你的"主存档",永远保持稳定可用。当你要开发新功能时,你从 main 创建一个新分支,在那里开发、测试,完成后再合并回 main。
|
||||
|
||||
### 4.2 分支操作流程
|
||||
### 4.2 分支的可视化演示
|
||||
|
||||
**创建分支并切换**:
|
||||
👇 **动手点点看**:依次点击命令按钮,观察下方分支图如何分叉、延伸、最终合并。重点关注 HEAD 标签的位置变化——它始终指向"你当前在哪里"。
|
||||
|
||||
<GitBranchVisual />
|
||||
|
||||
### 4.3 分支操作详解
|
||||
|
||||
**创建并切换到新分支:**
|
||||
|
||||
```bash
|
||||
# 创建新分支
|
||||
git branch feature-login
|
||||
# 方式一:先创建,再切换(两步)
|
||||
git branch feature-login # 创建分支
|
||||
git checkout feature-login # 切换过去
|
||||
|
||||
# 切换到新分支
|
||||
git checkout feature-login
|
||||
|
||||
# 或者一步到位:创建并切换
|
||||
# 方式二:一步到位(推荐)
|
||||
git checkout -b feature-login
|
||||
|
||||
# 输出:Switched to a new branch 'feature-login'
|
||||
```
|
||||
|
||||
**在分支上开发**:
|
||||
|
||||
```bash
|
||||
# 在 feature-login 分支上改代码...
|
||||
git add .
|
||||
git commit -m "feat: 添加登录表单"
|
||||
创建分支后,你的命令行提示符会显示当前分支名,比如:
|
||||
```
|
||||
user@mac ~/project (feature-login) $
|
||||
```
|
||||
|
||||
**合并回主分支**:
|
||||
**查看所有分支:**
|
||||
|
||||
```bash
|
||||
# 切回主分支
|
||||
git branch
|
||||
|
||||
# 输出(* 表示当前所在分支):
|
||||
# * feature-login
|
||||
# main
|
||||
```
|
||||
|
||||
**在分支上正常开发:**
|
||||
|
||||
```bash
|
||||
# 在 feature-login 分支上,改代码、add、commit,和平时完全一样
|
||||
git add login.js
|
||||
git commit -m "feat: 添加登录表单 HTML 结构"
|
||||
|
||||
git add login.js api.js
|
||||
git commit -m "feat: 完成登录接口对接"
|
||||
```
|
||||
|
||||
这些提交只在 `feature-login` 分支上,`main` 分支完全不知道你做了什么。
|
||||
|
||||
**切回主分支,合并:**
|
||||
|
||||
```bash
|
||||
# 切回 main
|
||||
git checkout main
|
||||
|
||||
# 合并 feature-login
|
||||
# 把 feature-login 的所有改动合并进来
|
||||
git merge feature-login
|
||||
|
||||
# 删除已合并的分支(可选)
|
||||
# 合并完成后,可以删掉这个分支(可选)
|
||||
git branch -d feature-login
|
||||
```
|
||||
|
||||
::: tip 💡 什么时候用分支?
|
||||
**个人开发**:
|
||||
### 4.4 什么时候该开分支?
|
||||
|
||||
- 要尝试新想法,不确定会不会搞崩现有代码 → 开分支
|
||||
- 修一个复杂 bug,需要多次实验 → 开分支
|
||||
| 场景 | 建议 | 理由 |
|
||||
| :--- | :--- | :--- |
|
||||
| 开发一个新功能 | ✅ 开分支 | 功能完成前不影响主线,随时可以放弃 |
|
||||
| 修复线上紧急 Bug | ✅ 从 main 开 `hotfix-xxx` 分支 | 修复完直接合并上线,不带入未完成的功能 |
|
||||
| 和队友并行开发 | ✅ 各自开分支 | 互不干扰,完成后统一通过 Pull Request 合并 |
|
||||
| 只改一个错别字 | ❌ 直接在 main 改 | 风险极低,没必要额外开分支 |
|
||||
|
||||
**团队开发**:
|
||||
### 4.5 团队常用的分支策略
|
||||
|
||||
- 每个功能一个分支,互不干扰
|
||||
- 开发完提 Pull Request,队友 review 后再合并
|
||||
:::
|
||||
在实际项目中,团队通常会约定好分支的命名和用途:
|
||||
|
||||
| 分支名 | 用途 | 特点 |
|
||||
| :--- | :--- | :--- |
|
||||
| `main` / `master` | 生产环境的稳定代码 | 只有测试通过的代码才能进来,不能直接推送 |
|
||||
| `dev` / `develop` | 日常集成分支 | 所有功能分支先合并到这里,测试通过再上 main |
|
||||
| `feature/xxx` | 具体功能开发 | 如 `feature/user-login`,完成后合并到 dev |
|
||||
| `hotfix/xxx` | 紧急修复 | 从 main 创建,修完直接合并回 main 和 dev |
|
||||
|
||||
---
|
||||
|
||||
## 5. 常用命令速查表
|
||||
## 5. 与队友协作:远程仓库
|
||||
|
||||
| 命令 | 作用 | 人话解释 | 使用频率 |
|
||||
| ----------------------- | ---------- | ------------------------------ | --------------------- |
|
||||
| `git init` | 初始化 | "在这里建个新仓库" | 项目开始时用一次 |
|
||||
| `git status` | 查看状态 | "现在乱不乱?有没有东西没提交?" | ⭐⭐⭐⭐⭐ 极高频 |
|
||||
| `git add .` | 添加所有 | "把桌上所有文件都扔进快递盒" | ⭐⭐⭐⭐⭐ 每次提交前 |
|
||||
| `git add file.txt` | 添加单个 | "只要这个文件" | ⭐⭐⭐⭐ 选择性添加 |
|
||||
| `git commit -m "..."` | 提交 | "封箱!贴上标签,写上这次改了啥" | ⭐⭐⭐⭐⭐ 完成功能时 |
|
||||
| `git log` | 查看历史 | "翻翻以前的日记" | ⭐⭐⭐ 回顾历史 |
|
||||
| `git checkout -b dev` | 创建新分支 | "我要去平行宇宙 dev 探险了" | ⭐⭐⭐⭐ 开新功能 |
|
||||
| `git checkout main` | 切换分支 | "回地球(主分支)看看" | ⭐⭐⭐⭐ 切换任务 |
|
||||
| `git merge dev` | 合并分支 | "把平行宇宙的成果带回地球" | ⭐⭐⭐ 完成功能 |
|
||||
| `git branch` | 查看分支 | "现在有哪些平行世界?" | ⭐⭐⭐ 查看状态 |
|
||||
| `git branch -d feature` | 删除分支 | "这个平行世界不需要了,删掉" | ⭐⭐ 合并后清理 |
|
||||
| `git push` | 推送 | "把本地存档上传到云端" | ⭐⭐⭐⭐⭐ 团队协作 |
|
||||
| `git pull` | 拉取 | "把云端最新存档下载到本地" | ⭐⭐⭐⭐⭐ 团队协作 |
|
||||
到目前为止,你学的都是**本地**的 Git 操作——所有历史记录都存在你自己的电脑上。要和队友共享代码,你需要一个**远程仓库**,也就是 GitHub、GitLab 这样的云端存储。
|
||||
|
||||
---
|
||||
### 5.1 远程仓库的工作原理
|
||||
|
||||
## 6. 进阶:解决冲突与远程协作
|
||||
可以把远程仓库理解为**团队共用的"公共存档"**:
|
||||
|
||||
### 6.1 冲突(Conflict)是什么?
|
||||
- 每个人在本地写代码、commit
|
||||
- 写完后 `push`(上传)到远程仓库
|
||||
- 队友 `pull`(下载)远程仓库的最新内容到自己本地
|
||||
- 这样大家的代码就保持同步了
|
||||
|
||||
当你和队友**同时修改了同一个文件的同一行代码**,Git 就会懵:"我该听谁的?"这就是**冲突(Conflict)**。
|
||||
👇 **动手点点看**:依次点击命令,体验从关联远程仓库、推送、到拉取队友更新的完整流程。
|
||||
|
||||
<GitConflictDemo />
|
||||
<GitSyncDemo />
|
||||
|
||||
### 6.2 怎么解决冲突?
|
||||
### 5.2 第一次推送项目到 GitHub
|
||||
|
||||
**Step 1**:打开冲突文件,会看到这样的标记:
|
||||
**第一步**:在 GitHub 上创建一个新仓库(点击右上角 + → New repository),不要勾选初始化选项。
|
||||
|
||||
```text
|
||||
<<<<<<< HEAD
|
||||
你的代码
|
||||
=======
|
||||
队友的代码
|
||||
>>>>>>> feature-branch
|
||||
```
|
||||
|
||||
**Step 2**:手动选择要保留的代码,或合并两者:
|
||||
|
||||
```text
|
||||
# 保留你的代码 → 删除队友的部分和标记
|
||||
# 保留队友的 → 删除你的部分和标记
|
||||
# 合并两者 → 综合两边的代码
|
||||
```
|
||||
|
||||
**Step 3**:删除所有标记,保存文件
|
||||
|
||||
**Step 4**:重新提交
|
||||
**第二步**:回到本地终端,关联远程仓库:
|
||||
|
||||
```bash
|
||||
git add 解决冲突的文件
|
||||
git commit # Git 会自动生成合并提交
|
||||
# 把本地仓库和 GitHub 上的仓库关联起来
|
||||
# "origin" 是远程仓库的别名,是约定俗成的名字(也可以改,但没必要)
|
||||
git remote add origin https://github.com/你的用户名/仓库名.git
|
||||
|
||||
# 确认关联成功
|
||||
git remote -v
|
||||
# 输出:
|
||||
# origin https://github.com/你的用户名/仓库名.git (fetch)
|
||||
# origin https://github.com/你的用户名/仓库名.git (push)
|
||||
```
|
||||
|
||||
::: tip 💡 避免冲突的最佳实践
|
||||
**第三步**:推送本地内容到远程:
|
||||
|
||||
- **频繁沟通**:队友改同一个文件前,先打个招呼
|
||||
- **小步提交**:不要攒着大量代码最后才提交,增加冲突概率
|
||||
- **分支隔离**:不同功能用不同分支,减少直接冲突
|
||||
- **用 Pull Request**:合并前让队友 review,提前发现问题
|
||||
:::
|
||||
```bash
|
||||
# 第一次推送,-u 的意思是"以后 git push 时,默认推到 origin 的 main 分支"
|
||||
git push -u origin main
|
||||
|
||||
### 6.3 远程仓库(Remote)
|
||||
# 之后每次推送只需要:
|
||||
git push
|
||||
```
|
||||
|
||||
**远程仓库**(比如 GitHub/GitLab)就是**云端的备份中心**。
|
||||
### 5.3 日常协作的命令
|
||||
|
||||
<GitRemoteDemo />
|
||||
**推送(你改了东西,要让队友看到):**
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
**核心操作**:
|
||||
**拉取(队友改了东西,你要同步):**
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
| 操作 | 命令 | 作用 |
|
||||
| ------------ | ---------------------------------------------- | ------------------------ |
|
||||
| **关联远程** | `git remote add origin https://github.com/...` | 第一次连接云端 |
|
||||
| **推送** | `git push -u origin main` | 把本地存档上传 |
|
||||
| **拉取** | `git pull` | 把云端最新存档下载并合并 |
|
||||
| **克隆** | `git clone https://github.com/...` | 复制整个仓库到本地 |
|
||||
`git pull` 实际上是两个命令的组合:
|
||||
1. `git fetch`:先去远程仓库下载最新的提交记录
|
||||
2. `git merge`:把下载回来的内容合并到你当前的分支
|
||||
|
||||
::: tip 💡 push 和 pull 的区别
|
||||
**第一次从 GitHub 获取别人的项目:**
|
||||
```bash
|
||||
# 把整个远程仓库复制到本地(只需要做一次)
|
||||
git clone https://github.com/某人/某项目.git
|
||||
|
||||
- **push**:你的本地代码 → 云端(你改了东西,要同步给队友)
|
||||
- **pull**:云端代码 → 你的本地(队友改了东西,你要同步下来)
|
||||
# clone 会自动建立与远程的关联,之后直接 push/pull 就行
|
||||
```
|
||||
|
||||
**最佳实践**:每天开始工作前先`git pull`,下班前`git push`,这样减少冲突。
|
||||
:::
|
||||
### 5.4 push 和 pull 的方向
|
||||
|
||||
```
|
||||
你的电脑(本地仓库) ←→ GitHub(远程仓库)
|
||||
|
||||
git push: 本地 → 远程 (你改了东西,上传给队友)
|
||||
git pull: 远程 → 本地 (队友改了东西,下载到你这里)
|
||||
git clone: 远程 → 本地 (第一次完整复制整个仓库)
|
||||
```
|
||||
|
||||
> **最佳实践**:每天开始工作前先 `git pull`,拿到最新代码;下班或完成一个功能后 `git push`,及时备份并让队友看到你的进展。
|
||||
|
||||
---
|
||||
|
||||
## 7. 总结:Git 的核心思想
|
||||
## 6. 进阶:处理冲突
|
||||
|
||||
Git 不是简单的"版本备份",而是一个**完整的代码协作系统**:
|
||||
冲突是协作中不可避免的,但也没那么可怕。
|
||||
|
||||
| 特性 | 解决的问题 | 生活类比 |
|
||||
| ------------ | ------------------------------- | --------------------- |
|
||||
| **版本管理** | 代码改错了怎么办? | 时光机,随时回退 |
|
||||
| **分支** | 多人同时改同一个文件怎么办? | 平行宇宙,互不干扰 |
|
||||
| **暂存区** | 这次提交不想包含所有修改怎么办? | 快递盒,挑拣要寄的东西 |
|
||||
| **远程协作** | 怎么和队友共享代码? | 云备份,随时随地同步 |
|
||||
| **冲突处理** | 真的改到同一行了怎么办? | 智能合并 + 手动协调 |
|
||||
### 6.1 冲突是怎么发生的?
|
||||
|
||||
**学习建议**:
|
||||
当你和队友**同时修改了同一个文件的同一行**,在合并时 Git 不知道该用谁的版本,就会产生冲突。
|
||||
|
||||
1. **先用起来**:不要等"完全理解"再用,一边用一边理解
|
||||
2. **从简单开始**:个人项目先掌握`add/commit/push/pull`,团队项目再学分支
|
||||
3. **看 Git 图形化工具**:SourceTree、GitHub Desktop 等,可视化帮助理解
|
||||
4. **遇到问题不要慌**:Git 的设计就是为了让你能安全地尝试,大不了`git reset`
|
||||
举个例子:
|
||||
- 你在 `login.js` 第 5 行写了:`const timeout = 3000`
|
||||
- 队友同时在同一行写了:`const timeout = 5000`
|
||||
- 当你 `git pull` 或 `git merge` 时,Git 发现了这个矛盾,就会"暂停"并告诉你:我不知道该用哪个,你来决定。
|
||||
|
||||
### 6.2 冲突文件长什么样?
|
||||
|
||||
Git 会在冲突的地方插入特殊标记:
|
||||
|
||||
```javascript
|
||||
function login() {
|
||||
const url = '/api/login'
|
||||
|
||||
<<<<<<< HEAD
|
||||
const timeout = 3000 // 你的版本
|
||||
=======
|
||||
const timeout = 5000 // 队友的版本
|
||||
>>>>>>> feature/update-timeout
|
||||
|
||||
return fetch(url, { timeout })
|
||||
}
|
||||
```
|
||||
|
||||
- `<<<<<<< HEAD` 到 `=======` 之间:是你当前分支的内容
|
||||
- `=======` 到 `>>>>>>> xxx` 之间:是合并过来的内容
|
||||
|
||||
### 6.3 如何解决冲突?
|
||||
|
||||
**第一步**:打开冲突文件,找到所有 `<<<<<<<` 标记(通常 VS Code 等编辑器会自动高亮)
|
||||
|
||||
**第二步**:决定保留哪段代码,然后手动编辑文件,删掉所有标记符号(`<<<<<<<`、`=======`、`>>>>>>>`)。
|
||||
|
||||
比如决定用 5000(队友的版本):
|
||||
```javascript
|
||||
function login() {
|
||||
const url = '/api/login'
|
||||
const timeout = 5000 // 采用队友的修改
|
||||
return fetch(url, { timeout })
|
||||
}
|
||||
```
|
||||
|
||||
**第三步**:重新提交
|
||||
|
||||
```bash
|
||||
# 标记冲突已解决
|
||||
git add login.js
|
||||
|
||||
# 完成合并提交(Git 会自动生成合并提交信息)
|
||||
git commit
|
||||
```
|
||||
|
||||
### 6.4 减少冲突的好习惯
|
||||
|
||||
- **勤 pull**:开始工作前同步最新代码,减少"你落后太多"的情况
|
||||
- **小步提交**:不要写了一周代码才一次性提交,频繁小提交更容易发现和解决冲突
|
||||
- **分支隔离**:不同功能用不同分支,减少对同一行代码的竞争
|
||||
- **沟通**:要改公共文件(比如 `config.js`)前,跟队友打个招呼
|
||||
|
||||
---
|
||||
|
||||
## 附录:名词速查表
|
||||
## 7. 常用命令速查
|
||||
|
||||
| 名词 | 英文 | 用人话解释 |
|
||||
| -------- | ---------- | ------------------------------------- |
|
||||
| **仓库** | Repository | 存放所有版本历史的数据库 |
|
||||
| **提交** | Commit | 一次完整的版本记录,像存档点 |
|
||||
| **分支** | Branch | 独立的开发线,像平行宇宙 |
|
||||
| **合并** | Merge | 把一个分支的改动整合到另一个分支 |
|
||||
| **冲突** | Conflict | 同一行代码被修改多次,Git 不知道选哪个 |
|
||||
| **暂存** | Stage | 把修改加入"准备提交"的列表 |
|
||||
| **远程** | Remote | 云端的仓库副本(GitHub/GitLab) |
|
||||
| **克隆** | Clone | 复制整个远程仓库到本地 |
|
||||
| **推送** | Push | 本地 → 远程,上传代码 |
|
||||
| **拉取** | Pull | 远程 → 本地,下载代码 |
|
||||
| **检出** | Checkout | 切换到某个分支或版本 |
|
||||
| **HEAD** | - | 当前所在的分支/版本的指针 |
|
||||
<GitCommandCheatsheet />
|
||||
|
||||
---
|
||||
|
||||
## 8. 实战:加入一个团队项目的完整流程
|
||||
|
||||
这是你加入新团队或新项目时的标准操作流程,可以直接照抄:
|
||||
|
||||
```bash
|
||||
# ① 第一天:把项目 clone 到本地(只做一次)
|
||||
git clone https://github.com/team/project.git
|
||||
cd project
|
||||
|
||||
# ② 每天开始工作:先拉取最新代码,确保你的代码是最新的
|
||||
git pull origin main
|
||||
|
||||
# ③ 创建自己的功能分支(不要直接在 main 上改)
|
||||
git checkout -b feature/user-profile
|
||||
|
||||
# ④ 正常开发...写代码...
|
||||
|
||||
# ⑤ 完成一个小功能点后,立即提交(不要攒着)
|
||||
git add src/UserProfile.vue
|
||||
git commit -m "feat: 完成用户头像上传功能"
|
||||
|
||||
git add src/UserProfile.vue src/api/user.js
|
||||
git commit -m "feat: 完成用户资料编辑接口"
|
||||
|
||||
# ⑥ 把自己的分支推送到远程,让队友能看到
|
||||
git push origin feature/user-profile
|
||||
|
||||
# ⑦ 在 GitHub 上创建 Pull Request(PR),请求合并到 main
|
||||
# (这步在 GitHub 网页上操作)
|
||||
|
||||
# ⑧ 等队友 Code Review,按反馈修改,继续 commit + push
|
||||
|
||||
# ⑨ PR 合并后,回到 main,更新本地,删掉功能分支
|
||||
git checkout main
|
||||
git pull
|
||||
git branch -d feature/user-profile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. .gitignore:哪些文件不应该被追踪?
|
||||
|
||||
有些文件你**不想**提交到 Git 仓库里,比如:
|
||||
- `node_modules/`:依赖包,体积巨大,可以用 `npm install` 重新生成
|
||||
- `.env`:环境变量文件,里面可能有数据库密码、API Key,**绝对不能上传到公开仓库**
|
||||
- `*.log`:日志文件
|
||||
- `.DS_Store`:macOS 自动生成的隐藏文件
|
||||
- `dist/`、`build/`:编译产物,可以重新构建
|
||||
|
||||
在项目根目录创建一个 `.gitignore` 文件,写上不想追踪的文件规则:
|
||||
|
||||
```gitignore
|
||||
# 依赖包
|
||||
node_modules/
|
||||
|
||||
# 环境变量(重要!密码不能提交)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
```
|
||||
|
||||
GitHub 上有各种语言和框架的 .gitignore 模板:[github.com/github/gitignore](https://github.com/github/gitignore)
|
||||
|
||||
---
|
||||
|
||||
## 名词速查表
|
||||
|
||||
| 名词 | 英文 | 解释 |
|
||||
| :--- | :--- | :--- |
|
||||
| **仓库** | Repository (Repo) | 存放项目所有版本历史的数据库,在 `.git` 文件夹里 |
|
||||
| **提交** | Commit | 一次完整的版本记录,像游戏存档点,附有说明和时间戳 |
|
||||
| **分支** | Branch | 独立的开发线,像平行时间线,互不影响 |
|
||||
| **合并** | Merge | 把一个分支的改动整合到另一个分支 |
|
||||
| **冲突** | Conflict | 同一行代码被多人修改,Git 不知道该用哪个,需要手动解决 |
|
||||
| **暂存** | Stage / Index | 把修改放入"准备提交"列表的操作 |
|
||||
| **远程** | Remote | 云端的仓库副本(GitHub / GitLab / Gitee) |
|
||||
| **克隆** | Clone | 把整个远程仓库完整复制到本地 |
|
||||
| **推送** | Push | 把本地提交上传到远程仓库 |
|
||||
| **拉取** | Pull | 把远程最新内容下载并合并到本地 |
|
||||
| **HEAD** | HEAD | 当前所在分支/提交的指针,表示"你现在在哪里" |
|
||||
| **origin** | origin | 远程仓库的默认别名(约定俗成的名字) |
|
||||
| **stash** | Stash | 临时保存还没 commit 的改动,切换任务时用 |
|
||||
| **PR / MR** | Pull Request / Merge Request | 请求把你的分支合并进主分支,通常需要队友 review |
|
||||
|
||||
@@ -1,551 +1,146 @@
|
||||
# 图形与动画(Canvas / SVG / WebGL)
|
||||
# 图形与动画(Canvas 与他的朋友们)
|
||||
|
||||
::: tip 🎯 核心问题
|
||||
**如何在网页上画图、做动画、甚至开发游戏?** Canvas 提供了一个强大的 2D 绘图能力,让你用代码创造视觉内容。
|
||||
|
||||
以前的网页只能展示干巴巴的文字和图片。但如果你想做打砖块游戏、华丽的动态特效、或是可以自由拖拽的数据报表呢?这就是 **Canvas(画布)** 诞生的原因。
|
||||
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要学 Canvas?
|
||||
## 1. 什么是 Canvas?
|
||||
|
||||
### 1.1 Canvas 是什么?
|
||||
如果说早期的那些 HTML 标签(如 `<div>`、`<img>`)是用**乐高积木**拼起一个静态的网页,那么 HTML5 的 `<canvas>` 标签就是扔给你一张**巨大的数字白纸**,然后递给你一支靠代码控制的**画笔**,剩下的全交给你自由发挥。
|
||||
|
||||
**Canvas (画布)** 是 HTML5 提供的一个通过 JavaScript 绘制 2D 图形的元素。
|
||||
这里面的画没有任何标签结构,你用画笔涂上去的心血,一旦落笔就变成了最纯粹的**“像素颜料”**。
|
||||
|
||||
你可以把它想象成一张**数字画布**:
|
||||
### 1.1 Canvas vs SVG:两种不同流派的艺术家
|
||||
|
||||
- 🖌️ 你可以用代码"画笔"在上面作画
|
||||
- 🎨 可以画任何东西: 简单的形状、复杂的图表、流畅的动画
|
||||
- 🎮 甚至可以做成完整的游戏
|
||||
在前端画图界,Canvas 有个宿敌叫 **SVG**。它们代表了两种截然不同的绘画观念:
|
||||
|
||||
::: tip 💡 Canvas vs SVG:有什么区别?
|
||||
**Canvas(位图画板):**
|
||||
* **原理**:就像真实在纸上涂色,几笔画上去就变成一团颜料。
|
||||
* **优势**:电脑只管往屏幕上“洒颜料”,性能起飞!能同时画出大几千个活蹦乱跳的闪烁粒子。
|
||||
* **缺点**:画完就没法单独反悔(没法被 DOM 直接选择),而且你用浏览器一旦放大,画面就会马赛克发虚。
|
||||
|
||||
在 Web 开发中,绘制图形主要有两种方式:
|
||||
**SVG(矢量图拼接):**
|
||||
* **原理**:就像在做幻灯片(PPT)。你画一个圆,它就生成一个圆圈的“实体对象”放在画面上。
|
||||
* **优势**:不管被放大成 100 倍还是 10 万倍,永远极其清晰。而且因为每一个形状都是一个独立标签,你可以在任何时候用鼠标点中某个小正方形,命令它换一种颜色。
|
||||
* **缺点**:如果你试图放几万个对象乱飞,繁重的排版引擎会直接把浏览器卡死。
|
||||
|
||||
| 特性 | Canvas | SVG |
|
||||
| -------- | -------------------- | --------------------- |
|
||||
| **类型** | 位图(光栅图形) | 矢量图形 |
|
||||
| **DOM** | 单个 `<canvas>` 元素 | 每个图形都是 DOM 元素 |
|
||||
| **交互** | 需要手动计算碰撞 | 天然支持事件绑定 |
|
||||
| **性能** | 适合大量对象 | 适合少量复杂对象 |
|
||||
| **缩放** | 放大会失真 | 无限缩放不失真 |
|
||||
| **应用** | 游戏、数据可视化 | 图标、插画 |
|
||||
|
||||
**简单总结**:
|
||||
|
||||
- **Canvas** = 像素画,画完就变成像素,性能好但交互麻烦
|
||||
- **SVG** = 矢量图,每个图形都是对象,交互方便但对象多了会慢
|
||||
:::
|
||||
|
||||
### 1.2 Canvas 的应用场景
|
||||
|
||||
Canvas 的用途非常广泛,你可能每天都在用:
|
||||
|
||||
1. **数据可视化**: ECharts、Chart.js 的图表
|
||||
2. **游戏开发**: 网页游戏(如 Phaser.js 引擎)
|
||||
3. **图像处理**: 图片裁剪、滤镜、拼图(如 Fabric.js)
|
||||
4. **创意效果**: 粒子特效、动画背景
|
||||
5. **工程绘图**: CAD、流程图、思维导图
|
||||
**🎮 简单总结:玩动态游戏、做酷炫粒子特效用 Canvas;画精密的 Logo、写交互清晰的小图表用 SVG。**
|
||||
|
||||
---
|
||||
|
||||
## 2. Canvas 基础
|
||||
## 2. 第一笔:用代码找坐标
|
||||
|
||||
### 2.1 Canvas 元素和上下文
|
||||
### 2.1 这张纸的上下怎么颠倒了?
|
||||
|
||||
使用 Canvas 的第一步是在 HTML 中创建一个 `<canvas>` 元素:
|
||||
当你准备下笔时,得先明白 Canvas 里的尺子是反着的。对于传统的数学课坐标系,中心点零点在中间,越往上越大。
|
||||
|
||||
```html
|
||||
<canvas id="myCanvas" width="600" height="400"></canvas>
|
||||
```
|
||||
但在屏幕显示领域,几乎所有设备的“原点(0,0)”都定在**屏幕的最左上角**。向右走 X 轴变大没问题,但是**向下走,Y 轴变大。**
|
||||
|
||||
然后通过 JavaScript 获取**渲染上下文 (Rendering Context)**:
|
||||
👇 **动手点点看**:
|
||||
拖拽下面的这些点,直观地感受一下坐标是如何变化的:
|
||||
|
||||
```javascript
|
||||
const canvas = document.getElementById('myCanvas')
|
||||
const ctx = canvas.getContext('2d') // 获取 2D 上下文
|
||||
```
|
||||
<CoordinateSystemDemo />
|
||||
|
||||
::: tip 💡 关键概念
|
||||
### 2.2 给你的魔法画笔上调料
|
||||
|
||||
- **canvas** 是 DOM 元素,控制画布的大小和位置
|
||||
- **ctx** 是绘图工具,所有的绘制操作都通过它完成
|
||||
- **`"2d"`** 表示使用 2D 渲染上下文(WebGL 使用 `"webgl"`)
|
||||
:::
|
||||
有了坐标,我们就能召唤那支画笔了(在代码里这支笔叫 `Context` 或简称 `ctx`)。
|
||||
|
||||
### 2.2 坐标系统:Canvas 的"地图规则"
|
||||
就像拿着调色盘作画,流程总是固定的三步:
|
||||
1. **调色**:告诉它你需要什么填充色(`fillStyle`)和描边色(`strokeStyle`)
|
||||
2. **构形**:构思你是画一个圈、还是一条直线?
|
||||
3. **下笔**:实打实地去填充(`fill( )`)还是去勾勒边缘(`stroke( )`)
|
||||
|
||||
Canvas 使用的是**屏幕坐标系**,这与传统数学坐标系有所不同:
|
||||
👇 **动手点点看**:
|
||||
试试把下面代码面板里的形状颜色换换:
|
||||
|
||||
- **原点 (0, 0)**: 在**左上角**(不是中心)
|
||||
- **X 轴**: 向右为正方向
|
||||
- **Y 轴**: **向下**为正方向(注意: 数学坐标系中 Y 轴向上)
|
||||
- **单位**: 像素 (px)
|
||||
|
||||
```javascript
|
||||
// 在左上角绘制一个矩形
|
||||
ctx.fillRect(0, 0, 10, 10)
|
||||
|
||||
// 在右下角绘制一个矩形
|
||||
ctx.fillRect(canvas.width - 10, canvas.height - 10, 10, 10)
|
||||
```
|
||||
|
||||
::: tip 💡 记忆技巧
|
||||
|
||||
想象你在看**屏幕**:
|
||||
|
||||
- 向右移 → X 增加 ✅
|
||||
- 向下移(滚动页面) → Y 增加 ✅
|
||||
- 向左移 → X 减少
|
||||
- 向上移(向上滚动) → Y 减少
|
||||
|
||||
这就是 Canvas 的坐标规则。
|
||||
:::
|
||||
|
||||
### 2.3 绘制基本形状
|
||||
|
||||
Canvas 提供了几种绘制基本形状的方法:
|
||||
|
||||
**矩形**:
|
||||
|
||||
```javascript
|
||||
// 填充矩形
|
||||
ctx.fillStyle = '#3498db'
|
||||
ctx.fillRect(x, y, width, height)
|
||||
|
||||
// 描边矩形
|
||||
ctx.strokeStyle = '#2c3e50'
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(x, y, width, height)
|
||||
|
||||
// 清除矩形区域
|
||||
ctx.clearRect(x, y, width, height)
|
||||
```
|
||||
|
||||
**圆形**:
|
||||
|
||||
```javascript
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, radius, startAngle, endAngle)
|
||||
ctx.fill() // 或 ctx.stroke()
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- **x, y**: 圆心坐标
|
||||
- **radius**: 半径
|
||||
- **startAngle, endAngle**: 起始和结束角度(弧度制)
|
||||
- `0` = 3 点钟方向
|
||||
- `Math.PI / 2` = 6 点钟方向
|
||||
- `Math.PI` = 9 点钟方向
|
||||
|
||||
**线条**:
|
||||
|
||||
```javascript
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x1, y1) // 起点
|
||||
ctx.lineTo(x2, y2) // 终点
|
||||
ctx.stroke()
|
||||
```
|
||||
|
||||
### 2.4 颜色和样式
|
||||
|
||||
Canvas 支持多种颜色设置方式:
|
||||
|
||||
```javascript
|
||||
// 纯色
|
||||
ctx.fillStyle = '#3498db' // 十六进制
|
||||
ctx.fillStyle = 'rgb(52, 152, 219)' // RGB
|
||||
ctx.fillStyle = 'rgba(52, 152, 219, 0.5)' // RGBA(带透明度)
|
||||
|
||||
// 线性渐变
|
||||
const gradient = ctx.createLinearGradient(x1, y1, x2, y2)
|
||||
gradient.addColorStop(0, '#3498db')
|
||||
gradient.addColorStop(1, '#e74c3c')
|
||||
ctx.fillStyle = gradient
|
||||
|
||||
// 径向渐变
|
||||
const radialGradient = ctx.createRadialGradient(x1, y1, r1, x2, y2, r2)
|
||||
radialGradient.addColorStop(0, '#3498db')
|
||||
radialGradient.addColorStop(1, 'transparent')
|
||||
ctx.fillStyle = radialGradient
|
||||
```
|
||||
<CanvasBasicsDemo />
|
||||
|
||||
---
|
||||
|
||||
## 3. 路径:Canvas 的"笔画"
|
||||
## 3. 翻页动画书:如何让画面动起来极度丝滑
|
||||
|
||||
### 3.1 什么是路径?
|
||||
我们刚才说过,Canvas 一旦你填上了颜色,这就变成了永久的马赛克。你怎么可能让马赛克奔跑呢?
|
||||
|
||||
**路径 (Path)** 是 Canvas 中的核心概念。你可以把它想象成用笔画线的过程:
|
||||
**答案是“骗过你的眼睛”。这和翻页手翻书或者电影胶片的原理一模一样。**
|
||||
|
||||
1. **`beginPath()`** - 开始新路径(拿起笔)
|
||||
2. **`moveTo()`** - 移动到起点(不画线)
|
||||
3. **`lineTo()` / `arc()`** - 绘制线条或曲线
|
||||
4. **`closePath()`** - 闭合路径(可选)
|
||||
5. **`fill()` / `stroke()`** - 填充或描边
|
||||
如果你想让一个球飞起来:
|
||||
1. **擦黑板**:用 `clearRect` 把这整块画布上的内容毫不留情地清空!
|
||||
2. **挪位置**:让那个球的 X 坐标往前偷偷加 2 毫米。
|
||||
3. **下笔重画**:把球在新的位置重新画一次。
|
||||
4. **疯狂循环**:浏览器内置了一个极其精准的神仙秒表叫 `requestAnimationFrame`。它会以每秒 60 次(即 60 FPS)的变态速度,重复着【擦除 -> 移动 -> 重绘】。由于人眼自带“视觉残留”,你在屏幕上看到的,不仅不是黑板被擦,反而是如同丝绸般顺滑的动画。
|
||||
|
||||
```javascript
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(100, 100) // 移动到起点
|
||||
ctx.lineTo(200, 100) // 画横线
|
||||
ctx.lineTo(150, 150) // 画斜线
|
||||
ctx.closePath() // 闭合路径(回到起点)
|
||||
ctx.fill() // 填充
|
||||
```
|
||||
👇 **动手点点看**:
|
||||
尝试添加或者减少物体的数量,感受每秒 60 帧带来的无缝快感:
|
||||
|
||||
### 3.2 绘制复杂形状
|
||||
|
||||
通过组合路径,可以绘制任意复杂的形状。
|
||||
|
||||
**三角形**:
|
||||
|
||||
```javascript
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(100, 50)
|
||||
ctx.lineTo(150, 150)
|
||||
ctx.lineTo(50, 150)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = '#e74c3c'
|
||||
ctx.fill()
|
||||
```
|
||||
<AnimationLoopDemo />
|
||||
|
||||
---
|
||||
|
||||
## 4. 动画基础
|
||||
## 4. 瞎子摸象:我在 Canvas 里面怎么点击?
|
||||
|
||||
### 4.1 动画循环
|
||||
因为 Canvas 画布就只是一张没有任何结构的“颜料布”。假设你在这个布上画了一只哥布林:
|
||||
|
||||
在 Canvas 中创建动画,核心是使用 **`requestAnimationFrame`** 方法。
|
||||
如果你想写个代码:“当玩家点中了哥布林,哥布林阵亡”。你根本没法像写普通网页那样通过 `getElementById` 去直接绑定这个外星怪物。因为在浏览器的眼里,**这里永远没有任何怪兽,只有一块宽 600 高 400 的 `<canvas>` 标签死死挡在这里**。
|
||||
|
||||
```javascript
|
||||
function animate() {
|
||||
// 1. 清除画布(或绘制半透明背景产生拖尾效果)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
那我们要怎么做事件交互呢?
|
||||
1. **监听布面被点**:先获取你目前鼠标点在这个死板的 HTML 大布的哪个具体的 XY 位置。
|
||||
2. **拿账本去对**:然后你必须自己翻你的代码记录,“我记得刚刚我在(100,100)的位置画了一个半径 50 的哥布林”。
|
||||
3. **勾股定理**:我们用初中教的勾股定理公式去疯狂计算——当前鼠标点击的位置,是不是落在了那个(100,100)距离 50 半径的圆内?。
|
||||
|
||||
// 2. 更新状态
|
||||
update()
|
||||
恭喜你!这种疯狂算几何数学距离的方法就是你在各大 3A 游戏里听过的 **“碰撞检测 (Collision Detection)”**
|
||||
|
||||
// 3. 绘制
|
||||
draw()
|
||||
👇 **动手点点看**:
|
||||
打开最下面的“Hover 悬停模式”,你就能看到它内部拼命去算距离有多累了。
|
||||
|
||||
// 4. 请求下一帧
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// 启动动画
|
||||
animate()
|
||||
```
|
||||
|
||||
::: tip 💡 为什么用 requestAnimationFrame 而不是 setInterval?
|
||||
|
||||
- ✅ 自动优化,通常为 60FPS(每秒 60 帧)
|
||||
- ✅ 页面不可见时自动暂停,节省资源
|
||||
- ✅ 与浏览器刷新周期同步,避免画面撕裂
|
||||
:::
|
||||
|
||||
### 4.2 动画的本质
|
||||
|
||||
动画的本质是**快速连续绘制静态画面**。每帧需要:
|
||||
|
||||
1. **清除旧画面**: `ctx.clearRect()` 或用半透明背景覆盖
|
||||
2. **更新状态**: 计算新位置、新角度等
|
||||
3. **绘制新画面**: 重新绘制所有对象
|
||||
|
||||
```javascript
|
||||
// 清除画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 半透明背景(产生拖尾效果)
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
```
|
||||
<EventHandlingDemo />
|
||||
|
||||
---
|
||||
|
||||
## 5. 事件处理
|
||||
## 5. 解放算力:粒子系统与视觉魔法
|
||||
|
||||
Canvas 只是一个 DOM 元素,不像 SVG 那样每个图形都是独立的 DOM 元素。因此,我们需要**手动处理交互事件**。
|
||||
到了这一步,当你把【坐标不断重绘的动画】跟【颜色和大小变换】融合,再放进成百上千个小碎片里。这就是引爆视觉的终极杀器:**粒子系统**。
|
||||
|
||||
### 5.1 鼠标事件
|
||||
你只需要建立一个巨大的数组,里面塞满了几百个拥有独立生命值、独立初始随机速度的数字对象。每次“重绘”,让所有的点根据重力或者惯性去减速。你的浏览器里马上就能发生逼真的大爆炸或者漫天飞雪。
|
||||
|
||||
```javascript
|
||||
canvas.addEventListener('click', (e) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
👇 **动手点点看**:
|
||||
试试“烟花”和“鼠标轨迹”!
|
||||
|
||||
console.log(`Clicked at (${x}, ${y})`)
|
||||
})
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
// 检测是否悬停在某个对象上
|
||||
objects.forEach((obj) => {
|
||||
const dist = Math.sqrt((x - obj.x) ** 2 + (y - obj.y) ** 2)
|
||||
if (dist < obj.radius) {
|
||||
canvas.style.cursor = 'pointer'
|
||||
obj.hovered = true
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 5.2 拖拽实现
|
||||
|
||||
```javascript
|
||||
let isDragging = false
|
||||
let selectedObject = null
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
const { x, y } = getMousePos(e)
|
||||
|
||||
objects.forEach((obj) => {
|
||||
const dist = Math.sqrt((x - obj.x) ** 2 + (y - obj.y) ** 2)
|
||||
if (dist < obj.radius) {
|
||||
isDragging = true
|
||||
selectedObject = obj
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (isDragging && selectedObject) {
|
||||
const { x, y } = getMousePos(e)
|
||||
selectedObject.x = x
|
||||
selectedObject.y = y
|
||||
draw() // 重绘
|
||||
}
|
||||
})
|
||||
|
||||
canvas.addEventListener('mouseup', () => {
|
||||
isDragging = false
|
||||
selectedObject = null
|
||||
})
|
||||
```
|
||||
<ParticleSystemDemo />
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能优化
|
||||
## 6. 守护 FPS 荣耀:如何应对高烧的 CPU?
|
||||
|
||||
随着绘制的对象增多,Canvas 性能会下降。以下是一些常用的优化技巧:
|
||||
让成千上万个对象在一秒内计算重画 60 遍,这是极其消耗电脑算力(CPU 和内存)的。
|
||||
很多野生小白刚做出来的游戏玩了两分钟可能风扇就起飞了。下面是真正的引擎大佬使用的降温护体绝技:
|
||||
|
||||
### 6.1 离屏 Canvas (Offscreen Canvas)
|
||||
1. **局部擦黑板(脏矩形 Dirty Rect)!** 一个角色在一望无际的草原上奔跑。你千万别每帧把整块大草原都擦了重画!角色经过哪一小块,你就用小板擦把哪里擦掉然后只补哪里的洞,这能省下几千倍的力气。
|
||||
2. **隐藏后台魔法(离屏 Canvas)!** 如果游戏背景是繁星漫天、有各种复杂绚丽的山脉。最好先偷偷在没人的后台建一个内存 Canvas 把它一次性精美地画上去。以后每秒 60 下的刷新,你直接把这幅“定格全图”通过贴图的方式贴到前端(`drawImage`)就行了。
|
||||
3. **批量洗画笔!** 如果画画时你要反复交替使用“红、蓝、红、蓝、红”这几种笔,频繁切换。可以提前把所有红色的兵全归档画完,再清空换蓝颜料画,省去了昂贵的上下文来回切换。
|
||||
|
||||
预渲染静态内容到离屏 Canvas,减少每帧的绘制操作:
|
||||
👇 **动手点点看**:
|
||||
先把对象数量拉满,看着网页快掉进卡顿的深渊,再依次打开右下方的绝技进行抢救。
|
||||
|
||||
```javascript
|
||||
// 创建离屏 Canvas
|
||||
const offscreenCanvas = document.createElement('canvas')
|
||||
const offscreenCtx = offscreenCanvas.getContext('2d')
|
||||
offscreenCanvas.width = 600
|
||||
offscreenCanvas.height = 400
|
||||
|
||||
// 预渲染背景
|
||||
function drawBackground(ctx) {
|
||||
ctx.fillStyle = '#f0f0f0'
|
||||
ctx.fillRect(0, 0, 600, 400)
|
||||
}
|
||||
drawBackground(offscreenCtx)
|
||||
|
||||
// 主渲染循环
|
||||
function draw() {
|
||||
// 直接复制预渲染的背景
|
||||
ctx.drawImage(offscreenCanvas, 0, 0)
|
||||
|
||||
// 只绘制动态对象
|
||||
objects.forEach((obj) => obj.draw(ctx))
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 减少重绘(脏矩形优化)
|
||||
|
||||
只重绘变化的部分:
|
||||
|
||||
```javascript
|
||||
function draw() {
|
||||
objects.forEach((obj) => {
|
||||
if (obj.moved) {
|
||||
// 清除旧位置
|
||||
ctx.clearRect(
|
||||
obj.oldX - obj.size,
|
||||
obj.oldY - obj.size,
|
||||
obj.size * 2,
|
||||
obj.size * 2
|
||||
)
|
||||
|
||||
// 绘制新位置
|
||||
obj.draw(ctx)
|
||||
|
||||
obj.moved = false
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 批量渲染
|
||||
|
||||
减少状态切换(fillStyle、strokeStyle 等):
|
||||
|
||||
```javascript
|
||||
// 按颜色分组
|
||||
const batches = {}
|
||||
objects.forEach((obj) => {
|
||||
if (!batches[obj.color]) {
|
||||
batches[obj.color] = []
|
||||
}
|
||||
batches[obj.color].push(obj)
|
||||
})
|
||||
|
||||
// 批量绘制相同颜色的对象
|
||||
Object.keys(batches).forEach((color) => {
|
||||
ctx.fillStyle = color // 只设置一次颜色
|
||||
batches[color].forEach((obj) => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
})
|
||||
```
|
||||
<PerformanceDemo />
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见库与框架
|
||||
## 7. 名词对照表
|
||||
|
||||
虽然原生 Canvas 已经很强大,但在实际项目中,使用成熟的库可以大大提高开发效率。
|
||||
|
||||
### 7.1 Fabric.js
|
||||
|
||||
**特点**: 对象模型,支持交互
|
||||
|
||||
```javascript
|
||||
const canvas = new fabric.Canvas('c')
|
||||
|
||||
// 创建圆形
|
||||
const circle = new fabric.Circle({
|
||||
radius: 20,
|
||||
fill: '#3498db',
|
||||
left: 100,
|
||||
top: 100
|
||||
})
|
||||
|
||||
canvas.add(circle)
|
||||
|
||||
// 自动处理事件
|
||||
circle.on('click', () => {
|
||||
circle.set('fill', '#e74c3c')
|
||||
canvas.renderAll()
|
||||
})
|
||||
```
|
||||
|
||||
**适用场景**: 图片编辑器、白板工具、图形设计工具
|
||||
|
||||
### 7.2 PixiJS (WebGL)
|
||||
|
||||
**特点**: WebGL 加速,超高性能
|
||||
|
||||
```javascript
|
||||
const app = new PIXI.Application({
|
||||
width: 600,
|
||||
height: 400,
|
||||
backgroundColor: 0x1099bb
|
||||
})
|
||||
document.body.appendChild(app.view)
|
||||
|
||||
const graphics = new PIXI.Graphics()
|
||||
graphics.beginFill(0x3498db)
|
||||
graphics.drawCircle(300, 200, 50)
|
||||
graphics.endFill()
|
||||
app.stage.addChild(graphics)
|
||||
```
|
||||
|
||||
**适用场景**: 大型游戏、粒子系统、大量对象的场景
|
||||
| 术语 | 解释 |
|
||||
| --- | --- |
|
||||
| **Canvas** | Html5 提供的 2D 画布。绘制极快,但画完就变成颜料像素,不支持通过 DOM 操作内容。 |
|
||||
| **SVG** | 矢量图,放大永远不模糊,且每个图形都是独立的标签元素可以单独点击绑定事件。 |
|
||||
| **Context (ctx)** | 获取到的“2D 上下文”,可以理解为用来在这张布上调各种颜色、干各种特殊效果的“画笔”。 |
|
||||
| **requestAnimationFrame** | 浏览器内置的神级节拍器,会以显示器的刷新率(通常 60FPS)不断狂飙执行,专门用来做完美动画。 |
|
||||
| **FPS / Frame Rate** | 帧率。60 FPS 代表一秒钟内浏览器帮我们默默擦除了 60 次黑板并画了 60 副新图,这骗过了视神经,看起来极其丝滑。 |
|
||||
| **Dirty Rect / 脏矩形** | 只在画面中发生变化的微小矩形区域内进行擦除和重绘,强力保留性能。 |
|
||||
| **Offscreen Canvas** | 藏在内存里的“影子画布”,把静态且复杂的树木和山脉先画好,当作死的一张贴图重复利用。 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 总结与最佳实践
|
||||
|
||||
### 8.1 核心要点回顾
|
||||
|
||||
1. **Canvas 是位图画布**: 绘制后就是像素,无法直接修改已有内容
|
||||
2. **坐标系统**: 原点在左上角,Y 轴向下为正
|
||||
3. **路径系统**: beginPath → moveTo → lineTo → fill/stroke
|
||||
4. **动画原理**: 清除 → 更新 → 绘制 → requestAnimationFrame
|
||||
5. **事件处理**: 需要手动计算碰撞
|
||||
6. **性能优化**: 离屏 Canvas、脏矩形、批量渲染
|
||||
|
||||
### 8.2 最佳实践
|
||||
|
||||
**代码组织**:
|
||||
|
||||
```javascript
|
||||
// 使用类封装对象
|
||||
class GameObject {
|
||||
constructor(x, y) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
}
|
||||
|
||||
update() {
|
||||
// 更新状态
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
// 绘制
|
||||
}
|
||||
|
||||
isHit(x, y) {
|
||||
// 碰撞检测
|
||||
const dist = Math.sqrt((x - this.x) ** 2 + (y - this.y) ** 2)
|
||||
return dist < this.radius
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**性能优化清单**:
|
||||
|
||||
- ✅ 使用 `requestAnimationFrame` 而不是 `setInterval`
|
||||
- ✅ 减少状态切换(按颜色分组绘制)
|
||||
- ✅ 使用离屏 Canvas 预渲染静态内容
|
||||
- ✅ 只重绘变化的部分(脏矩形)
|
||||
- ✅ 限制对象数量,使用对象池
|
||||
- ✅ 避免 `save()` 和 `restore()` 的频繁调用
|
||||
|
||||
---
|
||||
|
||||
## 9. 名词速查表 (Glossary)
|
||||
|
||||
| 名词 | 解释 |
|
||||
| ------------------------- | ----------------------------------------------------------------------- |
|
||||
| **Context / 上下文** | Canvas 的渲染环境,通过 `getContext("2d")` 获取,所有绘制操作都通过它完成 |
|
||||
| **Path / 路径** | 由一系列点连接成的轨迹,是 Canvas 绘图的基础 |
|
||||
| **Stroke / 描边** | 绘制路径的轮廓线 |
|
||||
| **Fill / 填充** | 用颜色填充路径内部 |
|
||||
| **requestAnimationFrame** | 浏览器提供的动画 API,在每次重绘前调用回调函数 |
|
||||
| **Offscreen Canvas** | 离屏 Canvas,用于预渲染静态内容以提高性能 |
|
||||
| **Dirty Rect** | 脏矩形优化,只重绘变化的部分 |
|
||||
| **Collision Detection** | 碰撞检测,判断鼠标或对象是否点击了某个图形 |
|
||||
| **Raster vs Vector** | 位图 vs 矢量图,Canvas 是位图,SVG 是矢量图 |
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
现在你已经掌握了 Canvas 2D 的核心概念:
|
||||
|
||||
- **基本绘图**: 矩形、圆形、线条
|
||||
- **样式控制**: 颜色、渐变、阴影
|
||||
- **动画制作**: requestAnimationFrame + 清除重绘
|
||||
- **交互处理**: 鼠标事件、碰撞检测
|
||||
- **性能优化**: 离屏 Canvas、批量渲染
|
||||
|
||||
**下一步建议**:
|
||||
|
||||
- 如果你想深入学习动画,可以尝试制作一个**贪吃蛇游戏**或**打砖块游戏**
|
||||
- 如果你对数据可视化感兴趣,可以学习 **ECharts** 或 **D3.js**
|
||||
- 如果你想做游戏开发,可以尝试 **Phaser.js** 游戏引擎
|
||||
- 如果你对 WebGL 感兴趣,可以学习 **Three.js** 或 **PixiJS**
|
||||
|
||||
祝你学习愉快! 🎨
|
||||
现在,不管是一把简单的魔法画笔、还是由万千雪花组成的宏大粒子系统,整个能够不断刷新重绘的数字世界引擎,都在你的掌控之中了!
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|------|------|----------|----------|
|
||||
| **腾讯云 CloudBase** | 国内访问速度快,与微信生态深度整合 | 国内用户为主、需要微信小程序支持的项目 | 有免费额度 |
|
||||
| **Vercel** | 前端框架支持好,与 GitHub 集成紧密 | React/Vue/Next.js 等现代前端项目 | 有免费额度 |
|
||||
| **Netlify** | 功能全面,支持表单处理和身份验证,与 Git 集成好 | 需要表单处理、身份验证等高级功能的静态网站 | 有免费额度 |
|
||||
| **Zeabur** | 支持多种语言和服务模板,配置灵活 | 需要部署多种服务(如 Dify、n8n)的复杂项目 | 每月约 5 美元免费额度 |
|
||||
|
||||
---
|
||||
@@ -162,7 +163,126 @@ Vercel 会自动识别项目类型并配置构建命令:
|
||||
|
||||
---
|
||||
|
||||
# 3. Zeabur
|
||||
# 3. Netlify
|
||||
|
||||
Netlify 是另一个非常流行的前端部署平台,与 Vercel 类似,特别适合部署静态网站和单页应用(SPA)。它的特点包括:
|
||||
|
||||
- **功能全面**:除了静态网站托管,还支持表单处理、身份验证、边缘函数等高级功能
|
||||
- **与 Git 深度集成**:支持 GitHub、GitLab、Bitbucket,推送代码自动部署
|
||||
- **分支预览**:每个分支都会自动生成独立的预览链接
|
||||
- **全球 CDN**:网站自动分发到全球节点,访问速度快
|
||||
- **表单处理**:无需后端代码即可处理网站表单提交
|
||||
- **身份验证**:内置用户身份验证功能,可快速实现登录/注册
|
||||
|
||||
> ⚠️ **注意**:Netlify 的国内访问速度可能不如 CloudBase,建议主要面向海外用户的项目使用。
|
||||
|
||||
## 使用 Netlify 部署 Web 应用
|
||||
|
||||
### 步骤 1:注册账号
|
||||
|
||||
访问 [Netlify 官网](https://www.netlify.com),点击 "Sign up" 注册。你可以使用 GitHub、GitLab、Bitbucket 或邮箱注册。
|
||||
|
||||
### 步骤 2:导入项目
|
||||
|
||||
1. 登录后点击 "Add new site" → "Import an existing project"
|
||||
2. 选择你的代码托管平台(如 GitHub)
|
||||
3. 授权 Netlify 访问你的仓库
|
||||
4. 从列表中选择你要部署的仓库
|
||||
|
||||
### 步骤 3:配置构建设置
|
||||
|
||||
Netlify 会自动识别常见的前端框架并配置构建设置:
|
||||
|
||||
| 框架 | 构建命令 | 发布目录 |
|
||||
|------|----------|----------|
|
||||
| React | `npm run build` | `build` |
|
||||
| Vue | `npm run build` | `dist` |
|
||||
| Angular | `ng build` | `dist/<project-name>` |
|
||||
| Next.js | `next build` | `out` |
|
||||
| 纯 HTML | - | `.`(项目根目录) |
|
||||
|
||||
如果自动识别不正确,可以手动配置:
|
||||
- **Build command**: 构建命令,如 `npm run build`
|
||||
- **Publish directory**: 构建输出目录,如 `dist` 或 `build`
|
||||
|
||||
### 步骤 4:部署
|
||||
|
||||
点击 "Deploy site" 按钮,等待构建完成。构建成功后,你会获得一个 `xxx.netlify.app` 的域名,任何人都可以通过这个地址访问你的网站。
|
||||
|
||||
### 步骤 5:配置自定义域名(可选)
|
||||
|
||||
1. 进入站点设置,点击 "Domain management"
|
||||
2. 点击 "Add custom domain"
|
||||
3. 输入你的域名并按照提示配置 DNS 记录
|
||||
4. Netlify 会自动申请并配置 HTTPS 证书
|
||||
|
||||
### 特色功能
|
||||
|
||||
#### 1. 表单处理
|
||||
|
||||
Netlify 提供了一个非常方便的功能:无需后端代码即可处理表单提交。
|
||||
|
||||
只需在 HTML 表单中添加 `netlify` 属性:
|
||||
|
||||
```html
|
||||
<form name="contact" netlify>
|
||||
<p>
|
||||
<label>姓名: <input type="text" name="name" /></label>
|
||||
</p>
|
||||
<p>
|
||||
<label>邮箱: <input type="email" name="email" /></label>
|
||||
</p>
|
||||
<p>
|
||||
<label>留言: <textarea name="message"></textarea></label>
|
||||
</p>
|
||||
<p>
|
||||
<button type="submit">发送</button>
|
||||
</p>
|
||||
</form>
|
||||
```
|
||||
|
||||
部署后,表单提交的数据会自动发送到 Netlify 后台,你可以在 "Forms" 页面查看所有提交记录,也可以设置邮件通知或将数据转发到其他服务。
|
||||
|
||||
#### 2. Netlify Functions(边缘函数)
|
||||
|
||||
Netlify 支持部署无服务器函数(Serverless Functions),让你可以在不搭建完整后端服务器的情况下,实现简单的 API 接口。你可以使用 JavaScript 或 TypeScript 编写函数,部署后会自动获得一个可访问的 URL。
|
||||
|
||||
例如,创建一个 `hello.js` 文件:
|
||||
|
||||
```javascript
|
||||
exports.handler = async (event, context) => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({ message: "Hello from Netlify!" })
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
部署后,你可以通过 `https://你的域名/.netlify/functions/hello` 访问这个函数。
|
||||
|
||||
#### 3. 本地开发支持
|
||||
|
||||
Netlify 提供了 CLI 工具,方便你在本地开发和测试:
|
||||
|
||||
```bash
|
||||
# 安装 Netlify CLI
|
||||
npm install -g netlify-cli
|
||||
|
||||
# 登录账号
|
||||
netlify login
|
||||
|
||||
# 本地启动开发服务器
|
||||
netlify dev
|
||||
|
||||
# 本地测试函数
|
||||
netlify functions:serve
|
||||
```
|
||||
|
||||
使用 CLI 工具可以在本地模拟 Netlify 环境,包括表单提交、函数调用等功能,方便在部署前进行测试。
|
||||
|
||||
---
|
||||
|
||||
# 4. Zeabur
|
||||
|
||||
Zeabur 是一个新兴的部署平台,特别适合需要部署多种服务的复杂项目。它的优势在于:
|
||||
|
||||
@@ -354,15 +474,17 @@ Zeabur 是一个新兴的部署平台,特别适合需要部署多种服务的
|
||||
|
||||
# 总结
|
||||
|
||||
在本教程中,我们介绍了三个常用的 Web 应用部署平台:
|
||||
在本教程中,我们介绍了四个常用的 Web 应用部署平台:
|
||||
|
||||
1. **腾讯云 CloudBase**:适合国内用户,访问速度快,与微信生态整合好
|
||||
2. **Vercel**:适合现代前端框架项目,与 GitHub 集成紧密,全球 CDN 加速
|
||||
3. **Zeabur**:适合复杂项目,服务模板丰富,支持多种部署方式
|
||||
3. **Netlify**:功能全面,支持表单处理和身份验证,适合需要高级功能的静态网站
|
||||
4. **Zeabur**:适合复杂项目,服务模板丰富,支持多种部署方式
|
||||
|
||||
选择哪个平台取决于你的具体需求:
|
||||
- 如果主要面向国内用户,推荐 **CloudBase**
|
||||
- 如果使用 React/Next.js 等框架,推荐 **Vercel**
|
||||
- 如果使用 React/Next.js 等框架,推荐 **Vercel** 或 **Netlify**
|
||||
- 如果需要表单处理、身份验证等高级功能,推荐 **Netlify**
|
||||
- 如果需要部署 Dify、n8n 等服务,推荐 **Zeabur**
|
||||
|
||||
无论选择哪个平台,部署的核心流程都是相似的:准备代码 → 选择平台 → 配置构建设置 → 部署上线。掌握这些技能后,你就可以将自己开发的应用分享给全世界了!
|
||||
|
||||
Reference in New Issue
Block a user