feat(docs): 完善开发工具文档并新增交互式演示组件
主要更新内容: ## 新增交互式演示组件 - SSH 认证流程可视化演示组件 - 正则表达式交互式测试工具 - 环境变量配置可视化演示 - 端口与 localhost 概念可视化解释 - 从晶体管到 CPU 的演进过程动画演示 - 数据编码(二进制/十六进制/base64)可视化演示 ## 文档内容完善 - 补全 SSH 认证与密钥管理完整指南,包含最佳实践 - 完善正则表达式教程,增加常用模式示例 - 完成环境变量与 PATH 配置详解 - 详细解释端口、localhost 和网络基础知识 - 新增 Netlify 部署指南,包含表单处理和函数示例 ## 样式优化 - 移除演示组件中的装饰性图标元素 - 清理未使用的 CSS 样式规则 - 统一组件视觉风格 ## 资源清理 - 删除 chapter4 中未使用的冗余图片文件(57张) ## 其他改进 - 移除文档中的 placeholder 待实现标记 - 增加实用示例和故障排查提示 - 添加 API 密钥和环境变量安全最佳实践 - 优化文档结构和导航体验 涉及文件:189 个文件变更,新增 21989 行,删除 7784 行
This commit is contained in:
@@ -589,7 +589,6 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: '集成开发环境 (IDE) 基础', link: '/zh-cn/appendix/2-development-tools/ide-basics' },
|
||||
{ text: '命令行与 Shell 脚本', link: '/zh-cn/appendix/2-development-tools/command-line-shell' },
|
||||
{ text: '编辑器与 AI 编程助手', link: '/zh-cn/appendix/2-development-tools/editors-and-ai' },
|
||||
{ text: 'Git:代码的时光机', link: '/zh-cn/appendix/2-development-tools/git-version-control' },
|
||||
{ text: '环境变量与 PATH', link: '/zh-cn/appendix/2-development-tools/environment-path' },
|
||||
{ text: '端口与 localhost', link: '/zh-cn/appendix/2-development-tools/ports-localhost' },
|
||||
|
||||
@@ -1,87 +1,128 @@
|
||||
<template>
|
||||
<div class="adder-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">➕</span>
|
||||
<span class="title">加法器:用逻辑门做加法</span>
|
||||
<span class="subtitle">从逻辑门到算术运算</span>
|
||||
<span class="title">加法器:用逻辑门做二进制加法</span>
|
||||
<span class="subtitle">点击蓝色位按钮切换 0/1,观察进位如何逐位传递</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="adder-controls">
|
||||
<div class="input-group">
|
||||
<label>A:</label>
|
||||
<div class="bits">
|
||||
<button
|
||||
v-for="(bit, i) in bitsA"
|
||||
:key="'a'+i"
|
||||
class="bit-btn"
|
||||
:class="{ on: bit }"
|
||||
@click="toggleBit('A', i)"
|
||||
>
|
||||
{{ bit }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="decimal">= {{ decimalA }}</span>
|
||||
<!-- 名词解释 -->
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="dot a" />A = 被加数</span>
|
||||
<span class="legend-item"><span class="dot b" />B = 加数</span>
|
||||
<span class="legend-item"><span class="dot s" />S = 和(Sum,本位结果)</span>
|
||||
<span class="legend-item"><span class="dot c" />C = 进位(Carry,传给下一位)</span>
|
||||
</div>
|
||||
|
||||
<!-- 输入控制 -->
|
||||
<div class="control-panel">
|
||||
<div class="input-group">
|
||||
<span class="group-label">A(被加数)</span>
|
||||
<div class="bits">
|
||||
<button
|
||||
v-for="(bit, i) in bitsA"
|
||||
:key="'a' + i"
|
||||
class="bit-btn"
|
||||
:class="{ on: bit }"
|
||||
@click="toggleBit('A', i)"
|
||||
>
|
||||
{{ bit }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="operator">
|
||||
+
|
||||
<span class="decimal">= {{ decimalA }}</span>
|
||||
</div>
|
||||
<div class="op-sign">+</div>
|
||||
<div class="input-group">
|
||||
<span class="group-label">B(加数)</span>
|
||||
<div class="bits">
|
||||
<button
|
||||
v-for="(bit, i) in bitsB"
|
||||
:key="'b' + i"
|
||||
class="bit-btn"
|
||||
:class="{ on: bit }"
|
||||
@click="toggleBit('B', i)"
|
||||
>
|
||||
{{ bit }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>B:</label>
|
||||
<div class="bits">
|
||||
<button
|
||||
v-for="(bit, i) in bitsB"
|
||||
:key="'b'+i"
|
||||
class="bit-btn"
|
||||
:class="{ on: bit }"
|
||||
@click="toggleBit('B', i)"
|
||||
>
|
||||
{{ bit }}
|
||||
</button>
|
||||
<span class="decimal">= {{ decimalB }}</span>
|
||||
</div>
|
||||
<div class="op-sign">=</div>
|
||||
<div class="result-inline">
|
||||
<span class="result-bin">{{ resultBinary }}</span>
|
||||
<span class="result-dec">(十进制 {{ resultDecimal }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 每位加法器展示 -->
|
||||
<div class="stages-label">逐位计算过程(从最低位开始)</div>
|
||||
<div class="adder-stages">
|
||||
<div
|
||||
v-for="(stage, idx) in stageData"
|
||||
:key="idx"
|
||||
class="stage"
|
||||
>
|
||||
<div class="stage-title">第 {{ stage.bitPos }} 位({{ stage.posName }})</div>
|
||||
|
||||
<div class="stage-content">
|
||||
<!-- 输入列 -->
|
||||
<div class="io-col inputs-col">
|
||||
<div class="io-row">
|
||||
<span class="io-badge a-badge">A</span>
|
||||
<span class="io-val">{{ stage.a }}</span>
|
||||
</div>
|
||||
<div class="io-row">
|
||||
<span class="io-badge b-badge">B</span>
|
||||
<span class="io-val">{{ stage.b }}</span>
|
||||
</div>
|
||||
<div v-if="stage.carryIn !== null" class="io-row carry-in-row">
|
||||
<span class="io-badge cin-badge">Cin</span>
|
||||
<span class="io-val">{{ stage.carryIn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="decimal">= {{ decimalB }}</span>
|
||||
|
||||
<!-- 全加器框 -->
|
||||
<div class="fa-box">
|
||||
<div class="fa-label">{{ stage.carryIn !== null ? '全加器' : '半加器' }}</div>
|
||||
<div class="fa-hint">{{ stage.carryIn !== null ? 'Full Adder' : 'Half Adder' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出列 -->
|
||||
<div class="io-col outputs-col">
|
||||
<div class="io-row">
|
||||
<span class="io-badge s-badge">S</span>
|
||||
<span class="io-val sum-val">{{ stage.sum }}</span>
|
||||
</div>
|
||||
<div class="io-row">
|
||||
<span class="io-badge cout-badge">Cout</span>
|
||||
<span class="io-val carry-val">{{ stage.carryOut }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进位传递提示 -->
|
||||
<div v-if="idx < stageData.length - 1 && stage.carryOut" class="carry-hint">
|
||||
进位 {{ stage.carryOut }} 传给第 {{ stage.bitPos + 1 }} 位 →
|
||||
</div>
|
||||
<div v-else-if="idx < stageData.length - 1" class="carry-hint no-carry">
|
||||
无进位
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adder-visual">
|
||||
<div
|
||||
v-for="(s, i) in stages"
|
||||
:key="i"
|
||||
class="adder-stage"
|
||||
>
|
||||
<div class="stage-label">
|
||||
{{ s.label }}
|
||||
</div>
|
||||
<div class="stage-bits">
|
||||
<span class="bit-label">A{{ 3-i }}: {{ bitsA[i] }}</span>
|
||||
<span class="bit-label">B{{ 3-i }}: {{ bitsB[i] }}</span>
|
||||
<span
|
||||
v-if="i > 0"
|
||||
class="bit-label"
|
||||
>C{{ i }}: {{ carries[i-1] }}</span>
|
||||
</div>
|
||||
<div class="stage-result">
|
||||
<span class="sum-bit">S{{ 3-i }}: {{ sumBits[i] }}</span>
|
||||
<span class="carry-bit">C{{ i+1 }}: {{ carries[i] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 结果 -->
|
||||
<div class="result-bar">
|
||||
<div class="result-row">
|
||||
<span class="result-label">二进制结果</span>
|
||||
<span class="result-bits">{{ resultBinary }}</span>
|
||||
</div>
|
||||
|
||||
<div class="result-display">
|
||||
<div class="result-row">
|
||||
<span class="result-label">二进制结果:</span>
|
||||
<span class="result-bits">{{ resultBinary }}</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">十进制验证:</span>
|
||||
<span class="result-decimal">{{ decimalA }} + {{ decimalB }} = {{ resultDecimal }}</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">十进制验证</span>
|
||||
<span class="result-eq">{{ decimalA }} + {{ decimalB }} = {{ resultDecimal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>加法器用全加器级联实现。每个全加器处理一位,产生"和"与"进位"。进位传递给下一位,就像我们手算加法一样。
|
||||
<strong>核心思想:</strong>每位全加器接收 A、B 和上一位的进位(Cin),输出本位的和(S)与向上传递的进位(Cout)——和我们手算竖式加法"逢二进一"完全一致。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,47 +141,46 @@ const toggleBit = (arr, i) => {
|
||||
}
|
||||
}
|
||||
|
||||
const decimalA = computed(() => {
|
||||
return bitsA.value.reduce((acc, bit, i) => acc + bit * Math.pow(2, 3-i), 0)
|
||||
})
|
||||
const decimalA = computed(() =>
|
||||
bitsA.value.reduce((acc, bit, i) => acc + bit * Math.pow(2, 3 - i), 0)
|
||||
)
|
||||
|
||||
const decimalB = computed(() => {
|
||||
return bitsB.value.reduce((acc, bit, i) => acc + bit * Math.pow(2, 3-i), 0)
|
||||
})
|
||||
const decimalB = computed(() =>
|
||||
bitsB.value.reduce((acc, bit, i) => acc + bit * Math.pow(2, 3 - i), 0)
|
||||
)
|
||||
|
||||
const carries = computed(() => {
|
||||
const c = [0, 0, 0, 0]
|
||||
const stageData = computed(() => {
|
||||
const stages = []
|
||||
let carry = 0
|
||||
const posNames = ['最低位', '次低位', '次高位', '最高位']
|
||||
for (let i = 3; i >= 0; i--) {
|
||||
const sum = bitsA.value[i] + bitsB.value[i] + (i < 3 ? c[i+1] : 0)
|
||||
c[i] = sum >= 2 ? 1 : 0
|
||||
const a = bitsA.value[i]
|
||||
const b = bitsB.value[i]
|
||||
const total = a + b + carry
|
||||
const sum = total % 2
|
||||
const carryOut = total >= 2 ? 1 : 0
|
||||
stages.push({
|
||||
bitPos: 3 - i,
|
||||
posName: posNames[3 - i],
|
||||
a,
|
||||
b,
|
||||
carryIn: stages.length > 0 ? carry : null,
|
||||
sum,
|
||||
carryOut
|
||||
})
|
||||
carry = carryOut
|
||||
}
|
||||
return c
|
||||
return stages
|
||||
})
|
||||
|
||||
const sumBits = computed(() => {
|
||||
const s = [0, 0, 0, 0]
|
||||
for (let i = 3; i >= 0; i--) {
|
||||
const sum = bitsA.value[i] + bitsB.value[i] + (i < 3 ? carries.value[i+1] : 0)
|
||||
s[i] = sum % 2
|
||||
}
|
||||
return s
|
||||
})
|
||||
const sumBits = computed(() => stageData.value.map((s) => s.sum).reverse())
|
||||
|
||||
const resultBinary = computed(() => {
|
||||
const allBits = [carries.value[0], ...sumBits.value]
|
||||
return allBits.join('')
|
||||
const lastCarry = stageData.value[stageData.value.length - 1]?.carryOut || 0
|
||||
return (lastCarry ? lastCarry.toString() : '') + sumBits.value.join('')
|
||||
})
|
||||
|
||||
const resultDecimal = computed(() => {
|
||||
return decimalA.value + decimalB.value
|
||||
})
|
||||
|
||||
const stages = [
|
||||
{ label: '第4位 (个位)' },
|
||||
{ label: '第3位' },
|
||||
{ label: '第2位' },
|
||||
{ label: '第1位 (最高位)' }
|
||||
]
|
||||
const resultDecimal = computed(() => decimalA.value + decimalB.value)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -156,45 +196,93 @@ const stages = [
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.adder-controls {
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 名词解释 */
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.a { background: var(--vp-c-brand); }
|
||||
.dot.b { background: #8b5cf6; }
|
||||
.dot.s { background: var(--vp-c-success, #16a34a); }
|
||||
.dot.c { background: #d97706; }
|
||||
|
||||
/* 控制面板 */
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
.group-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bits {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.bit-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
@@ -205,87 +293,168 @@ const stages = [
|
||||
}
|
||||
|
||||
.decimal {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.operator {
|
||||
font-size: 1.2rem;
|
||||
.op-sign {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.result-bin {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.adder-visual {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
.result-dec {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.adder-stage {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 0.5rem;
|
||||
/* 阶段 */
|
||||
.stages-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.adder-stages {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.stage {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
font-size: 0.75rem;
|
||||
.stage-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stage-bits {
|
||||
.stage-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.io-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 0.22rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bit-label {
|
||||
color: var(--vp-c-text-2);
|
||||
.io-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.stage-result {
|
||||
.io-badge {
|
||||
font-size: 0.62rem;
|
||||
font-weight: bold;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.a-badge { background: var(--vp-c-brand); }
|
||||
.b-badge { background: #8b5cf6; }
|
||||
.cin-badge { background: #d97706; }
|
||||
.s-badge { background: var(--vp-c-success, #16a34a); }
|
||||
.cout-badge { background: #d97706; }
|
||||
|
||||
.io-val {
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sum-val { color: var(--vp-c-success, #16a34a); }
|
||||
.carry-val { color: #d97706; }
|
||||
|
||||
/* 全加器盒子 */
|
||||
.fa-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fa-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sum-bit {
|
||||
color: var(--vp-c-brand);
|
||||
.fa-hint {
|
||||
font-size: 0.6rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.carry-bit {
|
||||
color: var(--vp-c-warning);
|
||||
/* 进位提示 */
|
||||
.carry-hint {
|
||||
font-size: 0.65rem;
|
||||
color: #d97706;
|
||||
text-align: center;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.result-display {
|
||||
.carry-hint.no-carry {
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
/* 结果栏 */
|
||||
.result-bar {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.result-row:last-child {
|
||||
margin-bottom: 0;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
@@ -295,21 +464,36 @@ const stages = [
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.result-decimal {
|
||||
.result-eq {
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-success);
|
||||
color: var(--vp-c-success, #16a34a);
|
||||
}
|
||||
|
||||
/* info box */
|
||||
.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 {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.adder-stages {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.adder-stages {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="algorithm-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🎯</span>
|
||||
<span class="title">算法思维:解决问题的方法</span>
|
||||
<span class="subtitle">不同策略解决不同类型的问题</span>
|
||||
</div>
|
||||
@@ -226,7 +225,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>算法是解决问题的方法。好的算法能让程序效率提升几个数量级。理解算法思维,比记住具体算法更重要。
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,7 +375,6 @@ const complexities = [
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -636,5 +633,4 @@ const complexities = [
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
-3
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="algorithm-overview-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🧮</span>
|
||||
<span class="title">算法思维入门</span>
|
||||
<span class="subtitle">解决问题的一套步骤和方法</span>
|
||||
</div>
|
||||
@@ -119,7 +118,6 @@
|
||||
<div class="tip-desc">不同场景反复应用同一算法</div>
|
||||
</div>
|
||||
<div class="tip-card">
|
||||
<div class="tip-icon">⚡</div>
|
||||
<div class="tip-title">分析优化</div>
|
||||
<div class="tip-desc">思考时间和空间复杂度,寻找优化方案</div>
|
||||
</div>
|
||||
@@ -232,7 +230,6 @@ const currentCategory = computed(() => categories.find(c => c.id === activeCateg
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="algorithm-paradigm-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔧</span>
|
||||
<span class="title">算法设计范式</span>
|
||||
<span class="subtitle">解决问题的常用套路</span>
|
||||
</div>
|
||||
@@ -236,7 +235,6 @@ const currentParadigm = computed(() => paradigms.find(p => p.id === activeParadi
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="application-layer-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🌍</span>
|
||||
<span class="title">应用层:为你服务的各种协议</span>
|
||||
<span class="subtitle">HTTP、DNS、DHCP 等协议如何工作</span>
|
||||
</div>
|
||||
@@ -269,7 +268,6 @@ const currentProtocol = computed(() => protocolDetails[activeProtocol.value])
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="compilation-practice-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">⚙️</span>
|
||||
<span class="title">编译过程实践</span>
|
||||
<span class="subtitle">从代码到可执行文件</span>
|
||||
</div>
|
||||
@@ -148,7 +147,6 @@ const outputFiles = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="compiler-analogy-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🌐</span>
|
||||
<span class="title">编译原理:翻译的艺术</span>
|
||||
<span class="subtitle">如何把代码翻译成机器指令</span>
|
||||
</div>
|
||||
@@ -197,7 +196,6 @@ const tokens = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
+376
-434
File diff suppressed because it is too large
Load Diff
+151
-168
@@ -1,108 +1,86 @@
|
||||
<template>
|
||||
<div class="cpu-arch-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🖥️</span>
|
||||
<span class="title">CPU 架构全貌</span>
|
||||
<span class="subtitle">从门电路到处理器</span>
|
||||
<span class="subtitle">从功能单元到完整核心</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="architecture-layers">
|
||||
<div
|
||||
v-for="(layer, i) in layers"
|
||||
:key="layer.name"
|
||||
class="layer"
|
||||
:class="{ active: activeLayer === i }"
|
||||
@click="activeLayer = activeLayer === i ? null : i"
|
||||
>
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">{{ layer.icon }}</span>
|
||||
<span class="layer-name">{{ layer.name }}</span>
|
||||
<span class="layer-count">{{ layer.count }}</span>
|
||||
</div>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="activeLayer === i"
|
||||
class="layer-detail"
|
||||
>
|
||||
<p class="detail-desc">
|
||||
{{ layer.desc }}
|
||||
</p>
|
||||
<div class="detail-example">
|
||||
<span class="example-label">🌰 例子:</span>
|
||||
<span class="example-content">{{ layer.example }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cpu-components">
|
||||
<div class="comp-title">
|
||||
CPU 核心组件
|
||||
</div>
|
||||
<div class="comp-grid">
|
||||
<div
|
||||
v-for="comp in components"
|
||||
:key="comp.name"
|
||||
class="comp-item"
|
||||
>
|
||||
<div class="architecture-overview">
|
||||
<div class="overview-title">核心组件一览(静态展示)</div>
|
||||
<div class="overview-grid">
|
||||
<div v-for="comp in components" :key="comp.name" class="overview-card">
|
||||
<div class="card-top">
|
||||
<span class="comp-icon">{{ comp.icon }}</span>
|
||||
<span class="comp-name">{{ comp.name }}</span>
|
||||
<span class="comp-desc">{{ comp.desc }}</span>
|
||||
</div>
|
||||
<div class="comp-desc">{{ comp.desc }}</div>
|
||||
<div class="comp-role">{{ comp.role }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instruction-flow">
|
||||
<div class="flow-title">一条指令在 CPU 内部的流动</div>
|
||||
<div class="flow-steps">
|
||||
<div
|
||||
v-for="(step, index) in instructionFlow"
|
||||
:key="step.name"
|
||||
class="flow-step"
|
||||
>
|
||||
<span class="step-index">{{ index + 1 }}</span>
|
||||
<span class="step-name">{{ step.name }}</span>
|
||||
<span class="step-desc">{{ step.desc }}</span>
|
||||
<span
|
||||
v-if="index < instructionFlow.length - 1"
|
||||
class="step-arrow"
|
||||
aria-hidden="true"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>CPU是层次化构建的:晶体管→逻辑门→功能单元→处理器。每一层都是下一层的"积木",最终形成能执行程序的"大脑"。
|
||||
<strong>核心思想:</strong
|
||||
>CPU 不是单一部件,而是多个功能单元的有序协作:控制器负责调度,ALU 负责计算,寄存器负责高速暂存,总线负责连接与传输。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeLayer = ref(null)
|
||||
|
||||
const layers = [
|
||||
const components = [
|
||||
{
|
||||
name: '晶体管',
|
||||
icon: '⚡',
|
||||
count: '数十亿个',
|
||||
desc: '最基本的开关单元,用半导体材料制成。现代CPU包含数十亿个晶体管。',
|
||||
example: 'Apple M2 芯片有约 200 亿个晶体管'
|
||||
icon: '🎮',
|
||||
name: '控制器(CU)',
|
||||
desc: '负责取指、解码和发出控制信号',
|
||||
role: '像指挥员,安排每个模块何时工作'
|
||||
},
|
||||
{
|
||||
name: '逻辑门',
|
||||
icon: '🔌',
|
||||
count: '数亿个',
|
||||
desc: '由多个晶体管组成,实现基本逻辑运算(AND、OR、NOT等)。',
|
||||
example: '一个 AND 门需要 2-6 个晶体管'
|
||||
icon: '📊',
|
||||
name: 'ALU',
|
||||
desc: '执行加减与、或、比较等运算',
|
||||
role: '像计算器,完成核心算术与逻辑处理'
|
||||
},
|
||||
{
|
||||
name: '功能单元',
|
||||
icon: '🔧',
|
||||
count: '数百个',
|
||||
desc: '由逻辑门组成,实现特定功能:加法器、多路选择器、寄存器等。',
|
||||
example: '一个 64 位加法器需要约 1000 个逻辑门'
|
||||
icon: '📁',
|
||||
name: '寄存器组',
|
||||
desc: '保存当前最常用的数据和中间结果',
|
||||
role: '像桌面便签,读写速度远高于内存'
|
||||
},
|
||||
{
|
||||
name: 'CPU 核心',
|
||||
icon: '🧠',
|
||||
count: '1-128个',
|
||||
desc: '包含完整的运算和控制能力,能独立执行指令流。',
|
||||
example: 'Intel i9-13900K 有 24 核心'
|
||||
icon: '🚌',
|
||||
name: '内部总线',
|
||||
desc: '在模块间传输数据、地址和控制信息',
|
||||
role: '像高速通道,把各组件连接成整体'
|
||||
}
|
||||
]
|
||||
|
||||
const components = [
|
||||
{ icon: '📊', name: 'ALU', desc: '算术逻辑单元,做加减乘除和逻辑运算' },
|
||||
{ icon: '📁', name: '寄存器', desc: '超高速存储,存放正在处理的数据' },
|
||||
{ icon: '🎮', name: '控制器', desc: '指挥官,解码指令并协调各部件' },
|
||||
{ icon: '🚌', name: '总线', desc: '数据高速公路,连接各部件' }
|
||||
const instructionFlow = [
|
||||
{ name: '取指', desc: '控制器从缓存/内存取来指令' },
|
||||
{ name: '解码', desc: '识别指令类型与需要的操作数' },
|
||||
{ name: '执行', desc: 'ALU 或其他单元完成具体运算' },
|
||||
{ name: '写回', desc: '结果写入寄存器,供后续指令使用' }
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -111,116 +89,59 @@ const components = [
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
padding: 1.25rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.architecture-layers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.layer {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.layer:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.layer.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.layer-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.8rem;
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.layer-detail {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.overview-title,
|
||||
.flow-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-example {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.example-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cpu-components {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.comp-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.comp-grid {
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.comp-item {
|
||||
.overview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
gap: 0.35rem;
|
||||
padding: 0.7rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.comp-icon {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.comp-name {
|
||||
@@ -229,28 +150,90 @@ const components = [
|
||||
}
|
||||
|
||||
.comp-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.comp-role {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
|
||||
.instruction-flow {
|
||||
margin-top: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
|
||||
.step-index {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand-1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
.step-arrow {
|
||||
margin-left: 0.1rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="data-encoding-basics-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔤</span>
|
||||
<span class="title">数据编码基础</span>
|
||||
<span class="subtitle">信息如何被表示和存储</span>
|
||||
</div>
|
||||
@@ -125,7 +124,6 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="data-lifecycle-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔄</span>
|
||||
<span class="title">数据的生命周期</span>
|
||||
<span class="subtitle">从输入到存储到传输到输出的全过程</span>
|
||||
</div>
|
||||
@@ -185,7 +184,6 @@ const keyPoints = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="data-link-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔗</span>
|
||||
<span class="title">数据链路层:帧的传递</span>
|
||||
<span class="subtitle">MAC 地址如何定位设备</span>
|
||||
</div>
|
||||
@@ -195,7 +194,6 @@ const transferSteps = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="data-structure-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📦</span>
|
||||
<span class="title">数据结构:数据的"容器"</span>
|
||||
<span class="subtitle">不同场景选择不同的存储方式</span>
|
||||
</div>
|
||||
@@ -281,7 +280,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>数据结构是数据的"容器",不同的容器有不同的特点。选择合适的数据结构,能让程序效率提升几个数量级。
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,7 +382,6 @@ const treeData = ref({
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -654,5 +651,4 @@ th {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="ds-overview-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🏗️</span>
|
||||
<span class="title">数据结构全景图</span>
|
||||
<span class="subtitle">不同场景选择不同的数据组织方式</span>
|
||||
</div>
|
||||
@@ -279,7 +278,6 @@ const currentCategory = computed(() => categories.find(c => c.id === activeCateg
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-3
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="ds-selector-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🎯</span>
|
||||
<span class="title">如何选择合适的数据结构?</span>
|
||||
<span class="subtitle">根据场景需求做出最佳选择</span>
|
||||
</div>
|
||||
@@ -25,7 +24,6 @@
|
||||
<!-- 推荐结果 -->
|
||||
<div v-if="activeScenario" class="recommendation">
|
||||
<div class="rec-header">
|
||||
<span class="rec-icon">💡</span>
|
||||
<span class="rec-title">推荐使用:{{ currentScenario.recommendation }}</span>
|
||||
</div>
|
||||
|
||||
@@ -233,7 +231,6 @@ const currentScenario = computed(() => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="encoding-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔢</span>
|
||||
<span class="title">数字编码:用 0 和 1 表示一切</span>
|
||||
<span class="subtitle">字符、数字、图像如何变成二进制</span>
|
||||
</div>
|
||||
@@ -67,7 +66,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>所有数据最终都要变成 0 和 1。不同类型的数据用不同的编码规则:字符用 ASCII/Unicode,数字用二进制,图像用像素值。
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +137,6 @@ const byteCount = computed(() => {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -273,5 +270,4 @@ const byteCount = computed(() => {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="est-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔗</span>
|
||||
<span class="title">编码、存储与传输的协作</span>
|
||||
<span class="subtitle">三大系统如何协同处理数据</span>
|
||||
</div>
|
||||
@@ -348,7 +347,6 @@ const currentScenario = computed(() => scenarioData[activeScenario.value])
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="evolution-flow-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">全景图:从沙子到智能</span>
|
||||
<span class="subtitle">每一层都是对下一层的抽象封装</span>
|
||||
</div>
|
||||
|
||||
<div class="flow-list">
|
||||
<div v-for="(step, index) in steps" :key="index" class="flow-row">
|
||||
<!-- 卡片 -->
|
||||
<div class="step-card">
|
||||
<div class="card-left">
|
||||
<span class="step-icon">{{ step.icon }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">{{ step.title }}</div>
|
||||
<div class="card-desc">{{ step.desc }}</div>
|
||||
</div>
|
||||
<div class="card-right">
|
||||
<span class="card-count">{{ step.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 箭头 -->
|
||||
<div v-if="index < steps.length - 1" class="flow-arrow">
|
||||
<div class="arrow-line" />
|
||||
<div class="arrow-action">{{ step.action }}</div>
|
||||
<div class="arrow-sym">↓</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>计算机的本质是"开关的组合"。通过一层层的抽象封装,最底层的物理材料最终变成了能执行任意逻辑的通用计算平台。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const steps = [
|
||||
{
|
||||
icon: '🏖️',
|
||||
title: '沙子(硅)',
|
||||
desc: '地球上最丰富的元素之一,提炼出高纯度硅',
|
||||
count: '原材料',
|
||||
action: '↓ 提纯 → 切割成晶圆'
|
||||
},
|
||||
{
|
||||
icon: '💿',
|
||||
title: '硅晶圆',
|
||||
desc: '直径 30cm 的单晶硅片,表面极其光滑',
|
||||
count: '基底',
|
||||
action: '↓ 光刻 → 蚀刻 → 掺杂'
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: '晶体管(开关)',
|
||||
desc: 'Gate=1 导通,Gate=0 断开,用电压控制电流',
|
||||
count: '数百亿个 / 芯片',
|
||||
action: '↓ 组合成逻辑电路'
|
||||
},
|
||||
{
|
||||
icon: '🔌',
|
||||
title: '逻辑门',
|
||||
desc: 'AND / OR / NOT / XOR,实现基本布尔运算',
|
||||
count: '数十亿个',
|
||||
action: '↓ 组合成功能模块'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: '功能单元',
|
||||
desc: '加法器、寄存器、多路选择器……各司其职',
|
||||
count: '数百个',
|
||||
action: '↓ 集成为完整处理器'
|
||||
},
|
||||
{
|
||||
icon: '🧠',
|
||||
title: 'CPU 核心',
|
||||
desc: 'ALU + 控制器 + 寄存器组,执行取指→解码→执行→写回',
|
||||
count: '1 ~ 128 核',
|
||||
action: '↓ 软件编程'
|
||||
},
|
||||
{
|
||||
icon: '💻',
|
||||
title: '软件应用',
|
||||
desc: '操作系统 / AI 模型 / 游戏 / 网页……一切皆指令',
|
||||
count: '无限可能',
|
||||
action: ''
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.evolution-flow-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 整体竖向流程 */
|
||||
.flow-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.flow-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.step-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.step-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.card-left {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.card-count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 箭头区域 */
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.2rem 1rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
width: 2px;
|
||||
height: 0.8rem;
|
||||
background: var(--vp-c-divider);
|
||||
margin-left: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arrow-action {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.arrow-sym {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-brand);
|
||||
margin-left: auto;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* info box */
|
||||
.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.8rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="filesystem-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📁</span>
|
||||
<span class="title">文件系统:数据的"档案柜"</span>
|
||||
<span class="subtitle">操作系统如何组织和管理文件</span>
|
||||
</div>
|
||||
@@ -86,7 +85,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>文件系统用"目录树"组织文件,用"inode"记录文件元数据。文件名只是给人看的,系统通过 inode 编号找到真正的数据。
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +135,6 @@ const selectItem = (item) => {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -303,5 +300,4 @@ const selectItem = (item) => {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="graph-structure-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🕸️</span>
|
||||
<span class="title">图结构:复杂关系的表示</span>
|
||||
<span class="subtitle">节点和边的网络</span>
|
||||
</div>
|
||||
@@ -175,7 +174,6 @@ const averageDegree = computed(() => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<div class="greedy-thinking-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🎯</span>
|
||||
<span class="title">贪心算法:每步都选当前最优</span>
|
||||
<span class="subtitle">局部最优 → 全局最优?</span>
|
||||
</div>
|
||||
|
||||
<div class="core-idea">
|
||||
<div class="idea-box">
|
||||
<div class="idea-icon">💡</div>
|
||||
<div class="idea-text">
|
||||
贪心算法在每一步选择中都采取当前状态下<strong>最优</strong>的选择<br>
|
||||
希望通过一系列局部最优选择达到<strong>全局最优</strong>
|
||||
@@ -214,7 +212,6 @@ const selectedCount = computed(() => activities.filter(a => a.selected).length)
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="hash-table-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🗂️</span>
|
||||
<span class="title">哈希表:超快的查找</span>
|
||||
<span class="subtitle">通过关键词直接找到数据</span>
|
||||
</div>
|
||||
@@ -175,7 +174,6 @@ initData()
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-3
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="language-evolution-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📜</span>
|
||||
<span class="title">编程语言的演化</span>
|
||||
<span class="subtitle">从机器语言到高级语言</span>
|
||||
</div>
|
||||
@@ -90,7 +89,6 @@
|
||||
<div class="trend-desc">类型系统、内存管理等安全机制</div>
|
||||
</div>
|
||||
<div class="trend-card">
|
||||
<div class="trend-icon">⚡</div>
|
||||
<div class="trend-title">越来越高效</div>
|
||||
<div class="trend-desc">编译器优化、JIT 技术提升性能</div>
|
||||
</div>
|
||||
@@ -234,7 +232,6 @@ const currentEra = computed(() => eras[activeEra.value])
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
+830
-292
File diff suppressed because it is too large
Load Diff
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="language-scenario-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🎬</span>
|
||||
<span class="title">为什么需要编程语言?</span>
|
||||
<span class="subtitle">从场景看编程语言的价值</span>
|
||||
</div>
|
||||
@@ -231,7 +230,6 @@ const currentScenario = computed(() => scenarios.find(s => s.id === activeScenar
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="language-type-model-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🏗️</span>
|
||||
<span class="title">编程语言的类型模型</span>
|
||||
<span class="subtitle">不同语言的类型系统差异</span>
|
||||
</div>
|
||||
@@ -112,7 +111,6 @@ const dimensions = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="linear-structures-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📚</span>
|
||||
<span class="title">线性结构的四种形态</span>
|
||||
<span class="subtitle">数组、链表、栈、队列的区别</span>
|
||||
</div>
|
||||
@@ -281,7 +280,6 @@ const dequeue = () => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
+205
-369
@@ -1,311 +1,120 @@
|
||||
<template>
|
||||
<div class="logic-gate-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔌</span>
|
||||
<span class="title">逻辑门:用开关做运算</span>
|
||||
<span class="subtitle">晶体管组合成基本运算单元</span>
|
||||
<span class="subtitle">切换输入 A / B,同屏观察四种门的输出</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="gate-tabs">
|
||||
<button
|
||||
v-for="gate in gates"
|
||||
:key="gate.name"
|
||||
:class="['tab-btn', { active: activeGate === gate.name }]"
|
||||
@click="activeGate = gate.name"
|
||||
>
|
||||
{{ gate.name }}
|
||||
<div class="control-panel">
|
||||
<span class="panel-hint">点按钮切换 0 / 1,右侧四个门同步更新:</span>
|
||||
<div class="input-item">
|
||||
<span class="input-label">输入 A</span>
|
||||
<button class="input-btn" :class="{ on: inputA }" @click="inputA = !inputA">
|
||||
{{ inputA ? '1' : '0' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<span class="input-label">输入 B</span>
|
||||
<button class="input-btn" :class="{ on: inputB }" @click="inputB = !inputB">
|
||||
{{ inputB ? '1' : '0' }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="current-state">当前:A={{ inputA ? 1 : 0 }},B={{ inputB ? 1 : 0 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="gate-display">
|
||||
<div class="gate-visual">
|
||||
<div class="inputs">
|
||||
<div class="input-item">
|
||||
<span>A:</span>
|
||||
<button
|
||||
class="input-btn"
|
||||
:class="{ on: inputA }"
|
||||
@click="inputA = !inputA"
|
||||
>
|
||||
{{ inputA ? '1' : '0' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<span>B:</span>
|
||||
<button
|
||||
class="input-btn"
|
||||
:class="{ on: inputB }"
|
||||
@click="inputB = !inputB"
|
||||
>
|
||||
{{ inputB ? '1' : '0' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gate-symbol">
|
||||
<svg
|
||||
viewBox="0 0 120 80"
|
||||
class="gate-svg"
|
||||
>
|
||||
<template v-if="activeGate === 'AND'">
|
||||
<path
|
||||
d="M20,20 L20,60 L60,60 Q90,60 90,40 Q90,20 60,20 Z"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="90"
|
||||
y1="40"
|
||||
x2="110"
|
||||
y2="40"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="30"
|
||||
x2="20"
|
||||
y2="30"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="50"
|
||||
x2="20"
|
||||
y2="50"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="activeGate === 'OR'">
|
||||
<path
|
||||
d="M20,20 Q40,40 20,60 Q60,60 90,40 Q60,20 20,20"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="90"
|
||||
y1="40"
|
||||
x2="110"
|
||||
y2="40"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="30"
|
||||
x2="25"
|
||||
y2="30"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="50"
|
||||
x2="25"
|
||||
y2="50"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="activeGate === 'NOT'">
|
||||
<polygon
|
||||
points="20,20 80,40 20,60"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
cx="85"
|
||||
cy="40"
|
||||
r="5"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="90"
|
||||
y1="40"
|
||||
x2="110"
|
||||
y2="40"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="40"
|
||||
x2="20"
|
||||
y2="40"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="activeGate === 'XOR'">
|
||||
<path
|
||||
d="M25,20 Q45,40 25,60 Q65,60 95,40 Q65,20 25,20"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M15,20 Q35,40 15,60"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="95"
|
||||
y1="40"
|
||||
x2="115"
|
||||
y2="40"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="30"
|
||||
x2="30"
|
||||
y2="30"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="50"
|
||||
x2="30"
|
||||
y2="50"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
<div class="gate-name">
|
||||
{{ activeGate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output">
|
||||
<span class="output-label">输出:</span>
|
||||
<span
|
||||
class="output-value"
|
||||
:class="{ on: output }"
|
||||
>{{ output ? '1' : '0' }}</span>
|
||||
</div>
|
||||
<div class="gate-grid">
|
||||
<div v-for="gate in gates" :key="gate.name" class="gate-card">
|
||||
<div class="gate-top">
|
||||
<span class="gate-name">{{ gate.name }}</span>
|
||||
<span class="gate-formula">{{ gate.formula }}</span>
|
||||
</div>
|
||||
|
||||
<div class="truth-table-mini">
|
||||
<div class="table-title">
|
||||
{{ activeGate }} 真值表
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>A</th>
|
||||
<th>B</th>
|
||||
<th>输出</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in currentTruthTable"
|
||||
:key="row.a + '-' + row.b"
|
||||
:class="{ highlight: row.a === inputA && row.b === inputB && (activeGate !== 'NOT') }"
|
||||
>
|
||||
<td>{{ row.a }}</td>
|
||||
<td>{{ row.b }}</td>
|
||||
<td>{{ row.out }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="gate-analogy">{{ gate.analogy }}</div>
|
||||
<div class="gate-output-row">
|
||||
<span class="output-label">输出</span>
|
||||
<span
|
||||
class="output-value"
|
||||
:class="{ on: gateOutput(gate.name, inputA, inputB) }"
|
||||
>
|
||||
{{ gateOutput(gate.name, inputA, inputB) }}
|
||||
</span>
|
||||
<span class="output-hint">{{ gateOutput(gate.name, inputA, inputB) ? '(真 / 导通)' : '(假 / 断开)' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gate-explanation">
|
||||
<div class="exp-title">
|
||||
{{ currentGate.expTitle }}
|
||||
</div>
|
||||
<div class="exp-content">
|
||||
{{ currentGate.expContent }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="truth-section">
|
||||
<div class="table-title">四种门真值表对照(高亮行 = 当前输入)</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>A</th>
|
||||
<th>B</th>
|
||||
<th>AND</th>
|
||||
<th>OR</th>
|
||||
<th>NOT(A)</th>
|
||||
<th>XOR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in truthRows"
|
||||
:key="`${row.a}-${row.b}`"
|
||||
:class="{
|
||||
highlight:
|
||||
row.a === (inputA ? 1 : 0) && row.b === (inputB ? 1 : 0)
|
||||
}"
|
||||
>
|
||||
<td>{{ row.a }}</td>
|
||||
<td>{{ row.b }}</td>
|
||||
<td>{{ gateOutput('AND', !!row.a, !!row.b) }}</td>
|
||||
<td>{{ gateOutput('OR', !!row.a, !!row.b) }}</td>
|
||||
<td>{{ gateOutput('NOT', !!row.a, !!row.b) }}</td>
|
||||
<td>{{ gateOutput('XOR', !!row.a, !!row.b) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>逻辑门用晶体管的"开关"组合实现基本运算。AND门像"串联开关"(两个都开才通),OR门像"并联开关"(任一个开就通)。
|
||||
<strong>核心思想:</strong>逻辑门用晶体管的"开关"组合实现基本运算——AND 像串联、OR 像并联、NOT 取反、XOR 判异。所有复杂计算都由这四种基础操作构建而来。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeGate = ref('AND')
|
||||
const inputA = ref(false)
|
||||
const inputB = ref(false)
|
||||
|
||||
const gates = [
|
||||
{
|
||||
name: 'AND',
|
||||
expTitle: 'AND门:两个都为1才输出1',
|
||||
expContent: '就像串联的两个开关,必须两个都按下灯才会亮。用于"同时满足多个条件"的场景。'
|
||||
},
|
||||
{
|
||||
name: 'OR',
|
||||
expTitle: 'OR门:任一个为1就输出1',
|
||||
expContent: '就像并联的两个开关,按任意一个灯就会亮。用于"满足任一条件"的场景。'
|
||||
},
|
||||
{
|
||||
name: 'NOT',
|
||||
expTitle: 'NOT门:取反,0变1,1变0',
|
||||
expContent: '就像一个反相器,输入开就输出关,输入关就输出开。用于"否定"的场景。'
|
||||
},
|
||||
{
|
||||
name: 'XOR',
|
||||
expTitle: 'XOR门:相同为0,不同为1',
|
||||
expContent: '就像"异或"判断,两个输入不同才输出1。用于比较两个值是否不同。'
|
||||
}
|
||||
{ name: 'AND', formula: 'A && B', analogy: '串联:都为 1 才输出 1' },
|
||||
{ name: 'OR', formula: 'A || B', analogy: '并联:任一为 1 就输出 1' },
|
||||
{ name: 'NOT', formula: '!A', analogy: '取反:0→1,1→0' },
|
||||
{ name: 'XOR', formula: 'A ⊕ B', analogy: '判异:不同为 1,相同为 0' }
|
||||
]
|
||||
|
||||
const currentGate = computed(() => gates.find(g => g.name === activeGate.value))
|
||||
const truthRows = [
|
||||
{ a: 0, b: 0 },
|
||||
{ a: 0, b: 1 },
|
||||
{ a: 1, b: 0 },
|
||||
{ a: 1, b: 1 }
|
||||
]
|
||||
|
||||
const output = computed(() => {
|
||||
const a = inputA.value
|
||||
const b = inputB.value
|
||||
switch (activeGate.value) {
|
||||
case 'AND': return a && b ? 1 : 0
|
||||
case 'OR': return a || b ? 1 : 0
|
||||
case 'NOT': return a ? 0 : 1
|
||||
case 'XOR': return a !== b ? 1 : 0
|
||||
default: return 0
|
||||
const gateOutput = (name, a, b) => {
|
||||
switch (name) {
|
||||
case 'AND':
|
||||
return a && b ? 1 : 0
|
||||
case 'OR':
|
||||
return a || b ? 1 : 0
|
||||
case 'NOT':
|
||||
return a ? 0 : 1
|
||||
case 'XOR':
|
||||
return a !== b ? 1 : 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
const currentTruthTable = computed(() => {
|
||||
if (activeGate.value === 'NOT') {
|
||||
return [
|
||||
{ a: 0, b: '-', out: 1 },
|
||||
{ a: 1, b: '-', out: 0 }
|
||||
]
|
||||
}
|
||||
const table = []
|
||||
for (let a = 0; a <= 1; a++) {
|
||||
for (let b = 0; b <= 1; b++) {
|
||||
let out = 0
|
||||
switch (activeGate.value) {
|
||||
case 'AND': out = a && b ? 1 : 0; break
|
||||
case 'OR': out = a || b ? 1 : 0; break
|
||||
case 'XOR': out = a !== b ? 1 : 0; break
|
||||
}
|
||||
table.push({ a, b, out })
|
||||
}
|
||||
}
|
||||
return table
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -322,66 +131,66 @@ const currentTruthTable = computed(() => {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.gate-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.gate-display {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gate-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.inputs {
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.current-state {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.input-item {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.input-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
@@ -393,44 +202,76 @@ const currentTruthTable = computed(() => {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.gate-symbol {
|
||||
width: 120px;
|
||||
text-align: center;
|
||||
.gate-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-svg {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
.gate-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.gate-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.output {
|
||||
.gate-formula {
|
||||
font-family: monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.gate-analogy {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gate-output-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.output-label {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.output-hint {
|
||||
font-size: 0.68rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.output-value {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.output-value.on {
|
||||
@@ -439,56 +280,42 @@ const currentTruthTable = computed(() => {
|
||||
border-color: var(--vp-c-success);
|
||||
}
|
||||
|
||||
.truth-table-mini {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
.truth-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.3rem 0.5rem;
|
||||
padding: 0;
|
||||
height: 2rem;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr.highlight {
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.gate-explanation {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.exp-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.exp-content {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
@@ -500,5 +327,14 @@ tr.highlight {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.gate-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="memory-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🧠</span>
|
||||
<span class="title">内存管理:程序的"工作台"</span>
|
||||
<span class="subtitle">操作系统如何分配和管理内存</span>
|
||||
</div>
|
||||
@@ -75,7 +74,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>虚拟内存让每个进程都以为自己独占整个内存空间,实际由操作系统统一管理和映射,实现隔离和保护。
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +134,6 @@ const usedMemory = computed(() => {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -296,5 +293,4 @@ const usedMemory = computed(() => {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="network-layers-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🌐</span>
|
||||
<span class="title">网络五层模型</span>
|
||||
<span class="subtitle">从应用到物理的数据封装过程</span>
|
||||
</div>
|
||||
@@ -93,7 +92,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>分层设计让网络协议模块化,每层只关心自己的职责。数据从应用层向下传递时,每层都会添加自己的"信封"(头部),接收时再逐层拆开。
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +177,6 @@ const encapsulation = [
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -387,8 +384,6 @@ const encapsulation = [
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="network-overview-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🌐</span>
|
||||
<span class="title">网络是怎么连接的</span>
|
||||
<span class="subtitle">从发送到接收的完整过程</span>
|
||||
</div>
|
||||
@@ -202,7 +201,6 @@ const protocolLayers = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="network-principle-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🌐</span>
|
||||
<span class="title">网络基本原理</span>
|
||||
<span class="subtitle">数据如何在网络中传输</span>
|
||||
</div>
|
||||
@@ -107,7 +106,6 @@ const principles = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="os-overview-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🎛️</span>
|
||||
<span class="title">操作系统:计算机的"大管家"</span>
|
||||
<span class="subtitle">让多个程序和谐共处的艺术</span>
|
||||
</div>
|
||||
@@ -162,7 +161,6 @@ const getActiveAppDesc = () => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="physical-layer-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">⚡</span>
|
||||
<span class="title">物理层:电信号的传递</span>
|
||||
<span class="subtitle">比特如何通过物理介质传输</span>
|
||||
</div>
|
||||
@@ -199,7 +198,6 @@ const currentMedia = computed(() => mediaData[activeMedia.value])
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="process-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔄</span>
|
||||
<span class="title">进程:程序的"分身术"</span>
|
||||
<span class="subtitle">一个程序如何同时运行多个实例</span>
|
||||
</div>
|
||||
@@ -82,7 +81,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>进程是程序的"运行实例"。同一个程序可以启动多个进程,每个进程有独立的内存空间,互不干扰。
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,7 +127,6 @@ const memorySegments = [
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -272,5 +269,4 @@ const memorySegments = [
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="pmf-collab-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🤝</span>
|
||||
<span class="title">进程、内存、文件系统的协作</span>
|
||||
<span class="subtitle">三大管理模块如何协同工作</span>
|
||||
</div>
|
||||
@@ -242,7 +241,6 @@ const getIcon = (type) => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="programming-language-comparison-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">⚖️</span>
|
||||
<span class="title">编程语言对比</span>
|
||||
<span class="subtitle">不同语言的特点和适用场景</span>
|
||||
</div>
|
||||
@@ -217,7 +216,6 @@ const currentLang = computed(() => languages.find(l => l.name === activeLang.val
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="programming-paradigm-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🎨</span>
|
||||
<span class="title">编程范式</span>
|
||||
<span class="subtitle">不同的编程思维方式</span>
|
||||
</div>
|
||||
@@ -193,7 +192,6 @@ const currentParadigm = computed(() => paradigms.find(p => p.id === activeParadi
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="recursive-thinking-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔄</span>
|
||||
<span class="title">递归思维:自己调用自己</span>
|
||||
<span class="subtitle">把大问题分解成相同的小问题</span>
|
||||
</div>
|
||||
@@ -190,7 +189,6 @@ const examples = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div class="register-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">寄存器:存储状态的功能单元</span>
|
||||
<span class="subtitle">改变输入不会改变存储值——必须主动"写入"</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="control-left">
|
||||
<span class="ctrl-label">输入值</span>
|
||||
<button
|
||||
class="input-toggle"
|
||||
:class="{ on: inputData === 1 }"
|
||||
@click="inputData = inputData === 1 ? 0 : 1"
|
||||
>
|
||||
{{ inputData }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="write-btn" :class="{ flash: isWriting }" @click="writeOnce">
|
||||
写入寄存器 →
|
||||
</button>
|
||||
<div class="control-right">
|
||||
<span class="chip">存储值:{{ storedData }}</span>
|
||||
<span class="chip" :class="{ chip_on: storedData === 1 }">输出:{{ storedData === 1 ? '1 ✓' : '0' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="flow-diagram">
|
||||
<div class="flow-node input-node">
|
||||
<div class="node-label">输入(Data)</div>
|
||||
<div class="node-value" :class="{ on: inputData === 1 }">{{ inputData }}</div>
|
||||
<div class="node-hint">点左侧按钮切换</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow" :class="{ active: isWriting }">
|
||||
<div class="arrow-line" />
|
||||
<div class="arrow-tag">{{ isWriting ? '写入中...' : '写入触发' }}</div>
|
||||
<div class="arrow-head">→</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-node register-node" :class="{ flashing: isWriting }">
|
||||
<div class="node-label">D 触发器(寄存器核心)</div>
|
||||
<div class="node-value" :class="{ on: storedData === 1 }">{{ storedData }}</div>
|
||||
<div class="node-hint">{{ isWriting ? '正在锁存...' : '保持 (Hold)' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow" :class="{ active: storedData === 1 }">
|
||||
<div class="arrow-line" />
|
||||
<div class="arrow-tag">输出</div>
|
||||
<div class="arrow-head">→</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-node output-node" :class="{ on: storedData === 1 }">
|
||||
<div class="node-label">输出(Output)</div>
|
||||
<div class="bulb">{{ storedData === 1 ? '💡' : '🌑' }}</div>
|
||||
<div class="node-hint">{{ storedData === 1 ? '亮(1)' : '灭(0)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-table">
|
||||
<div class="table-title">操作步骤说明</div>
|
||||
<div class="state-rows">
|
||||
<div class="state-row">
|
||||
<span class="step-num">①</span>
|
||||
<span>点"输入值"按钮切换输入(0/1)</span>
|
||||
</div>
|
||||
<div class="state-row">
|
||||
<span class="step-num">②</span>
|
||||
<span>此时存储值<strong>不变</strong>——这就是寄存器的意义</span>
|
||||
</div>
|
||||
<div class="state-row">
|
||||
<span class="step-num">③</span>
|
||||
<span>点"写入寄存器",输入值才被锁入</span>
|
||||
</div>
|
||||
<div class="state-row">
|
||||
<span class="step-num">④</span>
|
||||
<span>写入后再改输入,存储值依然<strong>保持</strong>不变</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diff-display">
|
||||
<div class="diff-item">
|
||||
<div class="diff-label">当前输入</div>
|
||||
<div class="diff-value" :class="{ on: inputData === 1 }">{{ inputData }}</div>
|
||||
</div>
|
||||
<div class="diff-sep">≠</div>
|
||||
<div class="diff-item">
|
||||
<div class="diff-label">存储值</div>
|
||||
<div class="diff-value" :class="{ on: storedData === 1 }">{{ storedData }}</div>
|
||||
</div>
|
||||
<div v-if="inputData === storedData" class="diff-same">(当前相同)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>寄存器只在"写入"信号触发时更新,其余时刻持续锁定当前值。这就是 CPU 能在计算过程中稳定保存中间结果的原因。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const inputData = ref(0)
|
||||
const storedData = ref(0)
|
||||
const isWriting = ref(false)
|
||||
|
||||
const writeOnce = () => {
|
||||
isWriting.value = true
|
||||
storedData.value = inputData.value
|
||||
window.setTimeout(() => {
|
||||
isWriting.value = false
|
||||
}, 400)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* ---- control panel ---- */
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
margin-bottom: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.input-toggle {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-toggle.on {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.write-btn {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--vp-c-warning);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-warning-1, #d97706);
|
||||
font-size: 0.82rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.write-btn:hover {
|
||||
background: var(--vp-c-warning-soft);
|
||||
}
|
||||
|
||||
.write-btn.flash {
|
||||
background: var(--vp-c-warning);
|
||||
color: white;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.control-right {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.chip_on {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* ---- main content ---- */
|
||||
.demo-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
/* ---- flow diagram ---- */
|
||||
.flow-diagram {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.flow-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-value {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.node-value.on {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.node-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.register-node .node-value {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.5rem;
|
||||
border: 3px solid var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.register-node.flashing .node-value {
|
||||
border-color: var(--vp-c-warning);
|
||||
box-shadow: 0 0 10px var(--vp-c-warning-soft);
|
||||
}
|
||||
|
||||
.bulb {
|
||||
font-size: 1.8rem;
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.4;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.output-node.on .bulb {
|
||||
filter: grayscale(0%);
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 12px #facc15;
|
||||
}
|
||||
|
||||
/* ---- arrows ---- */
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
width: 28px;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.flow-arrow.active .arrow-line {
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.arrow-tag {
|
||||
font-size: 0.65rem;
|
||||
color: var(--vp-c-text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow-head {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
/* ---- state table ---- */
|
||||
.state-table {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.state-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.state-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
flex-shrink: 0;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.diff-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.diff-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.diff-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.diff-value {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.diff-value.on {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.diff-sep {
|
||||
font-size: 1.1rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.diff-same {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
/* ---- info box ---- */
|
||||
.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.8rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="search-algorithm-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔍</span>
|
||||
<span class="title">查找算法</span>
|
||||
<span class="subtitle">如何在数据中找到目标</span>
|
||||
</div>
|
||||
@@ -186,7 +185,6 @@ const resetBinary = () => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="sorting-algorithm-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📊</span>
|
||||
<span class="title">排序算法</span>
|
||||
<span class="subtitle">把数据按顺序排列</span>
|
||||
</div>
|
||||
@@ -197,7 +196,6 @@ const partition = async (arr, low, high) => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="storage-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">💾</span>
|
||||
<span class="title">存储层次:从寄存器到云存储</span>
|
||||
<span class="subtitle">速度与容量的权衡</span>
|
||||
</div>
|
||||
@@ -60,7 +59,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>存储遵循"金字塔"原则:越快的存储越贵、容量越小。CPU 需要的数据放在最快的存储(寄存器、缓存),暂时不用的放在慢速大容量存储(磁盘、云端)。
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +145,6 @@ const currentLevel = computed(() => storageLevels[activeLevel.value])
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -255,5 +252,4 @@ const currentLevel = computed(() => storageLevels[activeLevel.value])
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
-2
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="storage-hierarchy-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">💾</span>
|
||||
<span class="title">存储层次结构</span>
|
||||
<span class="subtitle">从快到慢,从小到大</span>
|
||||
</div>
|
||||
@@ -126,7 +125,6 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="subnet-calculator">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🔢</span>
|
||||
<span class="title">子网计算器</span>
|
||||
<span class="subtitle">理解 IP 地址和子网掩码</span>
|
||||
</div>
|
||||
@@ -127,7 +126,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>子网掩码决定了 IP 地址的哪部分是"网络号"(小区),哪部分是"主机号"(房间)。/24 表示前 24 位是网络位,后 8 位是主机位。
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,7 +261,6 @@ onMounted(() => {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -462,5 +459,4 @@ onMounted(() => {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="tcp-udp-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📦</span>
|
||||
<span class="title">TCP vs UDP:可靠 vs 快速</span>
|
||||
<span class="subtitle">两种不同的传输策略</span>
|
||||
</div>
|
||||
@@ -184,7 +183,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>TCP 像挂号信,确保送达但较慢;UDP 像平信,快速但不保证送达。选择哪种协议取决于应用场景:需要可靠性选 TCP,需要实时性选 UDP。
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,7 +325,6 @@ const runDemo = async () => {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -639,5 +636,4 @@ th {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="feature">
|
||||
<span class="feature-icon">⚡</span>
|
||||
<span>速度极快</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
|
||||
+283
-148
@@ -1,129 +1,89 @@
|
||||
<template>
|
||||
<div class="transistor-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">⚡</span>
|
||||
<span class="title">晶体管:数字世界的开关</span>
|
||||
<span class="subtitle">一个开关如何变成计算能力</span>
|
||||
<span class="subtitle">Gate 电压决定电流能否通过</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="control-left">
|
||||
<span class="control-label">栅极输入(Gate)</span>
|
||||
<button class="gate-toggle" :class="{ on: isOn }" @click="toggleSwitch">
|
||||
{{ isOn ? '1(高电压)' : '0(低电压)' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="control-right">
|
||||
<span class="chip">通道:{{ isOn ? '导通' : '断开' }}</span>
|
||||
<span class="chip">输出:{{ isOn ? '1' : '0' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="switch-container">
|
||||
<div
|
||||
class="switch-area"
|
||||
@click="toggleSwitch"
|
||||
>
|
||||
<div class="transistor-symbol">
|
||||
<svg
|
||||
viewBox="0 0 100 80"
|
||||
class="transistor-svg"
|
||||
>
|
||||
<line
|
||||
x1="10"
|
||||
y1="40"
|
||||
x2="35"
|
||||
y2="40"
|
||||
stroke="var(--vp-c-text-1)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="65"
|
||||
y1="40"
|
||||
x2="90"
|
||||
y2="40"
|
||||
stroke="var(--vp-c-text-1)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="50"
|
||||
y1="20"
|
||||
x2="50"
|
||||
y2="35"
|
||||
stroke="var(--vp-c-text-1)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="50"
|
||||
y1="45"
|
||||
x2="50"
|
||||
y2="60"
|
||||
stroke="var(--vp-c-text-1)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="35"
|
||||
y1="30"
|
||||
x2="35"
|
||||
y2="50"
|
||||
stroke="var(--vp-c-text-1)"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<line
|
||||
x1="65"
|
||||
y1="30"
|
||||
x2="65"
|
||||
y2="50"
|
||||
stroke="var(--vp-c-text-1)"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<line
|
||||
x1="35"
|
||||
y1="40"
|
||||
x2="65"
|
||||
y2="40"
|
||||
stroke="var(--vp-c-text-1)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="60"
|
||||
r="4"
|
||||
fill="var(--vp-c-text-1)"
|
||||
/>
|
||||
</svg>
|
||||
<div class="transistor-diagram">
|
||||
<div class="gate-column">
|
||||
<div class="gate-title">控制端 Gate</div>
|
||||
<div class="gate-value" :class="{ on: isOn }">
|
||||
{{ isOn ? '1' : '0' }}
|
||||
</div>
|
||||
<div class="switch-label">
|
||||
<span class="state-label">{{ isOn ? 'ON (1)' : 'OFF (0)' }}</span>
|
||||
<div
|
||||
class="current-flow"
|
||||
:class="{ active: isOn }"
|
||||
>
|
||||
<span class="flow-indicator">电流</span>
|
||||
</div>
|
||||
<div class="gate-arrow">↓ 控制</div>
|
||||
</div>
|
||||
|
||||
<div class="main-channel">
|
||||
<div class="terminal-box">源极 Source</div>
|
||||
<div class="channel-track" :class="{ on: isOn }">
|
||||
<span v-if="!isOn" class="block-icon">✕</span>
|
||||
<template v-else>
|
||||
<span class="flow-dot d1" />
|
||||
<span class="flow-dot d2" />
|
||||
<span class="flow-dot d3" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="terminal-box">漏极 Drain</div>
|
||||
</div>
|
||||
|
||||
<div class="result-line" :class="{ on: isOn }">
|
||||
{{ isOn ? '电流通过:Source → Drain' : '电流被阻断:无法通过通道' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="truth-table">
|
||||
<div class="table-title">
|
||||
晶体管状态表
|
||||
</div>
|
||||
<div class="table-title">晶体管状态表</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>栅极(控制端)</th>
|
||||
<th>源极→漏极</th>
|
||||
<th>Gate 输入</th>
|
||||
<th>通道状态</th>
|
||||
<th>输出</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :class="{ highlight: !isOn }">
|
||||
<td>低电压 (0)</td>
|
||||
<td>0(低电压)</td>
|
||||
<td>断开</td>
|
||||
<td>0</td>
|
||||
</tr>
|
||||
<tr :class="{ highlight: isOn }">
|
||||
<td>高电压 (1)</td>
|
||||
<td>1(高电压)</td>
|
||||
<td>导通</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-hint">
|
||||
点上方按钮切换 Gate,观察“通道状态”和“电流流动”如何同步变化。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-guide">
|
||||
<div class="step-item">① 改变 Gate 电压(0/1)</div>
|
||||
<div class="step-item">② 通道变为断开/导通</div>
|
||||
<div class="step-item">③ 输出随之变成 0/1</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>晶体管就是一个用电控制的开关。给它高电压(1),它就导通;给低电压(0),它就断开。这是所有数字计算的基础。
|
||||
<strong>核心思想:</strong>晶体管本质是“电控开关”:Gate=1 时导通,Gate=0
|
||||
时断开。所有数字计算都建立在这种 0/1 开关之上。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -151,95 +111,236 @@ const toggleSwitch = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.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-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
margin-bottom: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.switch-container {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.switch-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.switch-area:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.transistor-symbol {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
margin: 0 auto 0.5rem;
|
||||
}
|
||||
|
||||
.transistor-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
.control-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.state-label {
|
||||
.control-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.gate-toggle {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
color: var(--vp-c-brand);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.current-flow {
|
||||
.gate-toggle.on {
|
||||
background: var(--vp-c-success-soft);
|
||||
color: var(--vp-c-success-1);
|
||||
border-color: var(--vp-c-success);
|
||||
color: var(--vp-c-success-1);
|
||||
background: var(--vp-c-success-soft);
|
||||
}
|
||||
|
||||
.control-right {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.chip.active {
|
||||
border-color: var(--vp-c-success);
|
||||
}
|
||||
|
||||
.legend-chip {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.16rem 0.42rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.transistor-diagram {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.gate-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.gate-title {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
opacity: 0.5;
|
||||
transition: all 0.3s;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.current-flow.active {
|
||||
opacity: 1;
|
||||
color: var(--vp-c-success);
|
||||
.gate-value {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gate-value.on {
|
||||
border-color: var(--vp-c-success);
|
||||
color: var(--vp-c-success-1);
|
||||
background: var(--vp-c-success-soft);
|
||||
}
|
||||
|
||||
.gate-arrow {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.main-channel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.6fr 1fr;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.terminal-box {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.45rem;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.channel-track {
|
||||
height: 2.4rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-track.on {
|
||||
background: var(--vp-c-success-soft);
|
||||
border-color: var(--vp-c-success);
|
||||
}
|
||||
|
||||
.block-icon {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.flow-dot {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-success);
|
||||
position: absolute;
|
||||
left: -8%;
|
||||
animation: flow 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.flow-dot.d2 {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
|
||||
.flow-dot.d3 {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
@keyframes flow {
|
||||
from {
|
||||
left: -8%;
|
||||
}
|
||||
to {
|
||||
left: 105%;
|
||||
}
|
||||
}
|
||||
|
||||
.result-line {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.result-line.on {
|
||||
color: var(--vp-c-success-1);
|
||||
}
|
||||
|
||||
.truth-table {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.55rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.5rem;
|
||||
padding: 0.45rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -251,6 +352,27 @@ tr.highlight {
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.table-hint {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.step-guide {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
@@ -262,5 +384,18 @@ tr.highlight {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.step-guide {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="transmission-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📡</span>
|
||||
<span class="title">数据传输:从串行到并行</span>
|
||||
<span class="subtitle">数据如何在不同设备间移动</span>
|
||||
</div>
|
||||
@@ -159,7 +158,6 @@
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>现代高速传输多采用串行方式。虽然并行"看起来"更快(一次传多位),但串行可以跑更高频率,抗干扰更强,实际速度反而更快。
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,7 +207,6 @@ const startTransmission = () => {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -406,5 +403,4 @@ th {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box .icon { flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="transport-layer-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🚚</span>
|
||||
<span class="title">传输层:端到端的可靠传输</span>
|
||||
<span class="subtitle">TCP 和 UDP 如何传输数据</span>
|
||||
</div>
|
||||
@@ -223,7 +222,6 @@ const currentProtocol = computed(() => protocolData[activeProtocol.value])
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="tree-structure-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🌳</span>
|
||||
<span class="title">树形结构:层级关系的表示</span>
|
||||
<span class="subtitle">像家谱一样的组织方式</span>
|
||||
</div>
|
||||
@@ -236,7 +235,6 @@ const binaryTreeLines = [
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.5rem; }
|
||||
.demo-header .title { font-weight: 700; font-size: 1.1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.9rem; }
|
||||
|
||||
|
||||
+680
-381
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">硬编码密钥 vs 用环境变量</span>
|
||||
<span class="subtitle">同样的功能,两种写法,安全性天壤之别</span>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<!-- Bad -->
|
||||
<div class="panel bad">
|
||||
<div class="panel-title">
|
||||
<span class="icon">❌</span> 危险写法:密钥写在代码里
|
||||
</div>
|
||||
<div class="code-area">
|
||||
<div class="code-line comment"># Python</div>
|
||||
<div class="code-line normal">import openai</div>
|
||||
<div class="code-line normal"> </div>
|
||||
<div class="code-line highlight-bad">client = openai.OpenAI(</div>
|
||||
<div class="code-line highlight-bad"> api_key=<span class="key-literal">"sk-proj-abc123..."</span></div>
|
||||
<div class="code-line highlight-bad">)</div>
|
||||
</div>
|
||||
<div class="consequences">
|
||||
<div v-for="c in badConsequences" :key="c" class="consequence bad-item">
|
||||
<span class="ci">💀</span><span>{{ c }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Good -->
|
||||
<div class="panel good">
|
||||
<div class="panel-title">
|
||||
<span class="icon">✅</span> 正确写法:从环境变量读取
|
||||
</div>
|
||||
<div class="code-area">
|
||||
<div class="code-line comment"># Python</div>
|
||||
<div class="code-line normal">import openai, os</div>
|
||||
<div class="code-line normal"> </div>
|
||||
<div class="code-line highlight-good">client = openai.OpenAI(</div>
|
||||
<div class="code-line highlight-good"> api_key=<span class="key-env">os.environ.get(<span class="key-name">"OPENAI_API_KEY"</span>)</span></div>
|
||||
<div class="code-line highlight-good">)</div>
|
||||
</div>
|
||||
<div class="consequences">
|
||||
<div v-for="c in goodConsequences" :key="c" class="consequence good-item">
|
||||
<span class="ci">✅</span><span>{{ c }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>黄金法则:</strong>代码里出现密钥字符串 = 密钥已泄露。GitHub 的 Secret Scanner 会在推送后秒级扫描,发现 <code>sk-</code> 等前缀就通知厂商吊销。即使立刻删除提交,Git 历史里仍然保存着。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const badConsequences = [
|
||||
'git push 后,密钥就公开在 GitHub 上',
|
||||
'爬虫秒级扫描,密钥被盗用并产生费用',
|
||||
'GitHub Secret Scanner 自动吊销密钥',
|
||||
'删除提交也没用,Git 历史仍保留'
|
||||
]
|
||||
|
||||
const goodConsequences = [
|
||||
'代码里没有任何密钥信息,可以安全开源',
|
||||
'不同环境(开发/测试/生产)用不同密钥',
|
||||
'密钥泄露时只需重新生成,不用改代码',
|
||||
'团队成员各用各的密钥,互不影响'
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: bold; color: var(--vp-c-text-1); }
|
||||
.demo-header .subtitle { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 2px solid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel.bad { border-color: #f87171; }
|
||||
.panel.good { border-color: var(--vp-c-green-1); }
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.panel.bad .panel-title { background: color-mix(in srgb, #f87171 15%, var(--vp-c-bg-alt)); color: #ef4444; }
|
||||
.panel.good .panel-title { background: color-mix(in srgb, var(--vp-c-green-1) 12%, var(--vp-c-bg-alt)); color: var(--vp-c-green-1); }
|
||||
|
||||
.code-area {
|
||||
background: #1e1e2e;
|
||||
padding: 0.5rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.77rem;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
padding: 0 0.7rem;
|
||||
white-space: pre;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.code-line.comment { color: #6c7086; font-style: italic; }
|
||||
.code-line.normal { color: #cdd6f4; }
|
||||
.code-line.highlight-bad { background: color-mix(in srgb, #f87171 10%, transparent); color: #cdd6f4; }
|
||||
.code-line.highlight-good { background: color-mix(in srgb, #4ade80 6%, transparent); color: #cdd6f4; }
|
||||
|
||||
.key-literal { color: #f38ba8; }
|
||||
.key-env { color: #a6e3a1; }
|
||||
.key-name { color: #89b4fa; }
|
||||
|
||||
.consequences {
|
||||
padding: 0.55rem 0.65rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.consequence {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bad-item { color: color-mix(in srgb, #f87171 80%, var(--vp-c-text-2)); }
|
||||
.good-item { color: var(--vp-c-text-2); }
|
||||
|
||||
.ci { flex-shrink: 0; font-size: 0.8rem; }
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: color-mix(in srgb, #ef4444 8%, var(--vp-c-bg-alt));
|
||||
border: 1px solid color-mix(in srgb, #ef4444 30%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: #ef4444; }
|
||||
|
||||
.info-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: #ef4444;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,847 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">依赖树 & 版本语义</span>
|
||||
<span class="subtitle">理解语义化版本号与依赖关系图</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="tab-group">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['tab-btn', { active: activeTab === tab.id }]"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: 语义化版本 -->
|
||||
<div v-if="activeTab === 'semver'" class="visualization-area">
|
||||
<div class="semver-display">
|
||||
<div class="version-number">
|
||||
<div
|
||||
v-for="part in versionParts"
|
||||
:key="part.id"
|
||||
:class="['ver-part', { highlight: hoveredPart === part.id }]"
|
||||
@mouseenter="hoveredPart = part.id"
|
||||
@mouseleave="hoveredPart = null"
|
||||
>
|
||||
<div class="ver-num">{{ part.num }}</div>
|
||||
<div class="ver-name" :style="{ color: part.color }">{{ part.label }}</div>
|
||||
</div>
|
||||
<div class="ver-dots">
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="hoveredPart" class="ver-detail" :style="{ borderColor: currentPart.color }">
|
||||
<div class="ver-detail-title" :style="{ color: currentPart.color }">
|
||||
{{ currentPart.label }} 版本
|
||||
</div>
|
||||
<div class="ver-detail-desc">{{ currentPart.desc }}</div>
|
||||
<div class="ver-detail-example">
|
||||
<span class="example-label">示例:</span>
|
||||
<code>{{ currentPart.example }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div v-if="!hoveredPart" class="ver-hint">← 鼠标悬停数字查看含义</div>
|
||||
</div>
|
||||
|
||||
<div class="range-grid">
|
||||
<div class="range-title">常用版本范围符号</div>
|
||||
<div
|
||||
v-for="r in ranges"
|
||||
:key="r.sym"
|
||||
:class="['range-card', { active: activeRange === r.sym }]"
|
||||
@click="activeRange = activeRange === r.sym ? null : r.sym"
|
||||
>
|
||||
<code class="range-sym">{{ r.sym }}</code>
|
||||
<div class="range-name">{{ r.name }}</div>
|
||||
<div class="range-desc">{{ r.desc }}</div>
|
||||
<div v-if="activeRange === r.sym" class="range-example">
|
||||
<div v-for="ex in r.examples" :key="ex.v" class="range-ex-row">
|
||||
<code>{{ ex.v }}</code>
|
||||
<span :class="['ex-status', ex.ok ? 'ok' : 'no']">{{ ex.ok ? '✓ 接受' : '✗ 拒绝' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: 依赖树 -->
|
||||
<div v-if="activeTab === 'tree'" class="visualization-area">
|
||||
<div class="scenario-select">
|
||||
<button
|
||||
v-for="sc in scenarios"
|
||||
:key="sc.id"
|
||||
:class="['scenario-btn', { active: activeScenario === sc.id }]"
|
||||
@click="activeScenario = sc.id"
|
||||
>
|
||||
{{ sc.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tree-container">
|
||||
<div class="tree-root-node node">
|
||||
<span class="node-name">{{ currentScenario.root }}</span>
|
||||
<span class="node-badge root-badge">你的项目</span>
|
||||
</div>
|
||||
|
||||
<div class="tree-level">
|
||||
<div
|
||||
v-for="dep in currentScenario.direct"
|
||||
:key="dep.name"
|
||||
:class="['tree-branch', dep.conflict ? 'conflict' : '']"
|
||||
>
|
||||
<div class="branch-line"></div>
|
||||
<div class="node dep-node">
|
||||
<span class="node-name">{{ dep.name }}</span>
|
||||
<span class="node-ver">{{ dep.version }}</span>
|
||||
<span v-if="dep.conflict" class="conflict-badge">⚠ 冲突</span>
|
||||
</div>
|
||||
<div v-if="dep.children && dep.children.length" class="sub-level">
|
||||
<div
|
||||
v-for="child in dep.children"
|
||||
:key="child.name + dep.name"
|
||||
:class="['sub-branch', child.conflict ? 'conflict' : '']"
|
||||
>
|
||||
<div class="sub-line"></div>
|
||||
<div class="node sub-node">
|
||||
<span class="node-name">{{ child.name }}</span>
|
||||
<span class="node-ver">{{ child.version }}</span>
|
||||
<span v-if="child.conflict" class="conflict-badge small">⚠</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-desc" :class="currentScenario.type">
|
||||
<div class="desc-icon">{{ currentScenario.icon }}</div>
|
||||
<div class="desc-text">{{ currentScenario.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: 锁文件 -->
|
||||
<div v-if="activeTab === 'lockfile'" class="visualization-area">
|
||||
<div class="lockfile-compare">
|
||||
<div class="lf-col">
|
||||
<div class="lf-title">📄 package.json(声明意图)</div>
|
||||
<div class="lf-content">
|
||||
<pre class="code-block">{{ packageJsonExample }}</pre>
|
||||
</div>
|
||||
<div class="lf-note">用范围符号声明「可以接受哪些版本」</div>
|
||||
</div>
|
||||
<div class="lf-arrow">→</div>
|
||||
<div class="lf-col">
|
||||
<div class="lf-title">🔒 package-lock.json(固定现实)</div>
|
||||
<div class="lf-content">
|
||||
<pre class="code-block">{{ lockfileExample }}</pre>
|
||||
</div>
|
||||
<div class="lf-note">锁定实际安装的精确版本,团队共享</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lockfile-rules">
|
||||
<div
|
||||
v-for="rule in lockfileRules"
|
||||
:key="rule.title"
|
||||
class="rule-card"
|
||||
>
|
||||
<div class="rule-icon">{{ rule.icon }}</div>
|
||||
<div class="rule-body">
|
||||
<div class="rule-title">{{ rule.title }}</div>
|
||||
<div class="rule-desc">{{ rule.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>黄金法则:</strong>
|
||||
<span v-if="activeTab === 'semver'">语义化版本 = MAJOR.MINOR.PATCH,MAJOR 变说明有破坏性改动,升级需谨慎。</span>
|
||||
<span v-else-if="activeTab === 'tree'">依赖的依赖也是依赖,一个包可以间接引入几十个包,这就是"依赖树"。</span>
|
||||
<span v-else>把锁文件提交到 Git,保证团队每个人、每次 CI 安装的包版本完全一致。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeTab = ref('semver')
|
||||
const hoveredPart = ref(null)
|
||||
const activeRange = ref(null)
|
||||
const activeScenario = ref('normal')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'semver', label: '语义化版本' },
|
||||
{ id: 'tree', label: '依赖树' },
|
||||
{ id: 'lockfile', label: '锁文件' }
|
||||
]
|
||||
|
||||
const versionParts = [
|
||||
{
|
||||
id: 'major',
|
||||
num: '2',
|
||||
label: 'MAJOR',
|
||||
color: '#ef4444',
|
||||
desc: '主版本号。有破坏性 API 变更时递增,通常不向后兼容。升级前必须看 CHANGELOG。',
|
||||
example: 'React 16 → 17 → 18,每次都有较大改动'
|
||||
},
|
||||
{
|
||||
id: 'minor',
|
||||
num: '8',
|
||||
label: 'MINOR',
|
||||
color: '#f59e0b',
|
||||
desc: '次版本号。新增功能但向后兼容时递增,可以放心升级。',
|
||||
example: 'axios 1.5.0 → 1.6.0,新增了功能但不影响老用法'
|
||||
},
|
||||
{
|
||||
id: 'patch',
|
||||
num: '3',
|
||||
label: 'PATCH',
|
||||
color: '#22c55e',
|
||||
desc: '补丁版本号。只修复 bug,完全向后兼容,建议及时升级。',
|
||||
example: 'lodash 4.17.20 → 4.17.21,修复安全漏洞'
|
||||
}
|
||||
]
|
||||
|
||||
const currentPart = computed(
|
||||
() => versionParts.find(p => p.id === hoveredPart.value) || versionParts[0]
|
||||
)
|
||||
|
||||
const ranges = [
|
||||
{
|
||||
sym: '^2.8.3',
|
||||
name: '兼容范围(推荐)',
|
||||
desc: '允许 MINOR 和 PATCH 升级,锁定 MAJOR',
|
||||
examples: [
|
||||
{ v: '2.8.3', ok: true }, { v: '2.9.0', ok: true },
|
||||
{ v: '3.0.0', ok: false }, { v: '2.8.2', ok: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
sym: '~2.8.3',
|
||||
name: '近似范围(保守)',
|
||||
desc: '只允许 PATCH 升级,锁定 MAJOR 和 MINOR',
|
||||
examples: [
|
||||
{ v: '2.8.3', ok: true }, { v: '2.8.9', ok: true },
|
||||
{ v: '2.9.0', ok: false }, { v: '3.0.0', ok: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
sym: '2.8.3',
|
||||
name: '精确版本(严格)',
|
||||
desc: '只接受这一个版本,完全锁定',
|
||||
examples: [
|
||||
{ v: '2.8.3', ok: true }, { v: '2.8.4', ok: false },
|
||||
{ v: '2.9.0', ok: false }, { v: '2.8.2', ok: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
sym: '*',
|
||||
name: '任意版本(危险)',
|
||||
desc: '接受任何版本,包括主版本升级,生产环境禁止',
|
||||
examples: [
|
||||
{ v: '1.0.0', ok: true }, { v: '2.8.3', ok: true },
|
||||
{ v: '99.0.0', ok: true }, { v: '0.0.1', ok: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'normal', label: '正常依赖' },
|
||||
{ id: 'shared', label: '共享依赖' },
|
||||
{ id: 'conflict', label: '版本冲突' }
|
||||
]
|
||||
|
||||
const allScenarios = {
|
||||
normal: {
|
||||
root: 'my-app',
|
||||
type: 'success',
|
||||
icon: '✅',
|
||||
description: '正常情况:直接依赖 axios 和 lodash,它们各自有少量子依赖,无冲突。',
|
||||
direct: [
|
||||
{
|
||||
name: 'axios',
|
||||
version: '^1.6.8',
|
||||
children: [
|
||||
{ name: 'follow-redirects', version: '^1.15.6' },
|
||||
{ name: 'form-data', version: '^4.0.0' }
|
||||
]
|
||||
},
|
||||
{ name: 'lodash', version: '^4.17.21', children: [] }
|
||||
]
|
||||
},
|
||||
shared: {
|
||||
root: 'my-app',
|
||||
type: 'info',
|
||||
icon: '📌',
|
||||
description: '共享依赖:react-dom 和 react-router 都依赖同一个 react,npm 会自动复用,不重复安装。',
|
||||
direct: [
|
||||
{
|
||||
name: 'react-dom',
|
||||
version: '^18.2.0',
|
||||
children: [{ name: 'react', version: '^18.2.0' }]
|
||||
},
|
||||
{
|
||||
name: 'react-router',
|
||||
version: '^6.22.0',
|
||||
children: [{ name: 'react', version: '^18.2.0' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
conflict: {
|
||||
root: 'my-app',
|
||||
type: 'warning',
|
||||
icon: '⚠️',
|
||||
description: '版本冲突:pkg-a 需要 lodash@^3.0.0,pkg-b 需要 lodash@^4.0.0,MAJOR 不同无法共享,npm 会安装两份,导致包体积膨胀。',
|
||||
direct: [
|
||||
{
|
||||
name: 'pkg-a',
|
||||
version: '^1.0.0',
|
||||
children: [{ name: 'lodash', version: '^3.10.1', conflict: true }]
|
||||
},
|
||||
{
|
||||
name: 'pkg-b',
|
||||
version: '^2.0.0',
|
||||
children: [{ name: 'lodash', version: '^4.17.21', conflict: true }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentScenario = computed(() => allScenarios[activeScenario.value])
|
||||
|
||||
const packageJsonExample = `{
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"lodash": "^4.17.0"
|
||||
}
|
||||
}`
|
||||
|
||||
const lockfileExample = `{
|
||||
"node_modules/axios": {
|
||||
"version": "1.6.8",
|
||||
"resolved": "https://registry.npmjs.org/...",
|
||||
"integrity": "sha512-..."
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/..."
|
||||
}
|
||||
}`
|
||||
|
||||
const lockfileRules = [
|
||||
{ icon: '📌', title: '必须提交到 Git', desc: '锁文件是团队契约,让所有成员、CI/CD 安装完全相同的版本。' },
|
||||
{ icon: '🚫', title: '不要手动编辑', desc: '锁文件由包管理器自动维护,手动修改极易引入错误。' },
|
||||
{ icon: '🔄', title: 'npm install 会更新它', desc: '每次 install/update 后,锁文件会自动更新到最新解析结果。' },
|
||||
{ icon: '🧪', title: 'npm ci 严格遵守它', desc: 'CI 环境用 npm ci 而非 npm install,保证精确复现锁文件记录的版本。' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1.5rem 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.1rem 0.7rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.tab-group {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.83rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
/* === Semver Tab === */
|
||||
.semver-display {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ver-dots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-3);
|
||||
gap: 1.5rem;
|
||||
padding: 0 0.1rem;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ver-part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
|
||||
.ver-part.highlight {
|
||||
border-color: currentColor;
|
||||
background: var(--vp-c-bg-soft);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.ver-num {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ver-name {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ver-detail {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1.5px solid;
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.ver-detail-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.ver-detail-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.ver-detail-example {
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.example-label {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.ver-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.range-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.range-title {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.range-card {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.range-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.range-card.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.range-sym {
|
||||
font-size: 0.95rem;
|
||||
color: var(--vp-c-brand);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.range-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.range-desc {
|
||||
font-size: 0.74rem;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.range-example {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px dashed var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.range-ex-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.ex-status.ok { color: #22c55e; }
|
||||
.ex-status.no { color: #ef4444; }
|
||||
|
||||
/* === Tree Tab === */
|
||||
.scenario-select {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.scenario-btn {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.scenario-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
padding: 0.8rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tree-root-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.node-ver {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.node-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.root-badge {
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.conflict-badge {
|
||||
font-size: 0.7rem;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.conflict-badge.small {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.tree-level {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-left: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tree-branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tree-branch.conflict .dep-node {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.branch-line {
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: var(--vp-c-divider);
|
||||
margin-left: 0.8rem;
|
||||
}
|
||||
|
||||
.sub-level {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.sub-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.sub-branch.conflict .sub-node {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.sub-line {
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.sub-node {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.scenario-desc.success { background: color-mix(in srgb, #22c55e 10%, var(--vp-c-bg)); border: 1px solid color-mix(in srgb, #22c55e 30%, transparent); }
|
||||
.scenario-desc.info { background: color-mix(in srgb, var(--vp-c-brand) 10%, var(--vp-c-bg)); border: 1px solid color-mix(in srgb, var(--vp-c-brand) 30%, transparent); }
|
||||
.scenario-desc.warning { background: color-mix(in srgb, #f59e0b 10%, var(--vp-c-bg)); border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent); }
|
||||
|
||||
.desc-icon { font-size: 1rem; flex-shrink: 0; }
|
||||
.desc-text { color: var(--vp-c-text-2); }
|
||||
|
||||
/* === Lockfile Tab === */
|
||||
.lockfile-compare {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.lf-col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lf-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.lf-content {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
white-space: pre;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.lf-note {
|
||||
font-size: 0.73rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.lf-arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-brand);
|
||||
padding-top: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lockfile-compare { flex-direction: column; }
|
||||
.lf-arrow { transform: rotate(90deg); padding-top: 0; align-self: center; }
|
||||
}
|
||||
|
||||
.lockfile-rules {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.rule-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rule-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.rule-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* === Info Box === */
|
||||
.info-box {
|
||||
display: block;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">.env 文件 + 代码读取</span>
|
||||
<span class="subtitle">左边写配置,右边读取——两者之间只有变量名这一条线</span>
|
||||
</div>
|
||||
|
||||
<div class="lang-tabs">
|
||||
<button
|
||||
v-for="lang in langs"
|
||||
:key="lang.id"
|
||||
class="lang-tab"
|
||||
:class="{ active: currentLang === lang.id }"
|
||||
@click="currentLang = lang.id"
|
||||
>
|
||||
{{ lang.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<!-- Left: .env file -->
|
||||
<div class="file-panel">
|
||||
<div class="file-title">
|
||||
<span class="file-icon">📄</span> .env
|
||||
<span class="file-badge no-commit">不提交 Git</span>
|
||||
</div>
|
||||
<div class="code-area">
|
||||
<div v-for="(line, i) in envLines" :key="i" class="code-line" :class="line.type">
|
||||
<span
|
||||
v-if="line.key"
|
||||
class="env-key"
|
||||
:class="{ active: hoveredKey === line.key }"
|
||||
@mouseenter="hoveredKey = line.key"
|
||||
@mouseleave="hoveredKey = null"
|
||||
>{{ line.key }}</span>
|
||||
<span v-if="line.key" class="env-eq">=</span>
|
||||
<span v-if="line.key" class="env-val">{{ line.value }}</span>
|
||||
<span v-else class="env-comment">{{ line.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-title example">
|
||||
<span class="file-icon">📋</span> .env.example
|
||||
<span class="file-badge can-commit">可以提交 Git</span>
|
||||
</div>
|
||||
<div class="code-area dim">
|
||||
<div v-for="(line, i) in exampleLines" :key="i" class="code-line" :class="line.type">
|
||||
<span v-if="line.key" class="env-key">{{ line.key }}</span>
|
||||
<span v-if="line.key" class="env-eq">=</span>
|
||||
<span v-if="line.key" class="env-val empty">(值留空)</span>
|
||||
<span v-else class="env-comment">{{ line.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: code -->
|
||||
<div class="code-panel">
|
||||
<div class="file-title">
|
||||
<span class="file-icon">💻</span> {{ currentLangObj.filename }}
|
||||
</div>
|
||||
<div class="code-area">
|
||||
<div v-for="(line, i) in currentLangObj.lines" :key="i" class="code-line" :class="line.type">
|
||||
<span class="line-content" v-html="line.text" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-result">
|
||||
<div class="result-title">程序实际读到的值</div>
|
||||
<div v-for="kv in readResults" :key="kv.key" class="result-row">
|
||||
<span
|
||||
class="result-key"
|
||||
:class="{ active: hoveredKey === kv.key }"
|
||||
@mouseenter="hoveredKey = kv.key"
|
||||
@mouseleave="hoveredKey = null"
|
||||
>{{ kv.key }}</span>
|
||||
<span class="result-arrow">→</span>
|
||||
<span class="result-val">{{ kv.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>工作流程:</strong><code>load_dotenv()</code> / <code>import 'dotenv/config'</code> 在启动时读取 <code>.env</code> 文件,把里面的键值注入到进程环境变量中,代码里再用 <code>os.environ</code> 或 <code>process.env</code> 读取,两端只靠变量名连接。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const hoveredKey = ref(null)
|
||||
const currentLang = ref('python')
|
||||
|
||||
const langs = [
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'node', label: 'Node.js' }
|
||||
]
|
||||
|
||||
const envLines = [
|
||||
{ type: 'comment', text: '# 本地开发配置,不提交到 Git' },
|
||||
{ key: 'OPENAI_API_KEY', value: 'sk-proj-abc123...' },
|
||||
{ key: 'DATABASE_URL', value: 'postgresql://localhost/dev' },
|
||||
{ key: 'PORT', value: '3000' },
|
||||
{ key: 'NODE_ENV', value: 'development' }
|
||||
]
|
||||
|
||||
const exampleLines = [
|
||||
{ type: 'comment', text: '# 复制为 .env,填入真实值' },
|
||||
{ key: 'OPENAI_API_KEY', value: '' },
|
||||
{ key: 'DATABASE_URL', value: '' },
|
||||
{ key: 'PORT', value: '' },
|
||||
{ key: 'NODE_ENV', value: '' }
|
||||
]
|
||||
|
||||
const readResults = [
|
||||
{ key: 'OPENAI_API_KEY', value: 'sk-proj-abc123...' },
|
||||
{ key: 'DATABASE_URL', value: 'postgresql://localhost/dev' },
|
||||
{ key: 'PORT', value: '3000' }
|
||||
]
|
||||
|
||||
const pythonLines = [
|
||||
{ type: 'comment', text: '# pip install python-dotenv openai' },
|
||||
{ type: 'normal', text: 'from dotenv import load_dotenv' },
|
||||
{ type: 'normal', text: 'import os, openai' },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'highlight', text: 'load_dotenv() <span class="comment-inline"># 读取 .env 文件</span>' },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'normal', text: 'client = openai.OpenAI(' },
|
||||
{ type: 'highlight', text: ' api_key=os.environ.get(<span class="key-ref">"OPENAI_API_KEY"</span>)' },
|
||||
{ type: 'normal', text: ')' },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'normal', text: 'db = os.environ.get(<span class="key-ref">"DATABASE_URL"</span>)' },
|
||||
{ type: 'normal', text: 'port = int(os.environ.get(<span class="key-ref">"PORT"</span>, 8000))' }
|
||||
]
|
||||
|
||||
const nodeLines = [
|
||||
{ type: 'comment', text: '# npm install dotenv openai' },
|
||||
{ type: 'highlight', text: "import 'dotenv/config' <span class=\"comment-inline\">// 读取 .env 文件</span>" },
|
||||
{ type: 'normal', text: "import OpenAI from 'openai'" },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'normal', text: 'const client = new OpenAI({' },
|
||||
{ type: 'highlight', text: ' apiKey: process.env.<span class="key-ref">OPENAI_API_KEY</span>' },
|
||||
{ type: 'normal', text: '})' },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'normal', text: 'const db = process.env.<span class="key-ref">DATABASE_URL</span>' },
|
||||
{ type: 'normal', text: 'const port = process.env.<span class="key-ref">PORT</span> ?? 8000' }
|
||||
]
|
||||
|
||||
const currentLangObj = computed(() => {
|
||||
if (currentLang.value === 'python') {
|
||||
return { filename: 'main.py', lines: pythonLines }
|
||||
}
|
||||
return { filename: 'index.js', lines: nodeLines }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: bold; color: var(--vp-c-text-1); }
|
||||
.demo-header .subtitle { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.lang-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.lang-tab {
|
||||
padding: 0.25rem 0.7rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.lang-tab.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.file-panel, .code-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.file-title.example {
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.file-icon { flex-shrink: 0; }
|
||||
|
||||
.file-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.file-badge.no-commit { background: color-mix(in srgb, #f87171 15%, transparent); color: #ef4444; }
|
||||
.file-badge.can-commit { background: color-mix(in srgb, var(--vp-c-green-1) 15%, transparent); color: var(--vp-c-green-1); }
|
||||
|
||||
.code-area {
|
||||
background: #1e1e2e;
|
||||
padding: 0.45rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.77rem;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-area.dim { background: #16131e; opacity: 0.75; }
|
||||
|
||||
.code-line {
|
||||
padding: 0 0.65rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.code-line.highlight { background: color-mix(in srgb, var(--vp-c-brand) 8%, transparent); }
|
||||
.code-line.comment .env-comment { color: #6c7086; font-style: italic; }
|
||||
|
||||
.env-key {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.env-key.active { background: color-mix(in srgb, var(--vp-c-brand) 25%, transparent); }
|
||||
.env-eq { color: #45475a; margin: 0 1px; }
|
||||
.env-val { color: #a6e3a1; }
|
||||
.env-val.empty { color: #45475a; font-style: italic; }
|
||||
.env-comment { color: #6c7086; font-style: italic; }
|
||||
|
||||
.line-content { color: #cdd6f4; white-space: pre; }
|
||||
.code-line.comment .line-content { color: #6c7086; font-style: italic; }
|
||||
.code-line.highlight .line-content { color: #cdd6f4; }
|
||||
|
||||
:deep(.key-ref) { color: var(--vp-c-brand); font-weight: bold; }
|
||||
:deep(.comment-inline) { color: #6c7086; font-style: italic; }
|
||||
|
||||
.read-result {
|
||||
background: #11111b;
|
||||
border-top: 1px solid #313244;
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 0.68rem;
|
||||
color: #6c7086;
|
||||
margin-bottom: 0.3rem;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.result-key {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.result-key.active { background: color-mix(in srgb, var(--vp-c-brand) 25%, transparent); }
|
||||
.result-arrow { color: #45475a; }
|
||||
.result-val { color: #a6e3a1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: var(--vp-c-text-1); }
|
||||
|
||||
.info-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">export 决定子进程能不能"看见"变量</span>
|
||||
<span class="subtitle">切换开关,观察子进程是否能读到父进程设置的变量</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-label">使用 <code>export</code></span>
|
||||
<button class="toggle-btn" :class="{ on: useExport }" @click="useExport = !useExport">
|
||||
<span class="thumb" />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<!-- Parent shell -->
|
||||
<div class="shell-box parent">
|
||||
<div class="shell-title">父进程(Shell)</div>
|
||||
<div class="shell-body">
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd" :class="{ exported: useExport }">
|
||||
<span v-if="useExport">export </span>MY_VAR="hello"
|
||||
</span>
|
||||
</div>
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">echo $MY_VAR</span>
|
||||
</div>
|
||||
<div class="output">hello</div>
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">bash -c 'echo $MY_VAR'</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="arrow-col">
|
||||
<div class="arrow-label">启动子进程</div>
|
||||
<div class="arrow-icon">→</div>
|
||||
<div class="inherit-tag" :class="useExport ? 'yes' : 'no'">
|
||||
{{ useExport ? '变量已继承' : '变量未继承' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Child shell -->
|
||||
<div class="shell-box child" :class="{ has: useExport, missing: !useExport }">
|
||||
<div class="shell-title">子进程(bash -c ...)</div>
|
||||
<div class="shell-body">
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">echo $MY_VAR</span>
|
||||
</div>
|
||||
<div v-if="useExport" class="output success">hello</div>
|
||||
<div v-else class="output empty">(空,什么都没有)</div>
|
||||
<div class="cmd-line muted">
|
||||
<span class="prompt">#</span>
|
||||
<span class="cmd muted-text">子进程无法修改父进程的变量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>{{ useExport ? '有 export:' : '没有 export:' }}</strong>
|
||||
{{ useExport
|
||||
? '变量被标记为"可导出",子进程启动时自动继承一份副本。'
|
||||
: '变量只存在于当前 Shell,子进程读到的是空字符串。' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const useExport = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: bold; color: var(--vp-c-text-1); }
|
||||
.demo-header .subtitle { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.toggle-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-label code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-btn.on {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-text-2);
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.toggle-btn.on .thumb {
|
||||
left: 22px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* ── Two column layout ── */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.arrow-col { flex-direction: row; justify-content: center; }
|
||||
}
|
||||
|
||||
.shell-box {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1e1e2e;
|
||||
transition: border-color 0.3s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell-box.has { border-color: var(--vp-c-green-1); }
|
||||
.shell-box.missing { border-color: color-mix(in srgb, #f87171 60%, transparent); }
|
||||
|
||||
.shell-title {
|
||||
background: #181825;
|
||||
padding: 0.28rem 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.shell-body {
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.cmd-line { display: flex; gap: 0.4rem; align-items: baseline; }
|
||||
|
||||
.prompt { color: #6c7086; flex-shrink: 0; }
|
||||
.cmd { color: #cdd6f4; word-break: break-all; }
|
||||
.cmd.exported { color: #a6e3a1; }
|
||||
|
||||
.muted .prompt { color: #45475a; }
|
||||
.muted-text { color: #45475a; font-style: italic; font-size: 0.72rem; }
|
||||
|
||||
.output {
|
||||
padding-left: 1rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.output.success { color: #a6e3a1; }
|
||||
.output.empty { color: #585b70; font-style: italic; }
|
||||
|
||||
.arrow-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.arrow-label { font-size: 0.7rem; color: var(--vp-c-text-3); white-space: nowrap; }
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 1.4rem;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.inherit-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.inherit-tag.yes { background: color-mix(in srgb, var(--vp-c-green-1) 15%, transparent); color: var(--vp-c-green-1); border: 1px solid var(--vp-c-green-1); }
|
||||
.inherit-tag.no { background: color-mix(in srgb, #f87171 12%, transparent); color: #f87171; border: 1px solid #f87171; }
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: var(--vp-c-text-1); }
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">环境变量的三个层级</span>
|
||||
<span class="subtitle">变量从外到内单向传递,子进程继承父进程的副本</span>
|
||||
</div>
|
||||
|
||||
<div class="scope-stack">
|
||||
<div class="scope-layer system">
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">🖥️</span>
|
||||
<div>
|
||||
<div class="layer-title">系统级 <code>/etc/environment</code></div>
|
||||
<div class="layer-desc">所有用户、所有进程都能看到,由管理员配置</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="var-list">
|
||||
<div v-for="v in systemVars" :key="v.key" class="var-chip system-chip">
|
||||
<span class="chip-key">{{ v.key }}</span><span class="chip-eq">=</span><span class="chip-val">{{ v.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-row">
|
||||
<span class="arrow-line" />
|
||||
<span class="arrow-label">▼ 子进程继承父进程环境</span>
|
||||
<span class="arrow-line" />
|
||||
</div>
|
||||
|
||||
<div class="scope-layer user">
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">👤</span>
|
||||
<div>
|
||||
<div class="layer-title">用户级 <code>~/.zshrc</code></div>
|
||||
<div class="layer-desc">只影响当前用户,登录 Shell 启动时自动加载</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="var-list">
|
||||
<div v-for="v in userVars" :key="v.key" class="var-chip user-chip">
|
||||
<span class="chip-key">{{ v.key }}</span><span class="chip-eq">=</span><span class="chip-val">{{ v.value }}</span>
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<input v-model="newKey" class="var-input" placeholder="KEY" maxlength="18" @keyup.enter="addVar" />
|
||||
<span class="eq-sign">=</span>
|
||||
<input v-model="newVal" class="var-input" placeholder="value" maxlength="24" @keyup.enter="addVar" />
|
||||
<button class="add-btn" :disabled="!newKey || !newVal" @click="addVar">export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-row">
|
||||
<span class="arrow-line" />
|
||||
<span class="arrow-label">▼ 启动子进程(如 node app.js)</span>
|
||||
<span class="arrow-line" />
|
||||
</div>
|
||||
|
||||
<div class="scope-layer process">
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">⚙️</span>
|
||||
<div>
|
||||
<div class="layer-title">进程级(当前运行的程序)</div>
|
||||
<div class="layer-desc">继承所有上层变量,退出后消失,修改不影响父进程</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="var-list">
|
||||
<div v-for="v in processVars" :key="v.key" class="var-chip process-chip" :class="{ 'is-new': v.isNew }">
|
||||
<span class="chip-key">{{ v.key }}</span><span class="chip-eq">=</span><span class="chip-val">{{ v.value }}</span>
|
||||
<span v-if="v.isNew" class="new-badge">你加的</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>单向传递:</strong>变量只能向下继承,子进程修改变量值不会影响父进程。关闭终端后,直接 <code>export</code> 的变量也会消失。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const systemVars = [
|
||||
{ key: 'PATH', value: '/usr/local/bin:/usr/bin:/bin' },
|
||||
{ key: 'LANG', value: 'zh_CN.UTF-8' },
|
||||
{ key: 'TZ', value: 'Asia/Shanghai' }
|
||||
]
|
||||
|
||||
const baseUserVars = [
|
||||
{ key: 'HOME', value: '/Users/alice' },
|
||||
{ key: 'SHELL', value: '/bin/zsh' },
|
||||
{ key: 'NVM_DIR', value: '$HOME/.nvm' }
|
||||
]
|
||||
|
||||
const extraVars = ref([])
|
||||
const newKey = ref('')
|
||||
const newVal = ref('')
|
||||
|
||||
const userVars = computed(() => [...baseUserVars, ...extraVars.value])
|
||||
|
||||
const processVars = computed(() => [
|
||||
...systemVars,
|
||||
...userVars.value.map((v) => ({ ...v })),
|
||||
{ key: 'NODE_ENV', value: 'development' },
|
||||
{ key: 'PORT', value: '3000' }
|
||||
])
|
||||
|
||||
const addVar = () => {
|
||||
if (!newKey.value || !newVal.value) return
|
||||
const key = newKey.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_')
|
||||
if (extraVars.value.some((v) => v.key === key)) return
|
||||
extraVars.value.push({ key, value: newVal.value, isNew: true })
|
||||
newKey.value = ''
|
||||
newVal.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.scope-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scope-layer {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.scope-layer.system {
|
||||
border-color: var(--vp-c-yellow-1, #f59e0b);
|
||||
background: color-mix(in srgb, var(--vp-c-yellow-1, #f59e0b) 5%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.scope-layer.user {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: color-mix(in srgb, var(--vp-c-brand) 5%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.scope-layer.process {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: color-mix(in srgb, var(--vp-c-green-1) 5%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.layer-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.layer-title code {
|
||||
font-size: 0.78rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.layer-desc {
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.var-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.var-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.18rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.system-chip { border-color: var(--vp-c-yellow-1, #f59e0b); background: color-mix(in srgb, var(--vp-c-yellow-1, #f59e0b) 12%, var(--vp-c-bg)); }
|
||||
.user-chip { border-color: var(--vp-c-brand); background: color-mix(in srgb, var(--vp-c-brand) 10%, var(--vp-c-bg)); }
|
||||
.process-chip { border-color: var(--vp-c-green-1); background: color-mix(in srgb, var(--vp-c-green-1) 10%, var(--vp-c-bg)); }
|
||||
.process-chip.is-new { border-style: dashed; }
|
||||
|
||||
.chip-key { font-weight: bold; color: var(--vp-c-brand); }
|
||||
.chip-eq { color: var(--vp-c-text-3); margin: 0 1px; }
|
||||
.chip-val { color: var(--vp-c-text-2); max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.new-badge {
|
||||
margin-left: 0.35rem;
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
font-size: 0.62rem;
|
||||
padding: 0 0.28rem;
|
||||
border-radius: 3px;
|
||||
font-family: var(--vp-font-family-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.var-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.22rem 0.4rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.76rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.var-input:focus { border-color: var(--vp-c-brand); }
|
||||
|
||||
.eq-sign { color: var(--vp-c-text-3); font-family: var(--vp-font-family-mono); }
|
||||
|
||||
.add-btn {
|
||||
padding: 0.22rem 0.6rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.76rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.arrow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.arrow-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: var(--vp-c-text-1); }
|
||||
|
||||
.info-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">环境变量浏览器</span>
|
||||
<span class="subtitle">点击任意变量行,在终端中查看它的值和作用</span>
|
||||
</div>
|
||||
|
||||
<div class="content-layout">
|
||||
<div class="env-table">
|
||||
<div class="table-header">
|
||||
<span>变量名</span>
|
||||
<span>示例值</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in envVars"
|
||||
:key="item.key"
|
||||
class="env-row"
|
||||
:class="{ selected: selected?.key === item.key }"
|
||||
@click="echoVar(item)"
|
||||
>
|
||||
<span class="env-key">{{ item.key }}</span>
|
||||
<span class="env-value">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-panel">
|
||||
<div class="term-titlebar">
|
||||
<span class="dot red" />
|
||||
<span class="dot yellow" />
|
||||
<span class="dot green" />
|
||||
<span class="term-name">bash</span>
|
||||
</div>
|
||||
<div ref="termBody" class="term-body">
|
||||
<div
|
||||
v-for="line in termLines"
|
||||
:key="line.id"
|
||||
:class="['term-line', `line-${line.type}`]"
|
||||
>
|
||||
{{ line.text }}
|
||||
</div>
|
||||
<div class="term-prompt">$ <span class="cursor">█</span></div>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="term-desc">
|
||||
<div class="desc-title">{{ selected.key }}</div>
|
||||
<div class="desc-body">{{ selected.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心概念:</strong>环境变量是每个进程持有的一组「键=值」配置。程序启动时自动从父进程继承一份,可随时通过
|
||||
<code>echo $变量名</code> 查看,用 <code>export KEY=value</code> 设置。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
let lineId = 0
|
||||
const termLines = ref([{ id: lineId++, type: 'hint', text: '← 点击左侧任意变量行来查看它' }])
|
||||
const selected = ref(null)
|
||||
const termBody = ref(null)
|
||||
|
||||
const envVars = [
|
||||
{
|
||||
key: 'HOME',
|
||||
value: '/Users/alice',
|
||||
desc: '当前用户的主目录路径。cd ~ 本质上就是跳到 $HOME。很多程序把配置文件存在这里。'
|
||||
},
|
||||
{
|
||||
key: 'USER',
|
||||
value: 'alice',
|
||||
desc: '当前登录的用户名。服务器程序常用它做权限判断或日志记录。'
|
||||
},
|
||||
{
|
||||
key: 'SHELL',
|
||||
value: '/bin/zsh',
|
||||
desc: '当前使用的 Shell 程序路径。决定了你输入命令后由哪个程序来解释执行。'
|
||||
},
|
||||
{
|
||||
key: 'PATH',
|
||||
value: '/usr/local/bin:/usr/bin:/bin',
|
||||
desc: '最重要的环境变量!Shell 查找可执行文件时,依次在这些目录里搜索,用冒号分隔。见下方演示。'
|
||||
},
|
||||
{
|
||||
key: 'PWD',
|
||||
value: '/Users/alice/projects',
|
||||
desc: '当前工作目录(Print Working Directory)。就是你现在"站在"的那个目录。'
|
||||
},
|
||||
{
|
||||
key: 'LANG',
|
||||
value: 'zh_CN.UTF-8',
|
||||
desc: '系统语言和字符编码。影响程序的错误提示语言、日期格式、排序规则等。'
|
||||
},
|
||||
{
|
||||
key: 'NODE_ENV',
|
||||
value: 'development',
|
||||
desc: '开发者自定义变量。告诉 Node.js 应用当前是开发(development)还是生产(production)环境,影响日志、错误显示等行为。'
|
||||
},
|
||||
{
|
||||
key: 'OPENAI_API_KEY',
|
||||
value: 'sk-••••••••••••••••',
|
||||
desc: '开发者自定义变量,存储 API 密钥。把密钥放在环境变量里(而非写死在代码里)是重要的安全最佳实践。'
|
||||
}
|
||||
]
|
||||
|
||||
const echoVar = (item) => {
|
||||
selected.value = item
|
||||
termLines.value.push(
|
||||
{ id: lineId++, type: 'cmd', text: `$ echo $${item.key}` },
|
||||
{ id: lineId++, type: 'output', text: item.value }
|
||||
)
|
||||
nextTick(() => {
|
||||
if (termBody.value) {
|
||||
termBody.value.scrollTop = termBody.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.content-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.env-table {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.env-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
transition: background 0.15s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.env-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.env-row:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.env-row.selected {
|
||||
background: color-mix(in srgb, var(--vp-c-brand) 12%, transparent);
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.env-key {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.env-value {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.76rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
.term-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.red {
|
||||
background: #ff5f57;
|
||||
}
|
||||
.dot.yellow {
|
||||
background: #febc2e;
|
||||
}
|
||||
.dot.green {
|
||||
background: #28c840;
|
||||
}
|
||||
|
||||
.term-name {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.term-body {
|
||||
padding: 0.6rem 0.75rem;
|
||||
min-height: 150px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.7;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.term-line {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.line-hint {
|
||||
color: #585b70;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.line-cmd {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.line-output {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.term-prompt {
|
||||
color: #585b70;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
animation: blink 1s step-end infinite;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.term-desc {
|
||||
border-top: 1px solid #313244;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #11111b;
|
||||
}
|
||||
|
||||
.desc-title {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.desc-body {
|
||||
font-size: 0.75rem;
|
||||
color: #7f849c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">npm install 全过程模拟</span>
|
||||
<span class="subtitle">观察一个包从命令行到磁盘的完整安装旅程</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="input-row">
|
||||
<span class="pm-label">$ npm install</span>
|
||||
<select v-model="selectedPkg" class="pkg-select" :disabled="installing">
|
||||
<option v-for="p in packages" :key="p.name" :value="p.name">{{ p.name }}</option>
|
||||
</select>
|
||||
<button class="install-btn" :disabled="installing" @click="runInstall">
|
||||
{{ installing ? '安装中…' : '运行' }}
|
||||
</button>
|
||||
<button class="reset-btn" :disabled="installing" @click="resetAll">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="two-col">
|
||||
<!-- 左侧:安装日志 -->
|
||||
<div class="log-panel">
|
||||
<div class="panel-title">📟 安装日志</div>
|
||||
<div ref="logRef" class="log-body">
|
||||
<div
|
||||
v-for="(line, i) in logs"
|
||||
:key="i"
|
||||
:class="['log-line', `log-${line.type}`]"
|
||||
>
|
||||
<span class="log-time">{{ line.time }}</span>
|
||||
<span class="log-text">{{ line.text }}</span>
|
||||
</div>
|
||||
<div v-if="!logs.length" class="log-empty">等待运行…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:文件结构 + package.json -->
|
||||
<div class="right-panel">
|
||||
<div class="panel-title">📁 文件结构变化</div>
|
||||
<div class="file-tree">
|
||||
<div class="tree-line">my-project/</div>
|
||||
<div class="tree-line">├── package.json</div>
|
||||
<div :class="['tree-line', { highlight: showLock }]">
|
||||
{{ showLock ? '├── package-lock.json ✨' : '├── package-lock.json' }}
|
||||
</div>
|
||||
<div class="tree-line">└── node_modules/</div>
|
||||
<template v-for="dep in installedDeps" :key="dep.name">
|
||||
<div class="tree-line dep-line animate-in">
|
||||
{{ dep.isLast ? '└──' : '├──' }} {{ dep.name }}/ <span class="dep-ver">{{ dep.version }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="panel-title" style="margin-top: 0.8rem;">📄 package.json</div>
|
||||
<div class="json-view">
|
||||
<pre class="json-pre">{{ packageJsonStr }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阶段进度条 -->
|
||||
<div class="phases">
|
||||
<div
|
||||
v-for="ph in phases"
|
||||
:key="ph.id"
|
||||
:class="['phase-item', ph.status]"
|
||||
>
|
||||
<div class="phase-dot"></div>
|
||||
<div class="phase-info">
|
||||
<div class="phase-name">{{ ph.name }}</div>
|
||||
<div class="phase-desc">{{ ph.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心机制:</strong>安装时先解析依赖树 → 去注册表下载 → 解压到 node_modules → 写入锁文件,锁文件确保团队所有人安装完全一致的版本。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const packages = [
|
||||
{
|
||||
name: 'axios',
|
||||
version: '1.6.8',
|
||||
deps: [
|
||||
{ name: 'follow-redirects', version: '1.15.6' },
|
||||
{ name: 'form-data', version: '4.0.0' },
|
||||
{ name: 'proxy-from-env', version: '1.1.0' }
|
||||
],
|
||||
type: 'dependencies'
|
||||
},
|
||||
{
|
||||
name: 'lodash',
|
||||
version: '4.17.21',
|
||||
deps: [],
|
||||
type: 'dependencies'
|
||||
},
|
||||
{
|
||||
name: 'typescript',
|
||||
version: '5.4.5',
|
||||
deps: [],
|
||||
type: 'devDependencies'
|
||||
},
|
||||
{
|
||||
name: 'vue',
|
||||
version: '3.4.21',
|
||||
deps: [
|
||||
{ name: '@vue/compiler-core', version: '3.4.21' },
|
||||
{ name: '@vue/reactivity', version: '3.4.21' },
|
||||
{ name: '@vue/runtime-dom', version: '3.4.21' }
|
||||
],
|
||||
type: 'dependencies'
|
||||
}
|
||||
]
|
||||
|
||||
const selectedPkg = ref('axios')
|
||||
const installing = ref(false)
|
||||
const logs = ref([])
|
||||
const installedDeps = ref([])
|
||||
const showLock = ref(false)
|
||||
const logRef = ref(null)
|
||||
|
||||
const phases = ref([
|
||||
{ id: 'resolve', name: '依赖解析', desc: '分析所有需要的包', status: 'pending' },
|
||||
{ id: 'fetch', name: '下载 & 解压', desc: '从 registry 拉取 tarball', status: 'pending' },
|
||||
{ id: 'link', name: '链接模块', desc: '写入 node_modules/', status: 'pending' },
|
||||
{ id: 'lockfile', name: '写锁文件', desc: '固化精确版本', status: 'pending' }
|
||||
])
|
||||
|
||||
const baseJson = {
|
||||
name: 'my-project',
|
||||
version: '1.0.0',
|
||||
dependencies: {},
|
||||
devDependencies: {}
|
||||
}
|
||||
|
||||
const jsonData = ref(JSON.parse(JSON.stringify(baseJson)))
|
||||
|
||||
const packageJsonStr = computed(() => JSON.stringify(jsonData.value, null, 2))
|
||||
|
||||
function getTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function addLog(text, type = 'info') {
|
||||
logs.value.push({ time: getTime(), text, type })
|
||||
nextTick(() => {
|
||||
if (logRef.value) logRef.value.scrollTop = logRef.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
function setPhase(id, status) {
|
||||
const ph = phases.value.find(p => p.id === id)
|
||||
if (ph) ph.status = status
|
||||
}
|
||||
|
||||
async function runInstall() {
|
||||
if (installing.value) return
|
||||
installing.value = true
|
||||
logs.value = []
|
||||
installedDeps.value = []
|
||||
showLock.value = false
|
||||
phases.value.forEach(p => (p.status = 'pending'))
|
||||
|
||||
const pkg = packages.find(p => p.name === selectedPkg.value)
|
||||
if (!pkg) { installing.value = false; return }
|
||||
|
||||
addLog(`> npm install ${pkg.name}`, 'cmd')
|
||||
await sleep(300)
|
||||
|
||||
// Phase 1: resolve
|
||||
setPhase('resolve', 'active')
|
||||
addLog(`正在解析 ${pkg.name}@${pkg.version} 的依赖…`, 'info')
|
||||
await sleep(500)
|
||||
const allPkgs = [pkg, ...pkg.deps]
|
||||
for (const dep of pkg.deps) {
|
||||
addLog(` 找到依赖: ${dep.name}@${dep.version}`, 'dep')
|
||||
await sleep(200)
|
||||
}
|
||||
addLog(`共需安装 ${allPkgs.length} 个包`, 'success')
|
||||
setPhase('resolve', 'done')
|
||||
await sleep(300)
|
||||
|
||||
// Phase 2: fetch
|
||||
setPhase('fetch', 'active')
|
||||
for (const dep of allPkgs) {
|
||||
addLog(`↓ 下载 ${dep.name}-${dep.version}.tgz`, 'fetch')
|
||||
await sleep(300)
|
||||
}
|
||||
setPhase('fetch', 'done')
|
||||
await sleep(200)
|
||||
|
||||
// Phase 3: link
|
||||
setPhase('link', 'active')
|
||||
for (let i = 0; i < allPkgs.length; i++) {
|
||||
const dep = allPkgs[i]
|
||||
addLog(`📂 解压 → node_modules/${dep.name}/`, 'link')
|
||||
installedDeps.value.push({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
isLast: i === allPkgs.length - 1
|
||||
})
|
||||
await sleep(250)
|
||||
}
|
||||
setPhase('link', 'done')
|
||||
await sleep(200)
|
||||
|
||||
// Phase 4: lockfile
|
||||
setPhase('lockfile', 'active')
|
||||
showLock.value = true
|
||||
addLog('✏️ 写入 package-lock.json', 'lock')
|
||||
await sleep(300)
|
||||
|
||||
// Update package.json
|
||||
const updated = JSON.parse(JSON.stringify(baseJson))
|
||||
if (pkg.type === 'dependencies') {
|
||||
updated.dependencies[pkg.name] = `^${pkg.version}`
|
||||
} else {
|
||||
updated.devDependencies[pkg.name] = `^${pkg.version}`
|
||||
}
|
||||
jsonData.value = updated
|
||||
setPhase('lockfile', 'done')
|
||||
|
||||
addLog(`✅ 完成!新增 ${pkg.name}@${pkg.version}`, 'success')
|
||||
installing.value = false
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
logs.value = []
|
||||
installedDeps.value = []
|
||||
showLock.value = false
|
||||
phases.value.forEach(p => (p.status = 'pending'))
|
||||
jsonData.value = JSON.parse(JSON.stringify(baseJson))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1.5rem 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.1rem 0.7rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pm-label {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-brand);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pkg-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.83rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.install-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
font-size: 0.83rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-body {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: #1a1a2e;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-cmd .log-text { color: #7dd3fc; }
|
||||
.log-info .log-text { color: #94a3b8; }
|
||||
.log-dep .log-text { color: #fbbf24; }
|
||||
.log-fetch .log-text { color: #60a5fa; }
|
||||
.log-link .log-text { color: #a78bfa; }
|
||||
.log-lock .log-text { color: #fb923c; }
|
||||
.log-success .log-text { color: #4ade80; }
|
||||
.log-empty { color: #555; font-size: 0.75rem; }
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tree-line {
|
||||
padding: 0.05rem 0;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.tree-line.highlight {
|
||||
color: var(--vp-c-warning-1, #f59e0b);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dep-line {
|
||||
color: var(--vp-c-brand);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.dep-ver {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(-6px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.json-view {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
max-height: 130px;
|
||||
}
|
||||
|
||||
.json-pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.phases {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phase-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-right: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.phase-item:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.phase-item.active {
|
||||
background: color-mix(in srgb, var(--vp-c-brand) 10%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.phase-item.done {
|
||||
background: color-mix(in srgb, #22c55e 8%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.phase-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--vp-c-divider);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.phase-item.active .phase-dot {
|
||||
background: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand) 25%, transparent);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.phase-item.done .phase-dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand) 25%, transparent); }
|
||||
50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--vp-c-brand) 10%, transparent); }
|
||||
}
|
||||
|
||||
.phase-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.phase-name {
|
||||
font-size: 0.77rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.phase-desc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">包管理器生态地图</span>
|
||||
<span class="subtitle">选择一个语言生态,探索它的包管理工具</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<button
|
||||
v-for="eco in ecosystems"
|
||||
:key="eco.id"
|
||||
:class="['eco-btn', { active: activeEco === eco.id }]"
|
||||
@click="selectEco(eco.id)"
|
||||
>
|
||||
<span class="eco-icon">{{ eco.icon }}</span>
|
||||
<span class="eco-name">{{ eco.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="managers-grid">
|
||||
<div
|
||||
v-for="pm in currentManagers"
|
||||
:key="pm.id"
|
||||
:class="['pm-card', { active: activePm === pm.id }]"
|
||||
@click="selectPm(pm.id)"
|
||||
>
|
||||
<div class="pm-badge" :style="{ background: pm.color }">{{ pm.name }}</div>
|
||||
<div class="pm-tagline">{{ pm.tagline }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="currentPm" class="pm-detail">
|
||||
<div class="detail-top">
|
||||
<span class="detail-name" :style="{ color: currentPm.color }">{{ currentPm.name }}</span>
|
||||
<span class="detail-full">{{ currentPm.fullName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-sections">
|
||||
<div class="detail-section">
|
||||
<div class="section-label">安装命令</div>
|
||||
<div class="cmd-list">
|
||||
<div v-for="(cmd, i) in currentPm.commands" :key="i" class="cmd-row">
|
||||
<span class="cmd-op">{{ cmd.op }}</span>
|
||||
<code class="cmd-code">{{ cmd.cmd }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-label">配置文件</div>
|
||||
<div class="file-list">
|
||||
<div v-for="f in currentPm.files" :key="f.name" class="file-row">
|
||||
<code class="file-name">{{ f.name }}</code>
|
||||
<span class="file-desc">{{ f.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-label">核心特点</div>
|
||||
<div class="feature-list">
|
||||
<div v-for="feat in currentPm.features" :key="feat" class="feature-tag">{{ feat }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="pm-placeholder">
|
||||
← 点击上方卡片查看详情
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>包管理器 = 应用商店,帮你下载、安装、管理别人写好的代码(库/包),并自动处理版本兼容问题。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeEco = ref('js')
|
||||
const activePm = ref('npm')
|
||||
|
||||
|
||||
const ecosystems = [
|
||||
{ id: 'js', icon: '🟨', name: 'JavaScript' },
|
||||
{ id: 'python', icon: '🐍', name: 'Python' },
|
||||
{ id: 'rust', icon: '🦀', name: 'Rust' },
|
||||
{ id: 'go', icon: '🐹', name: 'Go' },
|
||||
{ id: 'mac', icon: '🍎', name: 'macOS/Linux' },
|
||||
{ id: 'windows', icon: '🪟', name: 'Windows' }
|
||||
]
|
||||
|
||||
const allManagers = {
|
||||
js: [
|
||||
{
|
||||
id: 'npm',
|
||||
name: 'npm',
|
||||
fullName: 'Node Package Manager',
|
||||
tagline: '最广泛使用,Node.js 自带',
|
||||
color: '#cc3534',
|
||||
commands: [
|
||||
{ op: '安装依赖', cmd: 'npm install lodash' },
|
||||
{ op: '安装开发依赖', cmd: 'npm install -D typescript' },
|
||||
{ op: '运行脚本', cmd: 'npm run build' },
|
||||
{ op: '查看已安装', cmd: 'npm list --depth=0' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'package.json', desc: '项目声明文件,记录依赖和脚本' },
|
||||
{ name: 'package-lock.json', desc: '锁定精确版本,保证环境一致' },
|
||||
{ name: 'node_modules/', desc: '实际安装的包存放目录' }
|
||||
],
|
||||
features: ['Node.js 内置', '最大生态(200万+包)', '支持 workspaces', 'npx 直接运行']
|
||||
},
|
||||
{
|
||||
id: 'yarn',
|
||||
name: 'Yarn',
|
||||
fullName: 'Yet Another Resource Negotiator',
|
||||
tagline: '并行下载快,Plug\'n\'Play 免 node_modules',
|
||||
color: '#2c8ebb',
|
||||
commands: [
|
||||
{ op: '安装依赖', cmd: 'yarn add lodash' },
|
||||
{ op: '安装开发依赖', cmd: 'yarn add -D typescript' },
|
||||
{ op: '运行脚本', cmd: 'yarn build' },
|
||||
{ op: '查看已安装', cmd: 'yarn list --depth=0' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'package.json', desc: '与 npm 兼容的项目声明文件' },
|
||||
{ name: 'yarn.lock', desc: 'Yarn 专属锁文件,格式更易读' },
|
||||
{ name: '.yarnrc.yml', desc: 'Yarn Berry 配置文件' }
|
||||
],
|
||||
features: ['并行安装更快', 'Plug\'n\'Play 零 node_modules', 'Workspace 原生支持', '离线缓存']
|
||||
},
|
||||
{
|
||||
id: 'pnpm',
|
||||
name: 'pnpm',
|
||||
fullName: 'Performant npm',
|
||||
tagline: '硬链接共享,节省磁盘,速度最快',
|
||||
color: '#f9ad00',
|
||||
commands: [
|
||||
{ op: '安装依赖', cmd: 'pnpm add lodash' },
|
||||
{ op: '安装开发依赖', cmd: 'pnpm add -D typescript' },
|
||||
{ op: '运行脚本', cmd: 'pnpm run build' },
|
||||
{ op: '查看已安装', cmd: 'pnpm list --depth=0' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'package.json', desc: '与 npm 兼容的项目声明文件' },
|
||||
{ name: 'pnpm-lock.yaml', desc: 'pnpm 专属锁文件' },
|
||||
{ name: '.pnpm-store/', desc: '全局内容寻址存储,跨项目共享' }
|
||||
],
|
||||
features: ['磁盘空间最省', '安装速度最快', '严格隔离防幽灵依赖', 'Monorepo 友好']
|
||||
}
|
||||
],
|
||||
python: [
|
||||
{
|
||||
id: 'pip',
|
||||
name: 'pip',
|
||||
fullName: 'Pip Installs Packages',
|
||||
tagline: 'Python 官方标准,简单直接',
|
||||
color: '#3776ab',
|
||||
commands: [
|
||||
{ op: '安装包', cmd: 'pip install requests' },
|
||||
{ op: '安装指定版本', cmd: 'pip install requests==2.28.0' },
|
||||
{ op: '导出依赖', cmd: 'pip freeze > requirements.txt' },
|
||||
{ op: '批量安装', cmd: 'pip install -r requirements.txt' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'requirements.txt', desc: '依赖列表,每行一个包和版本' },
|
||||
{ name: 'setup.py / pyproject.toml', desc: '项目元数据和打包配置' }
|
||||
],
|
||||
features: ['Python 内置', '使用最广泛', '配合 venv 隔离环境', '简单直接']
|
||||
},
|
||||
{
|
||||
id: 'conda',
|
||||
name: 'conda',
|
||||
fullName: 'Conda Package Manager',
|
||||
tagline: '科学计算利器,同时管理 Python 版本',
|
||||
color: '#44a833',
|
||||
commands: [
|
||||
{ op: '创建环境', cmd: 'conda create -n myenv python=3.11' },
|
||||
{ op: '激活环境', cmd: 'conda activate myenv' },
|
||||
{ op: '安装包', cmd: 'conda install numpy' },
|
||||
{ op: '导出环境', cmd: 'conda env export > env.yml' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'environment.yml', desc: '完整环境配置,包含 Python 版本' },
|
||||
{ name: '.condarc', desc: 'conda 全局配置文件' }
|
||||
],
|
||||
features: ['管理 Python 版本', '支持非 Python 包(CUDA等)', '科学计算首选', '跨平台环境复现']
|
||||
},
|
||||
{
|
||||
id: 'uv',
|
||||
name: 'uv',
|
||||
fullName: 'Ultra-fast Python Package Manager',
|
||||
tagline: 'Rust 编写,比 pip 快 10-100 倍',
|
||||
color: '#7c3aed',
|
||||
commands: [
|
||||
{ op: '安装包', cmd: 'uv pip install requests' },
|
||||
{ op: '创建虚拟环境', cmd: 'uv venv' },
|
||||
{ op: '同步依赖', cmd: 'uv pip sync requirements.txt' },
|
||||
{ op: '运行脚本', cmd: 'uv run python script.py' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'requirements.txt', desc: '与 pip 完全兼容的依赖文件' },
|
||||
{ name: 'pyproject.toml', desc: '现代 Python 项目配置标准' }
|
||||
],
|
||||
features: ['Rust 编写极速', '与 pip 完全兼容', '内置虚拟环境管理', '2024年新秀']
|
||||
}
|
||||
],
|
||||
rust: [
|
||||
{
|
||||
id: 'cargo',
|
||||
name: 'Cargo',
|
||||
fullName: 'Rust\'s Package Manager & Build System',
|
||||
tagline: 'Rust 官方工具,集构建/测试/发布于一体',
|
||||
color: '#dea584',
|
||||
commands: [
|
||||
{ op: '添加依赖', cmd: 'cargo add serde' },
|
||||
{ op: '构建项目', cmd: 'cargo build --release' },
|
||||
{ op: '运行项目', cmd: 'cargo run' },
|
||||
{ op: '运行测试', cmd: 'cargo test' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'Cargo.toml', desc: '项目清单,声明依赖和元数据' },
|
||||
{ name: 'Cargo.lock', desc: '精确锁定版本,应用项目必须提交' }
|
||||
],
|
||||
features: ['官方唯一标准', '内置构建系统', '包 = Crate', 'crates.io 生态']
|
||||
}
|
||||
],
|
||||
go: [
|
||||
{
|
||||
id: 'gomod',
|
||||
name: 'Go Modules',
|
||||
fullName: 'Go 官方模块系统(go mod)',
|
||||
tagline: '内置于 Go 工具链,无需额外安装',
|
||||
color: '#00acd7',
|
||||
commands: [
|
||||
{ op: '初始化模块', cmd: 'go mod init github.com/user/project' },
|
||||
{ op: '添加依赖', cmd: 'go get github.com/gin-gonic/gin' },
|
||||
{ op: '整理依赖', cmd: 'go mod tidy' },
|
||||
{ op: '下载到本地', cmd: 'go mod download' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'go.mod', desc: '模块声明文件,记录依赖路径和版本' },
|
||||
{ name: 'go.sum', desc: '哈希校验文件,防止依赖被篡改' }
|
||||
],
|
||||
features: ['Go 工具链内置', '路径即包名', '自动校验完整性', 'pkg.go.dev 生态']
|
||||
}
|
||||
],
|
||||
mac: [
|
||||
{
|
||||
id: 'brew',
|
||||
name: 'Homebrew',
|
||||
fullName: 'The Missing Package Manager for macOS',
|
||||
tagline: 'macOS/Linux 必备,安装开发工具首选',
|
||||
color: '#fbb040',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'brew install git' },
|
||||
{ op: '更新所有', cmd: 'brew upgrade' },
|
||||
{ op: '搜索软件', cmd: 'brew search node' },
|
||||
{ op: '查看已安装', cmd: 'brew list' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'Brewfile', desc: '批量安装清单,可版本控制' }
|
||||
],
|
||||
features: ['macOS/Linux 通用', '管理系统级工具', 'Cask 安装 GUI 应用', '社区驱动']
|
||||
},
|
||||
{
|
||||
id: 'apt',
|
||||
name: 'apt',
|
||||
fullName: 'Advanced Package Tool',
|
||||
tagline: 'Ubuntu/Debian 系统包管理器',
|
||||
color: '#e95420',
|
||||
commands: [
|
||||
{ op: '更新列表', cmd: 'sudo apt update' },
|
||||
{ op: '安装软件', cmd: 'sudo apt install nginx' },
|
||||
{ op: '更新系统', cmd: 'sudo apt upgrade' },
|
||||
{ op: '卸载软件', cmd: 'sudo apt remove nginx' }
|
||||
],
|
||||
files: [
|
||||
{ name: '/etc/apt/sources.list', desc: '软件源配置文件' }
|
||||
],
|
||||
features: ['Ubuntu/Debian 官方', '系统级权限', '依赖自动解析', '服务器运维必备']
|
||||
},
|
||||
{
|
||||
id: 'dnf',
|
||||
name: 'dnf / yum',
|
||||
fullName: 'Dandified YUM(Fedora / RHEL / CentOS)',
|
||||
tagline: 'Red Hat 系 Linux 的系统包管理器',
|
||||
color: '#e00',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'sudo dnf install git' },
|
||||
{ op: '更新系统', cmd: 'sudo dnf upgrade' },
|
||||
{ op: '搜索软件', cmd: 'dnf search nginx' },
|
||||
{ op: '卸载软件', cmd: 'sudo dnf remove nginx' }
|
||||
],
|
||||
files: [
|
||||
{ name: '/etc/dnf/dnf.conf', desc: 'dnf 全局配置文件' }
|
||||
],
|
||||
features: ['Fedora/RHEL/CentOS 官方', '支持模块流', 'DNF5 大幅提速', '企业级 Linux 首选']
|
||||
}
|
||||
],
|
||||
windows: [
|
||||
{
|
||||
id: 'winget',
|
||||
name: 'winget',
|
||||
fullName: 'Windows Package Manager',
|
||||
tagline: 'Microsoft 官方出品,Win 10/11 内置',
|
||||
color: '#0078d4',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'winget install Git.Git' },
|
||||
{ op: '更新所有', cmd: 'winget upgrade --all' },
|
||||
{ op: '搜索软件', cmd: 'winget search nodejs' },
|
||||
{ op: '卸载软件', cmd: 'winget uninstall Git.Git' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'winget-packages.json', desc: '导出的软件清单,可用于批量恢复' }
|
||||
],
|
||||
features: ['Windows 10/11 内置', 'Microsoft Store 集成', '软件包签名验证', '官方持续更新中']
|
||||
},
|
||||
{
|
||||
id: 'choco',
|
||||
name: 'Chocolatey',
|
||||
fullName: 'Chocolatey Package Manager',
|
||||
tagline: 'Windows 最成熟的第三方包管理器',
|
||||
color: '#4a154b',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'choco install git' },
|
||||
{ op: '更新所有', cmd: 'choco upgrade all' },
|
||||
{ op: '搜索软件', cmd: 'choco search nodejs' },
|
||||
{ op: '卸载软件', cmd: 'choco uninstall git' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'packages.config', desc: 'XML 格式的软件清单,批量安装用' }
|
||||
],
|
||||
features: ['生态最成熟(10000+包)', '企业版商业支持', 'PowerShell 集成', '支持无人值守安装']
|
||||
},
|
||||
{
|
||||
id: 'scoop',
|
||||
name: 'Scoop',
|
||||
fullName: 'Scoop — A command-line installer for Windows',
|
||||
tagline: '无需管理员权限,专为开发者设计',
|
||||
color: '#1a73e8',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'scoop install git' },
|
||||
{ op: '更新所有', cmd: 'scoop update *' },
|
||||
{ op: '搜索软件', cmd: 'scoop search nodejs' },
|
||||
{ op: '卸载软件', cmd: 'scoop uninstall git' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'Scoopfile / apps.json', desc: '应用清单,用于环境还原' }
|
||||
],
|
||||
features: ['无需管理员权限', '安装到用户目录', '版本共存切换', '开发者工具首选']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const currentManagers = computed(() => allManagers[activeEco.value] || [])
|
||||
|
||||
const currentPm = computed(() => {
|
||||
const list = currentManagers.value
|
||||
return list.find(p => p.id === activePm.value) || null
|
||||
})
|
||||
|
||||
function selectEco(id) {
|
||||
activeEco.value = id
|
||||
activePm.value = allManagers[id]?.[0]?.id || null
|
||||
}
|
||||
|
||||
function selectPm(id) {
|
||||
activePm.value = id
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1.5rem 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.1rem 0.7rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.eco-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.eco-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.eco-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.eco-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.managers-grid {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pm-card {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1.5px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.pm-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pm-card.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg-alt);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--vp-c-brand) 20%, transparent);
|
||||
}
|
||||
|
||||
.pm-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.pm-tagline {
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.pm-detail {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.pm-placeholder {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.8rem;
|
||||
padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-full {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.detail-sections {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.cmd-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.cmd-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.cmd-op {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.cmd-code {
|
||||
font-size: 0.76rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-brand);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.file-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
font-size: 0.73rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">PATH 搜索过程</span>
|
||||
<span class="subtitle">输入命令名,看 Shell 是如何逐目录查找的</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="preset-label">选择命令:</div>
|
||||
<div class="preset-btns">
|
||||
<button
|
||||
v-for="cmd in presets"
|
||||
:key="cmd.name"
|
||||
class="preset-btn"
|
||||
:class="{ active: command === cmd.name }"
|
||||
:disabled="isSearching"
|
||||
@click="selectCommand(cmd)"
|
||||
>
|
||||
{{ cmd.name }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="action-btn" :disabled="isSearching || !command" @click="startSearch">
|
||||
{{ isSearching ? '搜索中...' : '▶ 开始搜索' }}
|
||||
</button>
|
||||
<button class="reset-btn" :disabled="isSearching" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="path-display">
|
||||
<div class="path-label">当前 PATH:</div>
|
||||
<div class="path-value">
|
||||
<span
|
||||
v-for="(dir, idx) in pathDirs"
|
||||
:key="dir"
|
||||
class="path-segment"
|
||||
:class="{ active: currentDirIdx === idx }"
|
||||
>{{ dir }}<span v-if="idx < pathDirs.length - 1" class="sep">:</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-grid">
|
||||
<div
|
||||
v-for="(dir, idx) in pathDirs"
|
||||
:key="dir"
|
||||
class="dir-card"
|
||||
:class="getDirClass(idx)"
|
||||
>
|
||||
<div class="dir-name">{{ dir }}</div>
|
||||
<div v-if="dirStates[idx] === 'searching'" class="dir-status searching">
|
||||
<span class="spin">⟳</span> 查找 {{ command }}...
|
||||
</div>
|
||||
<div v-else-if="dirStates[idx] === 'found'" class="dir-status found">
|
||||
✓ 找到了!
|
||||
</div>
|
||||
<div v-else-if="dirStates[idx] === 'notfound'" class="dir-status notfound">
|
||||
✗ 没有
|
||||
</div>
|
||||
<div v-else class="dir-status idle">待查找</div>
|
||||
|
||||
<div v-if="dirStates[idx] === 'found' && currentCmd" class="found-path">
|
||||
{{ dir }}/{{ command }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="result-panel" :class="result.type">
|
||||
<span class="result-icon">{{ result.type === 'success' ? '✅' : '❌' }}</span>
|
||||
<div class="result-text">
|
||||
<strong>{{ result.title }}</strong>
|
||||
<div class="result-detail">{{ result.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心机制:</strong>Shell 拿到命令名后,按 PATH 里目录的顺序依次查找。找到第一个匹配就立即使用,停止继续搜索。所以 PATH 中目录的顺序非常重要——先出现的目录优先级更高。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const pathDirs = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']
|
||||
|
||||
const presets = [
|
||||
{ name: 'git', foundAt: 1, desc: 'Git 版本控制工具' },
|
||||
{ name: 'python3', foundAt: 2, desc: 'Python 解释器' },
|
||||
{ name: 'node', foundAt: 0, desc: 'Node.js 运行时(通常安装在 /usr/local/bin)' },
|
||||
{ name: 'ls', foundAt: 2, desc: '列出目录内容的内置命令' },
|
||||
{ name: 'foobar', foundAt: -1, desc: '一个不存在的命令' }
|
||||
]
|
||||
|
||||
const command = ref('')
|
||||
const currentCmd = ref(null)
|
||||
const isSearching = ref(false)
|
||||
const currentDirIdx = ref(-1)
|
||||
const dirStates = reactive(Array(pathDirs.length).fill('idle'))
|
||||
const result = ref(null)
|
||||
|
||||
const selectCommand = (cmd) => {
|
||||
if (isSearching.value) return
|
||||
command.value = cmd.name
|
||||
currentCmd.value = cmd
|
||||
reset()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
currentDirIdx.value = -1
|
||||
for (let i = 0; i < pathDirs.length; i++) dirStates[i] = 'idle'
|
||||
result.value = null
|
||||
}
|
||||
|
||||
const getDirClass = (idx) => {
|
||||
const s = dirStates[idx]
|
||||
return {
|
||||
searching: s === 'searching',
|
||||
found: s === 'found',
|
||||
notfound: s === 'notfound',
|
||||
'past-current': idx < currentDirIdx.value && s !== 'found'
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const startSearch = async () => {
|
||||
if (isSearching.value || !currentCmd.value) return
|
||||
reset()
|
||||
isSearching.value = true
|
||||
|
||||
const cmd = currentCmd.value
|
||||
const foundIdx = cmd.foundAt
|
||||
|
||||
for (let i = 0; i < pathDirs.length; i++) {
|
||||
currentDirIdx.value = i
|
||||
dirStates[i] = 'searching'
|
||||
await sleep(700)
|
||||
|
||||
if (i === foundIdx) {
|
||||
dirStates[i] = 'found'
|
||||
result.value = {
|
||||
type: 'success',
|
||||
title: `命令找到了!`,
|
||||
detail: `在 ${pathDirs[i]}/${cmd.name} 找到可执行文件,搜索停止。`
|
||||
}
|
||||
break
|
||||
} else {
|
||||
dirStates[i] = 'notfound'
|
||||
}
|
||||
|
||||
if (i === pathDirs.length - 1 || (foundIdx === -1 && i === pathDirs.length - 1)) {
|
||||
result.value = {
|
||||
type: 'error',
|
||||
title: `command not found: ${cmd.name}`,
|
||||
detail: `已搜索 PATH 中所有 ${pathDirs.length} 个目录,均未找到。需要先安装该程序,或将其所在目录加入 PATH。`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentDirIdx.value = -1
|
||||
isSearching.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-btns {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.25rem 0.65rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.preset-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.preset-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
transition: opacity 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reset-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.reset-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.path-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-2);
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.path-segment {
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.path-segment.active {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--vp-c-divider);
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.search-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dir-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.dir-card.searching {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--vp-c-brand) 40%, transparent);
|
||||
}
|
||||
|
||||
.dir-card.found {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: color-mix(in srgb, var(--vp-c-green-1) 8%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.dir-card.notfound {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.dir-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.4rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.dir-status {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dir-status.idle {
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.dir-status.searching {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dir-status.found {
|
||||
color: var(--vp-c-green-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dir-status.notfound {
|
||||
color: var(--vp-c-danger-1, #f87171);
|
||||
}
|
||||
|
||||
.spin {
|
||||
display: inline-block;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.found-path {
|
||||
margin-top: 0.3rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-green-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.result-panel.success {
|
||||
background: color-mix(in srgb, var(--vp-c-green-1) 8%, var(--vp-c-bg));
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.result-panel.error {
|
||||
background: color-mix(in srgb, var(--vp-c-danger-1, #f87171) 8%, var(--vp-c-bg));
|
||||
border-color: var(--vp-c-danger-1, #f87171);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-text strong {
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-1);
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.result-detail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,978 @@
|
||||
<template>
|
||||
<div class="regex-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">正则表达式:文本的搜索引擎</span>
|
||||
<span class="subtitle">模式匹配 · 分组捕获 · 实时预览</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="mode-btns">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.id"
|
||||
:class="['mode-btn', { active: activeMode === m.id }]"
|
||||
@click="activeMode = m.id"
|
||||
>
|
||||
{{ m.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<!-- Mode 1: Live Playground -->
|
||||
<div v-if="activeMode === 'playground'" class="playground-section">
|
||||
<div class="input-group">
|
||||
<label>正则表达式</label>
|
||||
<div class="regex-input-wrapper">
|
||||
<span class="regex-slash">/</span>
|
||||
<input
|
||||
v-model="regexPattern"
|
||||
type="text"
|
||||
placeholder="输入正则..."
|
||||
class="regex-input"
|
||||
/>
|
||||
<span class="regex-slash">/</span>
|
||||
<input
|
||||
v-model="regexFlags"
|
||||
type="text"
|
||||
placeholder="g"
|
||||
class="flags-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>测试文本</label>
|
||||
<textarea
|
||||
v-model="testText"
|
||||
rows="3"
|
||||
placeholder="输入要匹配的文本..."
|
||||
class="test-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="match-results">
|
||||
<div class="results-header">
|
||||
<span class="results-title">匹配结果</span>
|
||||
<span
|
||||
class="match-count"
|
||||
:class="{ 'has-match': matches.length > 0 }"
|
||||
>
|
||||
{{ matches.length }} 个匹配
|
||||
</span>
|
||||
</div>
|
||||
<div class="highlighted-text" v-html="highlightedText" />
|
||||
<div v-if="matches.length > 0" class="match-list">
|
||||
<div v-for="(m, i) in matches" :key="i" class="match-item">
|
||||
<span class="match-index">#{{ i + 1 }}</span>
|
||||
<code class="match-value">"{{ m }}"</code>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="regexError" class="regex-error">{{ regexError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="preset-btns">
|
||||
<span class="preset-label">试试预设:</span>
|
||||
<button
|
||||
v-for="p in presets"
|
||||
:key="p.name"
|
||||
class="preset-btn"
|
||||
@click="applyPreset(p)"
|
||||
>
|
||||
{{ p.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode 2: Cheat Sheet -->
|
||||
<div v-if="activeMode === 'cheatsheet'" class="cheatsheet-section">
|
||||
<div
|
||||
v-for="cat in cheatsheet"
|
||||
:key="cat.category"
|
||||
class="cheat-category"
|
||||
>
|
||||
<div class="cat-title">{{ cat.category }}</div>
|
||||
<div class="cheat-grid">
|
||||
<div
|
||||
v-for="item in cat.items"
|
||||
:key="item.pattern"
|
||||
class="cheat-item"
|
||||
@click="tryCheat(item)"
|
||||
>
|
||||
<code class="cheat-pattern">{{ item.pattern }}</code>
|
||||
<span class="cheat-desc">{{ item.desc }}</span>
|
||||
<span class="cheat-example">{{ item.example }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode 3: Common Patterns -->
|
||||
<div v-if="activeMode === 'patterns'" class="patterns-section">
|
||||
<div class="patterns-grid">
|
||||
<div v-for="p in commonPatterns" :key="p.name" class="pattern-card">
|
||||
<div class="pattern-name">{{ p.name }}</div>
|
||||
<code class="pattern-regex">{{ p.regex }}</code>
|
||||
<div class="pattern-matches">
|
||||
<div
|
||||
v-for="(ex, i) in p.examples"
|
||||
:key="i"
|
||||
class="pattern-example"
|
||||
>
|
||||
<span class="ex-text">{{ ex.text }}</span>
|
||||
<span :class="['ex-result', ex.match ? 'pass' : 'fail']">
|
||||
{{ ex.match ? '✓ 匹配' : '✗ 不匹配' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode 4: Visual Breakdown -->
|
||||
<div v-if="activeMode === 'visual'" class="visual-section">
|
||||
<div class="visual-example">
|
||||
<div class="visual-title">正则解剖:拆解一个邮箱匹配模式</div>
|
||||
<div class="visual-regex">
|
||||
<span
|
||||
v-for="(part, i) in regexParts"
|
||||
:key="i"
|
||||
:class="['regex-part', part.type]"
|
||||
@mouseenter="activePart = i"
|
||||
@mouseleave="activePart = -1"
|
||||
>
|
||||
{{ part.text }}
|
||||
<span v-if="activePart === i" class="part-tooltip">{{
|
||||
part.desc
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="visual-legend">
|
||||
<span
|
||||
v-for="l in legend"
|
||||
:key="l.type"
|
||||
:class="['legend-item', l.type]"
|
||||
>
|
||||
<span class="legend-dot" />{{ l.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visual-flow">
|
||||
<div class="flow-title">正则引擎的工作过程</div>
|
||||
<div class="flow-steps">
|
||||
<div v-for="(step, i) in engineSteps" :key="i" class="flow-step">
|
||||
<div class="flow-num">{{ i + 1 }}</div>
|
||||
<div class="flow-content">
|
||||
<div class="flow-action">{{ step.action }}</div>
|
||||
<div class="flow-detail">{{ step.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span v-if="activeMode === 'playground'"
|
||||
>正则表达式是一种用特殊符号描述文本模式的语言,在搜索、替换、数据验证中无处不在。</span
|
||||
>
|
||||
<span v-else-if="activeMode === 'cheatsheet'"
|
||||
>记住几个核心符号(. * + ? \d \w [] ())就能覆盖 80%
|
||||
的使用场景。点击任意符号可直接试验。</span
|
||||
>
|
||||
<span v-else-if="activeMode === 'patterns'"
|
||||
>不需要自己从零写正则——常见场景(邮箱、手机号、URL)都有成熟的模式可以直接复用。</span
|
||||
>
|
||||
<span v-else
|
||||
>正则引擎从左到右逐字符匹配,遇到量词会"贪婪"地尽量多匹配,失败时"回溯"尝试其他路径。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeMode = ref('playground')
|
||||
|
||||
const modes = [
|
||||
{ id: 'playground', label: '实时试验' },
|
||||
{ id: 'cheatsheet', label: '速查表' },
|
||||
{ id: 'patterns', label: '常用模式' },
|
||||
{ id: 'visual', label: '可视化解析' }
|
||||
]
|
||||
|
||||
const regexPattern = ref('\\d+')
|
||||
const regexFlags = ref('g')
|
||||
const testText = ref(
|
||||
'我的手机号是 13812345678,座机是 010-12345678,邮箱是 test@example.com'
|
||||
)
|
||||
|
||||
function buildRegex(pattern, flags) {
|
||||
try {
|
||||
if (!pattern) return { regex: null, error: '' }
|
||||
return { regex: new RegExp(pattern, flags), error: '' }
|
||||
} catch (e) {
|
||||
return { regex: null, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
const regexResult = computed(() =>
|
||||
buildRegex(regexPattern.value, regexFlags.value)
|
||||
)
|
||||
const regexError = computed(() => regexResult.value.error)
|
||||
|
||||
const matches = computed(() => {
|
||||
const { regex } = regexResult.value
|
||||
if (!regex) return []
|
||||
try {
|
||||
const result = []
|
||||
let match
|
||||
if (regexFlags.value.includes('g')) {
|
||||
while ((match = regex.exec(testText.value)) !== null) {
|
||||
result.push(match[0])
|
||||
if (!match[0]) break
|
||||
}
|
||||
} else {
|
||||
match = regex.exec(testText.value)
|
||||
if (match) result.push(match[0])
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const highlightedText = computed(() => {
|
||||
try {
|
||||
if (!regexPattern.value || regexError.value) {
|
||||
return escapeHtml(testText.value)
|
||||
}
|
||||
const regex = new RegExp(regexPattern.value, regexFlags.value)
|
||||
return escapeHtml(testText.value).replace(
|
||||
regex,
|
||||
(m) => `<mark class="highlight">${escapeHtml(m)}</mark>`
|
||||
)
|
||||
} catch {
|
||||
return escapeHtml(testText.value)
|
||||
}
|
||||
})
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{
|
||||
name: '找数字',
|
||||
pattern: '\\d+',
|
||||
flags: 'g',
|
||||
text: '价格是 99 元,优惠 20 元,共 79 元'
|
||||
},
|
||||
{
|
||||
name: '找邮箱',
|
||||
pattern: '[\\w.+-]+@[\\w-]+\\.[\\w.]+',
|
||||
flags: 'g',
|
||||
text: 'admin@test.com 和 user@example.org 是有效邮箱'
|
||||
},
|
||||
{
|
||||
name: '找手机号',
|
||||
pattern: '1[3-9]\\d{9}',
|
||||
flags: 'g',
|
||||
text: '联系我:13812345678 或 15099887766'
|
||||
},
|
||||
{
|
||||
name: '找 URL',
|
||||
pattern: 'https?://[^\\s]+',
|
||||
flags: 'g',
|
||||
text: '访问 https://github.com 或 http://example.com/path'
|
||||
},
|
||||
{
|
||||
name: '找中文',
|
||||
pattern: '[\\u4e00-\\u9fa5]+',
|
||||
flags: 'g',
|
||||
text: 'Hello世界,你好World!'
|
||||
}
|
||||
]
|
||||
|
||||
function applyPreset(p) {
|
||||
regexPattern.value = p.pattern
|
||||
regexFlags.value = p.flags
|
||||
testText.value = p.text
|
||||
}
|
||||
|
||||
const cheatsheet = [
|
||||
{
|
||||
category: '字符类',
|
||||
items: [
|
||||
{ pattern: '.', desc: '任意字符(除换行)', example: 'a.c → abc, a1c' },
|
||||
{ pattern: '\\d', desc: '数字 [0-9]', example: '\\d → 3, 7' },
|
||||
{ pattern: '\\w', desc: '字母数字下划线', example: '\\w → a, 5, _' },
|
||||
{ pattern: '\\s', desc: '空白字符', example: '空格、Tab、换行' },
|
||||
{ pattern: '[abc]', desc: '字符集合', example: '[aeiou] → 元音' },
|
||||
{ pattern: '[^abc]', desc: '否定集合', example: '[^0-9] → 非数字' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '量词',
|
||||
items: [
|
||||
{ pattern: '*', desc: '0 或多次', example: 'ab* → a, ab, abb' },
|
||||
{ pattern: '+', desc: '1 或多次', example: 'ab+ → ab, abb' },
|
||||
{ pattern: '?', desc: '0 或 1 次', example: 'colou?r → color, colour' },
|
||||
{ pattern: '{n}', desc: '恰好 n 次', example: '\\d{4} → 2024' },
|
||||
{ pattern: '{n,m}', desc: 'n 到 m 次', example: '\\d{2,4} → 12, 123' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '位置',
|
||||
items: [
|
||||
{ pattern: '^', desc: '行首', example: '^Hello → 以 Hello 开头' },
|
||||
{ pattern: '$', desc: '行尾', example: 'end$ → 以 end 结尾' },
|
||||
{
|
||||
pattern: '\\b',
|
||||
desc: '单词边界',
|
||||
example: '\\bcat\\b → cat(不匹配 catch)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '分组与引用',
|
||||
items: [
|
||||
{ pattern: '(abc)', desc: '捕获组', example: '(\\d+)-(\\d+) → 分别捕获' },
|
||||
{ pattern: 'a|b', desc: '或', example: 'cat|dog → cat 或 dog' },
|
||||
{ pattern: '(?:abc)', desc: '非捕获组', example: '(?:ab)+ → abab' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function tryCheat(item) {
|
||||
activeMode.value = 'playground'
|
||||
regexPattern.value = item.pattern.replace(/\\/g, '\\')
|
||||
regexFlags.value = 'g'
|
||||
}
|
||||
|
||||
const commonPatterns = [
|
||||
{
|
||||
name: '邮箱',
|
||||
regex: '^[\\w.+-]+@[\\w-]+\\.[\\w.]+$',
|
||||
examples: [
|
||||
{ text: 'user@example.com', match: true },
|
||||
{ text: 'a.b+c@test.org', match: true },
|
||||
{ text: 'invalid@', match: false },
|
||||
{ text: '@no-user.com', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '手机号(中国)',
|
||||
regex: '^1[3-9]\\d{9}$',
|
||||
examples: [
|
||||
{ text: '13812345678', match: true },
|
||||
{ text: '15099887766', match: true },
|
||||
{ text: '12345678901', match: false },
|
||||
{ text: '1381234567', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'URL',
|
||||
regex: '^https?://[^\\s]+$',
|
||||
examples: [
|
||||
{ text: 'https://github.com', match: true },
|
||||
{ text: 'http://example.com/path?q=1', match: true },
|
||||
{ text: 'ftp://not-http.com', match: false },
|
||||
{ text: 'just-text', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'IPv4 地址',
|
||||
regex: '^(\\d{1,3}\\.){3}\\d{1,3}$',
|
||||
examples: [
|
||||
{ text: '192.168.1.1', match: true },
|
||||
{ text: '10.0.0.255', match: true },
|
||||
{ text: '999.999.999.999', match: true },
|
||||
{ text: '1.2.3', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '日期 (YYYY-MM-DD)',
|
||||
regex: '^\\d{4}-\\d{2}-\\d{2}$',
|
||||
examples: [
|
||||
{ text: '2024-01-15', match: true },
|
||||
{ text: '2023-12-31', match: true },
|
||||
{ text: '24-1-5', match: false },
|
||||
{ text: '2024/01/15', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '强密码',
|
||||
regex: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,}$',
|
||||
examples: [
|
||||
{ text: 'Passw0rd', match: true },
|
||||
{ text: 'MyP@ss123', match: true },
|
||||
{ text: 'password', match: false },
|
||||
{ text: 'SHORT1a', match: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const activePart = ref(-1)
|
||||
|
||||
const regexParts = [
|
||||
{ text: '[', type: 'bracket', desc: '字符集合开始' },
|
||||
{ text: '\\w', type: 'char-class', desc: '字母、数字或下划线' },
|
||||
{ text: '.+-', type: 'literal', desc: '点号、加号、横杠(字面量)' },
|
||||
{ text: ']', type: 'bracket', desc: '字符集合结束' },
|
||||
{ text: '+', type: 'quantifier', desc: '一个或多个(贪婪匹配)' },
|
||||
{ text: '@', type: 'literal', desc: '字面量 @ 符号' },
|
||||
{ text: '[', type: 'bracket', desc: '字符集合开始' },
|
||||
{ text: '\\w', type: 'char-class', desc: '字母、数字或下划线' },
|
||||
{ text: '-', type: 'literal', desc: '横杠(字面量)' },
|
||||
{ text: ']', type: 'bracket', desc: '字符集合结束' },
|
||||
{ text: '+', type: 'quantifier', desc: '一个或多个' },
|
||||
{ text: '\\.', type: 'escape', desc: '转义的点号(匹配字面量 .)' },
|
||||
{ text: '[', type: 'bracket', desc: '字符集合开始' },
|
||||
{ text: '\\w', type: 'char-class', desc: '字母、数字或下划线' },
|
||||
{ text: '.', type: 'literal', desc: '点号(在字符集中是字面量)' },
|
||||
{ text: ']', type: 'bracket', desc: '字符集合结束' },
|
||||
{ text: '+', type: 'quantifier', desc: '一个或多个' }
|
||||
]
|
||||
|
||||
const legend = [
|
||||
{ type: 'char-class', label: '字符类' },
|
||||
{ type: 'quantifier', label: '量词' },
|
||||
{ type: 'literal', label: '字面量' },
|
||||
{ type: 'bracket', label: '集合边界' },
|
||||
{ type: 'escape', label: '转义字符' }
|
||||
]
|
||||
|
||||
const engineSteps = [
|
||||
{
|
||||
action: '从左到右扫描',
|
||||
detail: '正则引擎从文本第一个字符开始,逐个尝试匹配'
|
||||
},
|
||||
{ action: '贪婪匹配', detail: '遇到 * + 等量词时,尽量多匹配字符' },
|
||||
{ action: '回溯', detail: '如果贪婪匹配失败,退回一步尝试更少的字符' },
|
||||
{ action: '捕获分组', detail: '遇到 () 时,记录匹配的子串供后续引用' },
|
||||
{ action: '返回结果', detail: '全部匹配完成,返回所有匹配项和捕获组' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.regex-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.mode-btns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Playground */
|
||||
.input-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.regex-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0 0.35rem;
|
||||
}
|
||||
|
||||
.regex-slash {
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.regex-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.25rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.flags-input {
|
||||
width: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.15rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.test-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.match-results {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-3);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.match-count.has-match {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.highlighted-text {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
padding: 0.35rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
:deep(.highlight) {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
padding: 0.05rem 0.15rem;
|
||||
border-radius: 2px;
|
||||
border-bottom: 2px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.match-list {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.match-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.match-index {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.match-value {
|
||||
background: var(--vp-c-brand-soft);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.regex-error {
|
||||
color: var(--vp-c-danger-1);
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.preset-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Cheatsheet */
|
||||
.cheat-category {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.cat-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.cheat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.cheat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.cheat-item:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.cheat-pattern {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.cheat-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.cheat-example {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
/* Patterns */
|
||||
.patterns-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.pattern-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.pattern-regex {
|
||||
display: block;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--vp-c-brand);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pattern-matches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.pattern-example {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.35rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ex-text {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ex-result.pass {
|
||||
color: var(--vp-c-green-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ex-result.fail {
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
/* Visual */
|
||||
.visual-example {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.visual-title,
|
||||
.flow-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.visual-regex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.regex-part {
|
||||
position: relative;
|
||||
padding: 0.15rem 0.1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.regex-part.char-class {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.regex-part.quantifier {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
.regex-part.literal {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.regex-part.bracket {
|
||||
color: #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
.regex-part.escape {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.part-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--vp-c-text-1);
|
||||
color: var(--vp-c-bg);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.visual-legend {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.legend-item.char-class .legend-dot {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.legend-item.quantifier .legend-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.legend-item.literal .legend-dot {
|
||||
background: var(--vp-c-text-2);
|
||||
}
|
||||
.legend-item.bracket .legend-dot {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.legend-item.escape .legend-dot {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.visual-flow {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.flow-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.72rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-action {
|
||||
font-weight: bold;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.flow-detail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.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 strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cheat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.patterns-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,835 @@
|
||||
<template>
|
||||
<div class="ssh-auth-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">SSH 密钥认证:你的数字身份证</span>
|
||||
<span class="subtitle"
|
||||
>对称加密 vs 非对称加密 · 密钥对生成 · 认证流程</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="scenario-btns">
|
||||
<button
|
||||
v-for="s in scenarios"
|
||||
:key="s.id"
|
||||
:class="['scenario-btn', { active: activeScenario === s.id }]"
|
||||
@click="activeScenario = s.id"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<!-- Scenario 1: Password vs Key -->
|
||||
<div v-if="activeScenario === 'compare'" class="compare-section">
|
||||
<div class="compare-grid">
|
||||
<div class="compare-card password">
|
||||
<div class="card-icon">🔑</div>
|
||||
<div class="card-title">密码登录</div>
|
||||
<div class="card-flow">
|
||||
<div class="flow-step" v-for="(step, i) in passwordFlow" :key="i">
|
||||
<span class="step-num">{{ i + 1 }}</span>
|
||||
<span class="step-text">{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-verdict danger">
|
||||
<span class="verdict-icon">⚠️</span>
|
||||
<span>密码在网络上传输,可能被截获</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compare-card key">
|
||||
<div class="card-icon">🔐</div>
|
||||
<div class="card-title">密钥登录</div>
|
||||
<div class="card-flow">
|
||||
<div class="flow-step" v-for="(step, i) in keyFlow" :key="i">
|
||||
<span class="step-num">{{ i + 1 }}</span>
|
||||
<span class="step-text">{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-verdict success">
|
||||
<span class="verdict-icon">✅</span>
|
||||
<span>私钥永远不离开你的电脑</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario 2: Key Pair Generation -->
|
||||
<div v-if="activeScenario === 'keygen'" class="keygen-section">
|
||||
<div class="keygen-visual">
|
||||
<div class="keygen-command">
|
||||
<code>ssh-keygen -t ed25519 -C "your@email.com"</code>
|
||||
<button
|
||||
class="gen-btn"
|
||||
:disabled="isGenerating"
|
||||
@click="generateKeys"
|
||||
>
|
||||
{{ isGenerating ? '生成中...' : '生成密钥对' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="key-pair" :class="{ generated: keysGenerated }">
|
||||
<div class="key-card private" :class="{ visible: keysGenerated }">
|
||||
<div class="key-header">
|
||||
<span class="key-icon">🔒</span>
|
||||
<span class="key-name">私钥 (Private Key)</span>
|
||||
</div>
|
||||
<div class="key-location">~/.ssh/id_ed25519</div>
|
||||
<div class="key-content">
|
||||
<code>{{ privateKeyDisplay }}</code>
|
||||
</div>
|
||||
<div class="key-rule danger">绝不外泄 · 留在本机</div>
|
||||
</div>
|
||||
|
||||
<div class="key-arrow" :class="{ visible: keysGenerated }">
|
||||
<span class="arrow-text">数学关联</span>
|
||||
<span class="arrow-icon">↔</span>
|
||||
</div>
|
||||
|
||||
<div class="key-card public" :class="{ visible: keysGenerated }">
|
||||
<div class="key-header">
|
||||
<span class="key-icon">🌍</span>
|
||||
<span class="key-name">公钥 (Public Key)</span>
|
||||
</div>
|
||||
<div class="key-location">~/.ssh/id_ed25519.pub</div>
|
||||
<div class="key-content">
|
||||
<code>{{ publicKeyDisplay }}</code>
|
||||
</div>
|
||||
<div class="key-rule success">可以给任何人 · 放到服务器</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="keysGenerated" class="key-analogy">
|
||||
<strong>生活类比:</strong>公钥 = 锁(可以随便装)· 私钥 =
|
||||
钥匙(只有你有)· 用锁锁住的东西,只有对应的钥匙能打开
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario 3: Auth Flow -->
|
||||
<div v-if="activeScenario === 'auth'" class="auth-section">
|
||||
<div class="auth-controls">
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="authStep > 0 && authStep < 5"
|
||||
@click="startAuth"
|
||||
>
|
||||
{{
|
||||
authStep === 0
|
||||
? '开始认证'
|
||||
: authStep >= 5
|
||||
? '重新演示'
|
||||
: '认证中...'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-flow">
|
||||
<div class="auth-parties">
|
||||
<div class="party client">
|
||||
<div class="party-icon">💻</div>
|
||||
<div class="party-name">你的电脑</div>
|
||||
<div class="party-has">持有:私钥</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-messages">
|
||||
<div
|
||||
:class="['msg', { active: authStep >= 1 }]"
|
||||
class="msg-right"
|
||||
>
|
||||
<span class="msg-label">① 请求连接</span>
|
||||
<span class="msg-detail">"我要用密钥登录"</span>
|
||||
</div>
|
||||
<div :class="['msg', { active: authStep >= 2 }]" class="msg-left">
|
||||
<span class="msg-label">② 发送随机挑战</span>
|
||||
<span class="msg-detail"
|
||||
>"请证明你有私钥:用它签名这段随机数据"</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
:class="['msg', { active: authStep >= 3 }]"
|
||||
class="msg-right"
|
||||
>
|
||||
<span class="msg-label">③ 返回签名</span>
|
||||
<span class="msg-detail"
|
||||
>"用私钥签名后的结果(私钥本身不发送)"</span
|
||||
>
|
||||
</div>
|
||||
<div :class="['msg', { active: authStep >= 4 }]" class="msg-left">
|
||||
<span class="msg-label">④ 用公钥验证</span>
|
||||
<span class="msg-detail">"用存储的公钥验证签名 → 匹配!"</span>
|
||||
</div>
|
||||
<div :class="['msg', 'msg-result', { active: authStep >= 5 }]">
|
||||
<span class="msg-label">⑤ 认证成功</span>
|
||||
<span class="msg-detail"
|
||||
>"欢迎登录!从始至终,私钥没离开过你的电脑"</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="party server">
|
||||
<div class="party-icon">🖥️</div>
|
||||
<div class="party-name">远程服务器</div>
|
||||
<div class="party-has">持有:公钥</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario 4: Common Uses -->
|
||||
<div v-if="activeScenario === 'uses'" class="uses-section">
|
||||
<div class="uses-grid">
|
||||
<div v-for="use in commonUses" :key="use.name" class="use-card">
|
||||
<div class="use-icon">{{ use.icon }}</div>
|
||||
<div class="use-name">{{ use.name }}</div>
|
||||
<div class="use-cmd">
|
||||
<code>{{ use.command }}</code>
|
||||
</div>
|
||||
<div class="use-desc">{{ use.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-tips">
|
||||
<div class="tip-title">~/.ssh/config 快捷配置</div>
|
||||
<pre class="tip-code"><code>Host my-server
|
||||
HostName 192.168.1.100
|
||||
User deploy
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
|
||||
Host github.com
|
||||
HostName github.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519</code></pre>
|
||||
<div class="tip-result">
|
||||
配置后:<code>ssh my-server</code> 即可一键连接
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span v-if="activeScenario === 'compare'"
|
||||
>SSH
|
||||
密钥登录比密码更安全,因为私钥从不在网络上传输,无法被中间人窃取。</span
|
||||
>
|
||||
<span v-else-if="activeScenario === 'keygen'"
|
||||
>一次 ssh-keygen
|
||||
生成一对密钥:私钥自己保管,公钥放到目标服务器或平台。</span
|
||||
>
|
||||
<span v-else-if="activeScenario === 'auth'"
|
||||
>认证过程基于"挑战-响应"机制:服务器出题,你的私钥签名作答,公钥验证答案。全程私钥不离开本机。</span
|
||||
>
|
||||
<span v-else
|
||||
>SSH 密钥不仅用于服务器登录,也是 Git (GitHub/GitLab)
|
||||
等开发工具的标准身份认证方式。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeScenario = ref('compare')
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'compare', label: '密码 vs 密钥' },
|
||||
{ id: 'keygen', label: '生成密钥对' },
|
||||
{ id: 'auth', label: '认证流程' },
|
||||
{ id: 'uses', label: '常见用途' }
|
||||
]
|
||||
|
||||
const passwordFlow = [
|
||||
'输入用户名和密码',
|
||||
'密码通过网络发送到服务器',
|
||||
'服务器比对密码是否正确',
|
||||
'每次都要输密码'
|
||||
]
|
||||
|
||||
const keyFlow = [
|
||||
'事先把公钥放到服务器',
|
||||
'连接时发送身份标识(不发私钥)',
|
||||
'服务器用公钥出"数学题"',
|
||||
'你的私钥在本地"答题",只发答案'
|
||||
]
|
||||
|
||||
const isGenerating = ref(false)
|
||||
const keysGenerated = ref(false)
|
||||
const privateKeyDisplay = ref(
|
||||
'-----BEGIN OPENSSH PRIVATE KEY-----\n(等待生成...)\n-----END OPENSSH PRIVATE KEY-----'
|
||||
)
|
||||
const publicKeyDisplay = ref('(等待生成...)')
|
||||
|
||||
const generateKeys = async () => {
|
||||
if (isGenerating.value) return
|
||||
isGenerating.value = true
|
||||
keysGenerated.value = false
|
||||
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
|
||||
privateKeyDisplay.value =
|
||||
'-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAA...\n(2048 位密钥,绝不外传)\n-----END OPENSSH PRIVATE KEY-----'
|
||||
publicKeyDisplay.value =
|
||||
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA\nIGx...kF your@email.com'
|
||||
|
||||
keysGenerated.value = true
|
||||
isGenerating.value = false
|
||||
}
|
||||
|
||||
const authStep = ref(0)
|
||||
|
||||
const startAuth = async () => {
|
||||
if (authStep.value > 0 && authStep.value < 5) return
|
||||
authStep.value = 0
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
authStep.value = i
|
||||
}
|
||||
}
|
||||
|
||||
const commonUses = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
name: '远程服务器',
|
||||
command: 'ssh user@server',
|
||||
desc: '免密码登录 Linux/Mac 服务器'
|
||||
},
|
||||
{
|
||||
icon: '🐙',
|
||||
name: 'GitHub',
|
||||
command: 'git push origin main',
|
||||
desc: '用 SSH 协议推送代码'
|
||||
},
|
||||
{
|
||||
icon: '🦊',
|
||||
name: 'GitLab',
|
||||
command: 'git clone git@gitlab.com:...',
|
||||
desc: '克隆私有仓库'
|
||||
},
|
||||
{
|
||||
icon: '📦',
|
||||
name: 'SCP 传文件',
|
||||
command: 'scp file.txt user@server:~/',
|
||||
desc: '安全复制文件到远程'
|
||||
},
|
||||
{
|
||||
icon: '🚇',
|
||||
name: 'SSH 隧道',
|
||||
command: 'ssh -L 8080:localhost:3000 server',
|
||||
desc: '将远程端口映射到本地'
|
||||
},
|
||||
{
|
||||
icon: '🐳',
|
||||
name: '部署服务',
|
||||
command: 'ssh deploy@prod "docker pull..."',
|
||||
desc: '远程执行部署命令'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ssh-auth-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-btns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scenario-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scenario-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Compare Section */
|
||||
.compare-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compare-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 50%;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-verdict {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-verdict.danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.card-verdict.success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
/* Keygen Section */
|
||||
.keygen-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.keygen-command code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.82rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.gen-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gen-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.key-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
opacity: 0.4;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.key-card.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.key-card.private {
|
||||
border-color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.key-card.public {
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.key-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.key-location {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.key-content code {
|
||||
display: block;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.72rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.35rem;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.key-rule {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 0.2rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.key-rule.danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.key-rule.success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.key-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.key-arrow.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.arrow-text {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.key-analogy {
|
||||
margin-top: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Auth Section */
|
||||
.auth-controls {
|
||||
text-align: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-flow {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.auth-parties {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 100px;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.party {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.party-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.party-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.82rem;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.party-has {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.auth-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.2;
|
||||
transition: all 0.4s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.msg.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.msg-right {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.2);
|
||||
margin-right: 20%;
|
||||
}
|
||||
|
||||
.msg-left {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
margin-left: 20%;
|
||||
}
|
||||
|
||||
.msg-result {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.msg-label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.msg-detail {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Uses Section */
|
||||
.uses-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.use-card {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.use-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.use-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.82rem;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.use-cmd code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.7rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.use-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.config-tips {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tip-code {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin: 0 0 0.35rem 0;
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tip-code code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.tip-result {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tip-result code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.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 strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.compare-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.key-pair {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.key-arrow {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.auth-parties {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.party {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">生产环境如何注入密钥</span>
|
||||
<span class="subtitle">.env 是开发工具,服务器上不能靠它</span>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button
|
||||
v-for="s in scenarios"
|
||||
:key="s.id"
|
||||
class="tab-btn"
|
||||
:class="{ active: current === s.id }"
|
||||
@click="current = s.id"
|
||||
>
|
||||
{{ s.icon }} {{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="scenario-body">
|
||||
<div class="code-block">
|
||||
<div class="code-title">{{ currentScenario.codeTitle }}</div>
|
||||
<div class="code-area">
|
||||
<div
|
||||
v-for="(line, i) in currentScenario.lines"
|
||||
:key="i"
|
||||
class="code-line"
|
||||
:class="line.type"
|
||||
>
|
||||
<span class="line-content" v-html="line.text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
<div v-for="tip in currentScenario.tips" :key="tip.text" class="tip" :class="tip.level">
|
||||
<span class="tip-dot" />
|
||||
<span class="tip-text">{{ tip.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>原则:</strong>.env 文件是本地开发便利工具,生产环境应由运行平台负责注入环境变量——代码完全不感知密钥存在哪、怎么来的。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const current = ref('systemd')
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'systemd', icon: '🖥️', label: '服务器 (systemd)' },
|
||||
{ id: 'cloud', icon: '☁️', label: '云平台 (Vercel 等)' },
|
||||
{ id: 'docker', icon: '🐳', label: 'Docker' }
|
||||
]
|
||||
|
||||
const scenarioData = {
|
||||
systemd: {
|
||||
codeTitle: '/etc/systemd/system/myapp.service',
|
||||
lines: [
|
||||
{ type: 'comment', text: '# 推荐:用独立密钥文件,权限可控' },
|
||||
{ type: 'normal', text: '[Service]' },
|
||||
{ type: 'highlight', text: 'EnvironmentFile=/etc/myapp/secrets.env' },
|
||||
{ type: 'normal', text: 'ExecStart=/usr/bin/node /app/index.js' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 设置文件权限:只有所有者可读' },
|
||||
{ type: 'good', text: 'sudo chmod 600 /etc/myapp/secrets.env' },
|
||||
{ type: 'good', text: 'sudo chown deploy:deploy /etc/myapp/secrets.env' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 应用配置后重启服务' },
|
||||
{ type: 'normal', text: 'sudo systemctl daemon-reload' },
|
||||
{ type: 'normal', text: 'sudo systemctl restart myapp' }
|
||||
],
|
||||
tips: [
|
||||
{ level: 'safe', text: '密钥文件 chmod 600 后,只有 deploy 用户可读,其他账号无法访问' },
|
||||
{ level: 'safe', text: '密钥和代码完全分离,更新密钥不需要重新部署代码' },
|
||||
{ level: 'warn', text: '不要直接在 systemd 文件里写 Environment="KEY=val"——改动需要 reload,且明文在配置里' }
|
||||
]
|
||||
},
|
||||
cloud: {
|
||||
codeTitle: '云平台控制台(Vercel / Railway / Render / Netlify)',
|
||||
lines: [
|
||||
{ type: 'comment', text: '# 在平台控制台界面操作,无需写配置文件' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 平台会自动将变量注入到运行时环境' },
|
||||
{ type: 'normal', text: '# 代码不变,照常读取:' },
|
||||
{ type: 'highlight', text: 'const key = process.env.OPENAI_API_KEY' },
|
||||
{ type: 'highlight', text: 'api_key = os.environ.get("OPENAI_API_KEY")' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 通常支持按环境设置不同的值:' },
|
||||
{ type: 'normal', text: '# Preview → OPENAI_API_KEY = sk-test-...' },
|
||||
{ type: 'normal', text: '# Production → OPENAI_API_KEY = sk-prod-...' }
|
||||
],
|
||||
tips: [
|
||||
{ level: 'safe', text: '平台加密存储密钥,你自己都不能再次查看原始值(只能重新生成)' },
|
||||
{ level: 'safe', text: '支持 Preview / Production 分环境设置,测试和生产用不同密钥' },
|
||||
{ level: 'info', text: '不要把 .env 文件提交到 Git 再让平台读取——这样密钥就进代码仓库了' }
|
||||
]
|
||||
},
|
||||
docker: {
|
||||
codeTitle: 'docker run / docker-compose.yml',
|
||||
lines: [
|
||||
{ type: 'comment', text: '# ❌ 错误:写在 Dockerfile ENV 里会固化到镜像层' },
|
||||
{ type: 'bad', text: 'ENV OPENAI_API_KEY=sk-xxx <span class="warn-inline">← 任何人都能 docker inspect 取到</span>' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# ✅ 正确:运行时从宿主机环境注入' },
|
||||
{ type: 'highlight', text: 'docker run \\' },
|
||||
{ type: 'highlight', text: ' -e OPENAI_API_KEY="$OPENAI_API_KEY" \\' },
|
||||
{ type: 'highlight', text: ' -e DATABASE_URL="$DATABASE_URL" \\' },
|
||||
{ type: 'highlight', text: ' myapp:latest' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 或用 --env-file(文件不进 Git)' },
|
||||
{ type: 'good', text: 'docker run --env-file .env myapp:latest' }
|
||||
],
|
||||
tips: [
|
||||
{ level: 'safe', text: '镜像本身不含任何密钥,可以安全上传到公开 Registry' },
|
||||
{ level: 'safe', text: '--env-file 在运行时读取,文件不需要进入镜像' },
|
||||
{ level: 'warn', text: 'docker history 可以查看所有镜像层内容——写在 Dockerfile ENV 里就永远泄露了' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentScenario = computed(() => scenarioData[current.value])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: bold; color: var(--vp-c-text-1); }
|
||||
.demo-header .subtitle { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-btn:hover { border-color: var(--vp-c-brand); color: var(--vp-c-brand); }
|
||||
.tab-btn.active { background: var(--vp-c-brand); border-color: var(--vp-c-brand); color: white; }
|
||||
|
||||
.scenario-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.scenario-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.code-block {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.code-area {
|
||||
background: #1e1e2e;
|
||||
padding: 0.45rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
padding: 0 0.7rem;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.code-line.highlight { background: color-mix(in srgb, var(--vp-c-brand) 8%, transparent); }
|
||||
.code-line.good { background: color-mix(in srgb, #4ade80 6%, transparent); }
|
||||
.code-line.bad { background: color-mix(in srgb, #f87171 10%, transparent); }
|
||||
|
||||
.line-content { color: #cdd6f4; white-space: pre; }
|
||||
.code-line.comment .line-content { color: #6c7086; font-style: italic; }
|
||||
.code-line.bad .line-content { color: #f38ba8; }
|
||||
.code-line.good .line-content { color: #a6e3a1; }
|
||||
|
||||
:deep(.warn-inline) { color: #f87171; font-size: 0.7em; }
|
||||
|
||||
.tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 5px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
|
||||
.tip.safe { border-left-color: var(--vp-c-green-1); }
|
||||
.tip.warn { border-left-color: var(--vp-c-yellow-1, #f59e0b); }
|
||||
.tip.info { border-left-color: var(--vp-c-brand); }
|
||||
|
||||
.tip-dot { flex-shrink: 0; margin-top: 5px; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.tip.safe .tip-dot { color: var(--vp-c-green-1); }
|
||||
.tip.warn .tip-dot { color: var(--vp-c-yellow-1, #f59e0b); }
|
||||
.tip.info .tip-dot { color: var(--vp-c-brand); }
|
||||
|
||||
.tip-text {
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: var(--vp-c-text-1); }
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div class="component-tree-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">组件化拆分</span>
|
||||
<span class="subtitle">一个页面如何拆成多个独立组件</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-body">
|
||||
<div class="tree-panel">
|
||||
<div class="tree-title">组件树结构</div>
|
||||
<div class="tree-list">
|
||||
<div
|
||||
v-for="comp in components"
|
||||
:key="comp.id"
|
||||
:class="['tree-item', { active: selected === comp.id }]"
|
||||
:style="{ paddingLeft: comp.depth * 1 + 'rem' }"
|
||||
@click="selected = comp.id"
|
||||
>
|
||||
<span class="tree-icon">{{ comp.icon }}</span>
|
||||
<span class="tree-name">{{ comp.name }}</span>
|
||||
<span v-if="comp.reused" class="reuse-badge">×{{ comp.reused }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-panel">
|
||||
<div class="tree-title">页面预览</div>
|
||||
<div class="page-mock">
|
||||
<div
|
||||
:class="['mock-navbar', { highlighted: selected === 'navbar' }]"
|
||||
@click="selected = 'navbar'"
|
||||
>
|
||||
<span>🏠 电商网站</span>
|
||||
<span
|
||||
:class="['mock-search', { highlighted: selected === 'search' }]"
|
||||
@click.stop="selected = 'search'"
|
||||
>🔍 搜索框</span>
|
||||
<span
|
||||
:class="['mock-cart-icon', { highlighted: selected === 'cart' }]"
|
||||
@click.stop="selected = 'cart'"
|
||||
>🛒 购物车(3)</span>
|
||||
</div>
|
||||
<div class="mock-content">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:class="['mock-product-card', { highlighted: selected === 'product' }]"
|
||||
@click="selected = 'product'"
|
||||
>
|
||||
<div class="mock-img">📦</div>
|
||||
<div class="mock-info">
|
||||
<div class="mock-product-name">商品 {{ i }}</div>
|
||||
<div class="mock-price">¥{{ i * 99 + 100 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="['mock-footer', { highlighted: selected === 'footer' }]"
|
||||
@click="selected = 'footer'"
|
||||
>
|
||||
© 2025 电商网站
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedComp" class="detail-card">
|
||||
<div class="detail-name">{{ selectedComp.icon }} {{ selectedComp.name }}</div>
|
||||
<div class="detail-desc">{{ selectedComp.desc }}</div>
|
||||
<div class="detail-tags">
|
||||
<span class="detail-tag">数据独立</span>
|
||||
<span class="detail-tag">样式隔离</span>
|
||||
<span v-if="selectedComp.reused" class="detail-tag reuse">
|
||||
复用 {{ selectedComp.reused }} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span>组件化就是把一个大页面拆成多个独立的小块。每个组件管理自己的数据、界面和样式,互不干扰。同一个组件可以在不同地方复用多次,传入不同的数据就会显示不同的内容。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const selected = ref('product')
|
||||
|
||||
const components = [
|
||||
{ id: 'app', name: 'App(根组件)', icon: '📱', depth: 0, desc: '整个应用的根组件,包含所有其他组件。' },
|
||||
{ id: 'navbar', name: 'NavBar(导航栏)', icon: '🧭', depth: 1, desc: '页面顶部的导航栏,包含 Logo、搜索框和购物车入口。' },
|
||||
{ id: 'search', name: 'SearchBox(搜索框)', icon: '🔍', depth: 2, desc: '独立的搜索框组件,管理搜索关键词和搜索结果。' },
|
||||
{ id: 'cart', name: 'CartIcon(购物车图标)', icon: '🛒', depth: 2, desc: '显示购物车数量的小图标,数据来自全局购物车状态。' },
|
||||
{ id: 'product', name: 'ProductCard(商品卡片)', icon: '📦', depth: 1, reused: 3, desc: '单个商品的展示卡片。写一次代码,传入不同的商品数据就能复用多次,每次显示不同的商品信息。' },
|
||||
{ id: 'footer', name: 'Footer(页脚)', icon: '📄', depth: 1, desc: '页面底部信息,一般包含版权声明等。' }
|
||||
]
|
||||
|
||||
const selectedComp = computed(() => components.find(c => c.id === selected.value))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.component-tree-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.demo-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-panel,
|
||||
.preview-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.tree-item.active {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tree-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.reuse-badge {
|
||||
margin-left: auto;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-mock {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mock-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mock-search,
|
||||
.mock-cart-icon {
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mock-content {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mock-product-card {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mock-img {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.mock-product-name {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mock-price {
|
||||
font-size: 0.65rem;
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.mock-footer {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
outline: 2px solid var(--vp-c-brand);
|
||||
outline-offset: -1px;
|
||||
background: rgba(59, 130, 246, 0.06) !important;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-tag.reuse {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.demo-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mock-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="data-ui-gap-demo">
|
||||
<div class="two-panels">
|
||||
<div class="panel data-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-badge data">数据(JavaScript 变量)</span>
|
||||
</div>
|
||||
<div class="data-display">
|
||||
<div class="data-row">
|
||||
<span class="data-key">商品数量</span>
|
||||
<span class="data-val">{{ dataCount }}</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-key">总价</span>
|
||||
<span class="data-val">¥{{ dataCount * 99 }}</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-key">状态</span>
|
||||
<span class="data-val">{{ dataCount > 5 ? '过多' : '正常' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn" @click="addItem">添加商品(修改数据)</button>
|
||||
</div>
|
||||
|
||||
<div class="gap-indicator" :class="{ desynced: isDesynced }">
|
||||
<div class="gap-line" />
|
||||
<span class="gap-label">{{ isDesynced ? '❌ 不同步' : '✅ 同步' }}</span>
|
||||
<div class="gap-line" />
|
||||
</div>
|
||||
|
||||
<div class="panel ui-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-badge ui">界面(用户看到的)</span>
|
||||
</div>
|
||||
<div class="ui-display">
|
||||
<div class="ui-row" :class="{ stale: uiCount !== dataCount }">
|
||||
<span class="ui-key">购物车</span>
|
||||
<span class="ui-val">{{ uiCount }} 件</span>
|
||||
</div>
|
||||
<div class="ui-row" :class="{ stale: uiCount !== dataCount }">
|
||||
<span class="ui-key">总价</span>
|
||||
<span class="ui-val">¥{{ uiCount * 99 }}</span>
|
||||
</div>
|
||||
<div class="ui-row" :class="{ stale: uiCount !== dataCount }">
|
||||
<span class="ui-key">状态</span>
|
||||
<span class="ui-val">{{ uiCount > 5 ? '过多' : '正常' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sync-btn" :disabled="!isDesynced" @click="syncUI">
|
||||
{{ isDesynced ? '手动同步界面' : '已同步' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-row">
|
||||
<button class="action-btn outline" @click="reset">重置</button>
|
||||
<span v-if="desyncCount > 0" class="desync-stat">
|
||||
累计不同步 {{ desyncCount }} 次
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心问题:</strong>
|
||||
<span>在没有框架的情况下,数据变了,界面不会自动跟着变。你必须自己写代码去更新界面,一旦忘了,用户看到的就是过时的、错误的信息。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const dataCount = ref(0)
|
||||
const uiCount = ref(0)
|
||||
const desyncCount = ref(0)
|
||||
|
||||
const isDesynced = computed(() => dataCount.value !== uiCount.value)
|
||||
|
||||
function addItem() {
|
||||
dataCount.value++
|
||||
if (dataCount.value > 1 && isDesynced.value) {
|
||||
desyncCount.value++
|
||||
}
|
||||
}
|
||||
|
||||
function syncUI() {
|
||||
uiCount.value = dataCount.value
|
||||
}
|
||||
|
||||
function reset() {
|
||||
dataCount.value = 0
|
||||
uiCount.value = 0
|
||||
desyncCount.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-ui-gap-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.two-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
text-align: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.panel-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-badge.data {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.panel-badge.ui {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.data-display,
|
||||
.ui-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.data-row,
|
||||
.ui-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.ui-row.stale {
|
||||
border-color: var(--vp-c-danger-1);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
.data-key,
|
||||
.ui-key {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.data-val,
|
||||
.ui-val {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.gap-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.gap-line {
|
||||
width: 2px;
|
||||
height: 2rem;
|
||||
background: var(--vp-c-green-1);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.gap-indicator.desynced .gap-line {
|
||||
background: var(--vp-c-danger-1);
|
||||
animation: pulse-line 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-line {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.gap-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover { opacity: 0.85; }
|
||||
|
||||
.action-btn.outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.action-btn.outline:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.sync-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.controls-row .action-btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.desync-stat {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-danger-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.two-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.gap-indicator {
|
||||
flex-direction: row;
|
||||
padding-top: 0;
|
||||
}
|
||||
.gap-line {
|
||||
width: 2rem;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="declarative-formula-demo">
|
||||
<div class="formula-row">
|
||||
<div class="formula-box state-box">
|
||||
<div class="formula-label">State(数据)</div>
|
||||
</div>
|
||||
<div class="formula-arrow">→ f →</div>
|
||||
<div class="formula-box ui-box">
|
||||
<div class="formula-label">UI(界面)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-body">
|
||||
<div class="input-panel">
|
||||
<div class="panel-title">修改数据(State)</div>
|
||||
<div class="input-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="username" type="text" placeholder="输入名字" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>商品数量</label>
|
||||
<div class="stepper">
|
||||
<button @click="count = Math.max(0, count - 1)">-</button>
|
||||
<span class="stepper-value">{{ count }}</span>
|
||||
<button @click="count++">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>深色模式</label>
|
||||
<label class="toggle-switch">
|
||||
<input v-model="darkMode" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-panel" :class="{ dark: darkMode }">
|
||||
<div class="panel-title">渲染结果(UI)</div>
|
||||
<div class="preview-card">
|
||||
<div class="preview-greeting">
|
||||
{{ username ? `你好,${username}!` : '你好,访客!' }}
|
||||
</div>
|
||||
<div class="preview-cart">
|
||||
购物车:{{ count }} 件商品
|
||||
</div>
|
||||
<div class="preview-total">
|
||||
总价:¥{{ count * 99 }}
|
||||
</div>
|
||||
<div v-if="count > 5" class="preview-warning">
|
||||
商品数量较多,请确认订单
|
||||
</div>
|
||||
<div class="preview-theme">
|
||||
当前主题:{{ darkMode ? '深色' : '浅色' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-snapshot">
|
||||
<div class="snapshot-title">当前 State 快照</div>
|
||||
<code class="snapshot-code">{{ stateSnapshot }}</code>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span>你只需要修改数据(State),框架会根据数据自动渲染出对应的界面(UI)。同样的数据永远渲染出同样的界面,这就是 UI = f(State)。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const username = ref('')
|
||||
const count = ref(2)
|
||||
const darkMode = ref(false)
|
||||
|
||||
const stateSnapshot = computed(() =>
|
||||
JSON.stringify(
|
||||
{ username: username.value || '(空)', count: count.value, darkMode: darkMode.value },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.declarative-formula-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.formula-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.formula-box {
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.ui-box {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.formula-arrow {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.input-panel,
|
||||
.output-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.output-panel.dark {
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.output-panel.dark .preview-card {
|
||||
background: #16213e;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.output-panel.dark .panel-title {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-group input[type="text"] {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.input-group input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stepper button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stepper button:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stepper-value {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 18px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 1px;
|
||||
bottom: 1px;
|
||||
background: var(--vp-c-text-2);
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
input:checked + .slider::before {
|
||||
transform: translateX(18px);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.preview-greeting {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-warning {
|
||||
color: var(--vp-c-danger-1);
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.output-panel.dark .preview-warning {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.preview-theme {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.state-snapshot {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.snapshot-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.snapshot-code {
|
||||
display: block;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-1);
|
||||
white-space: pre;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.demo-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div class="dom-cost-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">DOM 操作耗时对比</span>
|
||||
<span class="subtitle">逐个操作 vs 批量操作</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="control-group">
|
||||
<label>修改次数</label>
|
||||
<div class="radio-group">
|
||||
<button
|
||||
v-for="n in counts"
|
||||
:key="n"
|
||||
:class="['radio-btn', { active: selectedCount === n }]"
|
||||
@click="selectedCount = n"
|
||||
>
|
||||
{{ n }} 次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn" :disabled="isRunning" @click="runComparison">
|
||||
{{ isRunning ? '执行中...' : '开始对比' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="comparison-row">
|
||||
<div class="method-card">
|
||||
<div class="method-header">
|
||||
<span class="method-badge slow">逐个操作 DOM</span>
|
||||
</div>
|
||||
<div class="method-desc">
|
||||
每修改一次数据 → 立刻操作一次真实 DOM → 浏览器每次都要重新布局和绘制
|
||||
</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar-bg">
|
||||
<div
|
||||
class="progress-bar-fill slow"
|
||||
:style="{ width: slowProgress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">模拟耗时</span>
|
||||
<span class="result-value" :class="{ highlight: showResults }">
|
||||
{{ showResults ? slowTime + 'ms' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="step-list">
|
||||
<div class="step-item" v-for="i in Math.min(selectedCount, 4)" :key="i">
|
||||
<span class="step-num">{{ i }}</span>
|
||||
<span class="step-text">修改 → 布局 → 绘制</span>
|
||||
</div>
|
||||
<div v-if="selectedCount > 4" class="step-item ellipsis">
|
||||
<span class="step-text">... 重复 {{ selectedCount - 4 }} 次 ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="method-card">
|
||||
<div class="method-header">
|
||||
<span class="method-badge fast">批量计算后一次性操作</span>
|
||||
</div>
|
||||
<div class="method-desc">
|
||||
所有修改先在内存中计算好 → 最后只操作一次真实 DOM → 浏览器只需要重新布局和绘制一次
|
||||
</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar-bg">
|
||||
<div
|
||||
class="progress-bar-fill fast"
|
||||
:style="{ width: fastProgress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">模拟耗时</span>
|
||||
<span class="result-value" :class="{ highlight: showResults }">
|
||||
{{ showResults ? fastTime + 'ms' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="step-list">
|
||||
<div class="step-item">
|
||||
<span class="step-num">1</span>
|
||||
<span class="step-text">内存中计算 {{ selectedCount }} 次变化</span>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<span class="step-num">2</span>
|
||||
<span class="step-text">一次性提交 → 布局 → 绘制</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showResults" class="savings-banner">
|
||||
批量操作节省了 <strong>{{ savingsPercent }}%</strong> 的耗时
|
||||
({{ slowTime }}ms → {{ fastTime }}ms)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span>DOM 操作的真正代价不是"修改值"本身,而是每次修改后浏览器必须执行的"重新布局 + 重新绘制"。减少 DOM 操作次数,就是减少这些昂贵的计算。虚拟 DOM 的作用就是先在内存中算好所有变化,最后一次性提交。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const counts = [5, 20, 100, 500]
|
||||
const selectedCount = ref(20)
|
||||
const isRunning = ref(false)
|
||||
const slowProgress = ref(0)
|
||||
const fastProgress = ref(0)
|
||||
const showResults = ref(false)
|
||||
|
||||
const COST_PER_OP = 3
|
||||
const BATCH_OVERHEAD = 8
|
||||
|
||||
const slowTime = computed(() => selectedCount.value * COST_PER_OP)
|
||||
const fastTime = computed(() => Math.round(BATCH_OVERHEAD + selectedCount.value * 0.1))
|
||||
const savingsPercent = computed(() =>
|
||||
Math.round((1 - fastTime.value / slowTime.value) * 100)
|
||||
)
|
||||
|
||||
async function runComparison() {
|
||||
if (isRunning.value) return
|
||||
isRunning.value = true
|
||||
showResults.value = false
|
||||
slowProgress.value = 0
|
||||
fastProgress.value = 0
|
||||
|
||||
const totalSlow = slowTime.value
|
||||
const totalFast = fastTime.value
|
||||
const duration = Math.min(totalSlow * 2, 2000)
|
||||
const steps = 30
|
||||
const stepDelay = duration / steps
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await new Promise(r => setTimeout(r, stepDelay))
|
||||
slowProgress.value = Math.min((i / steps) * 100, 100)
|
||||
const fastRatio = totalFast / totalSlow
|
||||
fastProgress.value = Math.min((i / steps / fastRatio) * 100, 100)
|
||||
}
|
||||
|
||||
slowProgress.value = 100
|
||||
fastProgress.value = 100
|
||||
showResults.value = true
|
||||
isRunning.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dom-cost-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.radio-btn {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.radio-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.radio-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.8rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.comparison-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.method-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.method-header {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.method-badge.slow {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.method-badge.fast {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.method-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.6rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
height: 8px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.progress-bar-fill.slow {
|
||||
background: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.progress-bar-fill.fast {
|
||||
background: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.result-value.highlight {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.step-item.ellipsis {
|
||||
padding-left: 1.4rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.savings-banner {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.comparison-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="framework-motivation">
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
问题
|
||||
</div>
|
||||
<ul>
|
||||
<li>数据变化时,手动更新 DOM 容易遗漏</li>
|
||||
<li>页面越复杂,需要同步的地方越多,越容易出 bug</li>
|
||||
<li>多人协作时,DOM 操作散落各处,维护成本高</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
根本原因
|
||||
</div>
|
||||
<ul>
|
||||
<li>浏览器不知道"数据"和"界面"的对应关系</li>
|
||||
<li>原生 DOM API 只提供底层操作,没有"数据变了就更新 UI"的能力</li>
|
||||
<li>开发者被迫充当"人肉同步器"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
框架的解法
|
||||
</div>
|
||||
<ul>
|
||||
<li>建立数据到 UI 的映射关系(UI = f(State))</li>
|
||||
<li>自动检测数据变化(响应式系统)</li>
|
||||
<li>自动计算最小 DOM 更新(虚拟 DOM / 编译优化)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
<style scoped>
|
||||
.framework-motivation {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.framework-motivation {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">框架光谱</span>
|
||||
<span class="subtitle">运行时 ↔ 编译时</span>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="spectrum-wrapper">
|
||||
<div class="spectrum-labels">
|
||||
<span class="spectrum-label-left">更多运行时</span>
|
||||
<span class="spectrum-label-right">更多编译时</span>
|
||||
</div>
|
||||
<div class="spectrum-bar">
|
||||
<button
|
||||
v-for="fw in frameworks"
|
||||
:key="fw.id"
|
||||
:class="['spectrum-dot', { selected: selectedId === fw.id }]"
|
||||
:style="{ left: fw.percent + '%' }"
|
||||
:title="fw.name"
|
||||
@click="selectFramework(fw.id)"
|
||||
>
|
||||
{{ fw.short }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="spectrum-dot-labels">
|
||||
<span
|
||||
v-for="fw in frameworks"
|
||||
:key="'label-' + fw.id"
|
||||
class="dot-label"
|
||||
:style="{ left: fw.percent + '%' }"
|
||||
>
|
||||
{{ fw.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-header">
|
||||
<span class="detail-emoji">{{ selected.emoji }}</span>
|
||||
<span class="detail-name">{{ selected.name }}</span>
|
||||
</div>
|
||||
<div class="detail-summary">{{ selected.summary }}</div>
|
||||
<div class="work-bars">
|
||||
<div class="work-bar-row">
|
||||
<span class="work-label">运行时工作量</span>
|
||||
<div class="work-bar-track">
|
||||
<div
|
||||
class="work-bar-fill runtime"
|
||||
:style="{ width: selected.runtimePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="work-value">{{ selected.runtimePercent }}%</span>
|
||||
</div>
|
||||
<div class="work-bar-row">
|
||||
<span class="work-label">编译时工作量</span>
|
||||
<div class="work-bar-track">
|
||||
<div
|
||||
class="work-bar-fill compile"
|
||||
:style="{ width: selected.compilePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="work-value">{{ selected.compilePercent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">打包体积</span>
|
||||
<span class="meta-value">{{ selected.bundleSize }}</span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">开发体验</span>
|
||||
<span class="meta-value">{{ selected.devExperience }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>趋势:</strong>
|
||||
{{ selected.trendMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const FRAMEWORKS = {
|
||||
react: {
|
||||
id: 'react',
|
||||
name: 'React',
|
||||
short: 'R',
|
||||
emoji: '⚛️',
|
||||
percent: 20,
|
||||
runtimePercent: 80,
|
||||
compilePercent: 20,
|
||||
bundleSize: '中等',
|
||||
devExperience: '★★★★☆',
|
||||
summary: '运行时为主:虚拟 DOM + Reconciliation',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
},
|
||||
vue3: {
|
||||
id: 'vue3',
|
||||
name: 'Vue 3',
|
||||
short: 'V',
|
||||
emoji: '💚',
|
||||
percent: 40,
|
||||
runtimePercent: 60,
|
||||
compilePercent: 40,
|
||||
bundleSize: '中等',
|
||||
devExperience: '★★★★★',
|
||||
summary: '混合:编译优化模板 + 运行时虚拟 DOM',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
},
|
||||
vapor: {
|
||||
id: 'vapor',
|
||||
name: 'Vue Vapor',
|
||||
short: 'Vp',
|
||||
emoji: '🌫️',
|
||||
percent: 60,
|
||||
runtimePercent: 40,
|
||||
compilePercent: 60,
|
||||
bundleSize: '较小',
|
||||
devExperience: '★★★★☆',
|
||||
summary: '编译时为主:跳过虚拟 DOM,编译生成直接操作',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
},
|
||||
svelte: {
|
||||
id: 'svelte',
|
||||
name: 'Svelte',
|
||||
short: 'S',
|
||||
emoji: '🔥',
|
||||
percent: 80,
|
||||
runtimePercent: 20,
|
||||
compilePercent: 80,
|
||||
bundleSize: '最小',
|
||||
devExperience: '★★★★☆',
|
||||
summary: '编译时为主:编译时生成精确 DOM 更新代码',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
},
|
||||
solid: {
|
||||
id: 'solid',
|
||||
name: 'Solid.js',
|
||||
short: 'Sd',
|
||||
emoji: '⬆️',
|
||||
percent: 90,
|
||||
runtimePercent: 10,
|
||||
compilePercent: 90,
|
||||
bundleSize: '最小',
|
||||
devExperience: '★★★★☆',
|
||||
summary: '纯编译时:细粒度响应式,无虚拟 DOM',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
}
|
||||
}
|
||||
|
||||
const frameworks = Object.values(FRAMEWORKS)
|
||||
const selectedId = ref('vue3')
|
||||
|
||||
const selected = computed(() => FRAMEWORKS[selectedId.value] ?? FRAMEWORKS.vue3)
|
||||
|
||||
function selectFramework(id) {
|
||||
selectedId.value = id
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.spectrum-wrapper {
|
||||
position: relative;
|
||||
margin: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.spectrum-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.spectrum-bar {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--vp-c-brand),
|
||||
var(--vp-c-green-1)
|
||||
);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.spectrum-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.spectrum-dot:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.spectrum-dot.selected {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 10px var(--vp-c-brand);
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.spectrum-dot-labels {
|
||||
position: relative;
|
||||
height: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dot-label {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-emoji {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.work-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.work-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.work-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
width: 5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.work-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.work-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.work-bar-fill.runtime {
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.work-bar-fill.compile {
|
||||
background: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.work-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dot-label {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.spectrum-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div class="sync-demo">
|
||||
<div class="comparison-container">
|
||||
<div class="side manual-side">
|
||||
<div class="side-header">
|
||||
<span class="badge manual">手动同步 / jQuery 风格</span>
|
||||
</div>
|
||||
|
||||
<div class="cart-control">
|
||||
<button class="action-btn" @click="addManual">添加商品</button>
|
||||
<button class="action-btn outline" @click="resetManual">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="sync-areas">
|
||||
<div
|
||||
v-for="area in manualAreas"
|
||||
:key="area.id"
|
||||
class="sync-area"
|
||||
:class="{ synced: area.synced, unsynced: !area.synced }"
|
||||
>
|
||||
<div class="area-header">
|
||||
<span class="area-icon">{{ area.icon }}</span>
|
||||
<span class="area-name">{{ area.name }}</span>
|
||||
<span class="sync-badge" :class="{ synced: area.synced }">
|
||||
{{ area.synced ? '已同步' : '未同步' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="area-value">{{ area.synced ? area.actual : area.stale }}</div>
|
||||
<button
|
||||
v-if="!area.synced"
|
||||
class="sync-btn"
|
||||
@click="syncArea(area)"
|
||||
>
|
||||
手动同步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="miss-counter">
|
||||
<span class="miss-label">遗漏次数:</span>
|
||||
<span class="miss-value" :class="{ danger: missCount > 0 }">{{ missCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vs-divider">
|
||||
<div class="vs-badge">VS</div>
|
||||
</div>
|
||||
|
||||
<div class="side auto-side">
|
||||
<div class="side-header">
|
||||
<span class="badge auto">自动同步 / 框架风格</span>
|
||||
</div>
|
||||
|
||||
<div class="cart-control">
|
||||
<button class="action-btn" @click="addAuto">添加商品</button>
|
||||
<button class="action-btn outline" @click="resetAuto">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="sync-areas">
|
||||
<div
|
||||
v-for="area in autoAreas"
|
||||
:key="area.id"
|
||||
class="sync-area synced"
|
||||
>
|
||||
<div class="area-header">
|
||||
<span class="area-icon">{{ area.icon }}</span>
|
||||
<span class="area-name">{{ area.name }}</span>
|
||||
<span class="sync-badge synced">已同步</span>
|
||||
</div>
|
||||
<div class="area-value">{{ area.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="miss-counter">
|
||||
<span class="miss-label">遗漏次数:</span>
|
||||
<span class="miss-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span>前端框架的本质价值在于"自动同步"——你只需修改数据,框架保证所有依赖该数据的 UI 自动更新,不会遗漏。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
|
||||
const products = ['耳机 ¥99', '键盘 ¥199', '鼠标 ¥59', '显示器 ¥1299', '摄像头 ¥149', '音箱 ¥79']
|
||||
let productIndex = ref(0)
|
||||
|
||||
const manualCount = ref(0)
|
||||
const manualItems = ref([])
|
||||
const missCount = ref(0)
|
||||
let pendingManualCount = 0
|
||||
|
||||
const manualAreas = reactive([
|
||||
{
|
||||
id: 'count',
|
||||
icon: '🔴',
|
||||
name: '购物车数量',
|
||||
synced: true,
|
||||
stale: '0 件',
|
||||
actual: '0 件'
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
icon: '📋',
|
||||
name: '商品列表',
|
||||
synced: true,
|
||||
stale: '(空)',
|
||||
actual: '(空)'
|
||||
},
|
||||
{
|
||||
id: 'total',
|
||||
icon: '💰',
|
||||
name: '总价',
|
||||
synced: true,
|
||||
stale: '¥0',
|
||||
actual: '¥0'
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
icon: '⚠️',
|
||||
name: '状态提示',
|
||||
synced: true,
|
||||
stale: '正常',
|
||||
actual: '正常'
|
||||
}
|
||||
])
|
||||
|
||||
function addManual() {
|
||||
const name = products[productIndex.value % products.length]
|
||||
productIndex.value++
|
||||
manualCount.value++
|
||||
manualItems.value.push(name)
|
||||
pendingManualCount = manualCount.value
|
||||
|
||||
const price = parseInt(name.match(/¥(\d+)/)[1])
|
||||
const totalPrice = manualItems.value.reduce((sum, item) => {
|
||||
return sum + parseInt(item.match(/¥(\d+)/)[1])
|
||||
}, 0)
|
||||
|
||||
manualAreas[0].actual = `${manualCount.value} 件`
|
||||
manualAreas[0].synced = false
|
||||
|
||||
manualAreas[1].actual = manualItems.value.join('、')
|
||||
manualAreas[1].synced = false
|
||||
|
||||
manualAreas[2].actual = `¥${totalPrice}`
|
||||
manualAreas[2].synced = false
|
||||
|
||||
manualAreas[3].actual = manualCount.value > 5 ? '⚠️ 商品过多!' : '正常'
|
||||
manualAreas[3].synced = false
|
||||
|
||||
const unsyncedBefore = manualAreas.filter(a => !a.synced).length
|
||||
if (unsyncedBefore > 0 && manualCount.value > 1) {
|
||||
missCount.value++
|
||||
}
|
||||
}
|
||||
|
||||
function syncArea(area) {
|
||||
area.synced = true
|
||||
area.stale = area.actual
|
||||
}
|
||||
|
||||
function resetManual() {
|
||||
manualCount.value = 0
|
||||
manualItems.value = []
|
||||
missCount.value = 0
|
||||
pendingManualCount = 0
|
||||
manualAreas.forEach(a => {
|
||||
a.synced = true
|
||||
a.stale = a.id === 'count' ? '0 件' : a.id === 'list' ? '(空)' : a.id === 'total' ? '¥0' : '正常'
|
||||
a.actual = a.stale
|
||||
})
|
||||
}
|
||||
|
||||
const autoCount = ref(0)
|
||||
const autoItems = ref([])
|
||||
|
||||
const autoAreas = computed(() => {
|
||||
const totalPrice = autoItems.value.reduce((sum, item) => {
|
||||
return sum + parseInt(item.match(/¥(\d+)/)[1])
|
||||
}, 0)
|
||||
return [
|
||||
{ id: 'count', icon: '🔴', name: '购物车数量', value: `${autoCount.value} 件` },
|
||||
{ id: 'list', icon: '📋', name: '商品列表', value: autoItems.value.length ? autoItems.value.join('、') : '(空)' },
|
||||
{ id: 'total', icon: '💰', name: '总价', value: `¥${totalPrice}` },
|
||||
{ id: 'status', icon: '⚠️', name: '状态提示', value: autoCount.value > 5 ? '⚠️ 商品过多!' : '正常' }
|
||||
]
|
||||
})
|
||||
|
||||
function addAuto() {
|
||||
const name = products[productIndex.value % products.length]
|
||||
productIndex.value++
|
||||
autoCount.value++
|
||||
autoItems.value.push(name)
|
||||
}
|
||||
|
||||
function resetAuto() {
|
||||
autoCount.value = 0
|
||||
autoItems.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sync-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.side-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.manual {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.badge.auto {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.cart-control {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.action-btn.outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.action-btn.outline:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.sync-areas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sync-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sync-area.synced {
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.sync-area.unsynced {
|
||||
border-color: var(--vp-c-danger-1);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.area-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.area-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.area-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sync-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
background: var(--vp-c-danger-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sync-badge.synced {
|
||||
background: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.area-value {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
border: 1px solid var(--vp-c-danger-1);
|
||||
color: var(--vp-c-danger-1);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sync-btn:hover {
|
||||
background: var(--vp-c-danger-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.miss-counter {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.miss-label {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.miss-value {
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.miss-value.danger {
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comparison-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div
|
||||
class="reactivity-mechanism-demo"
|
||||
:style="{ '--tab-accent': currentTab?.color ?? 'var(--vp-c-brand)' }"
|
||||
>
|
||||
<div class="toggle-bar">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['toggle-btn', { active: activeTab === tab.id }]"
|
||||
:style="activeTab === tab.id ? { borderColor: tab.color, background: tab.color } : {}"
|
||||
@click="switchTab(tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="counter-row">
|
||||
<span class="counter-label">count:</span>
|
||||
<span class="counter-value">{{ count }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="modify-btn"
|
||||
:disabled="isAnimating"
|
||||
@click="modifyData"
|
||||
>
|
||||
修改数据
|
||||
</button>
|
||||
|
||||
<div class="steps-title">引擎盖下</div>
|
||||
<div class="steps-list">
|
||||
<div
|
||||
v-for="(step, idx) in currentSteps"
|
||||
:key="idx"
|
||||
:class="['step-item', stepState(idx)]"
|
||||
:style="stepStyle(idx)"
|
||||
>
|
||||
<span class="step-badge">{{ idx + 1 }}</span>
|
||||
<span class="step-text">{{ step }}</span>
|
||||
<span v-if="stepStatus(idx) === 'done'" class="step-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
{{ infoMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const TABS = {
|
||||
vue: {
|
||||
id: 'vue',
|
||||
label: 'Vue (Proxy)',
|
||||
color: 'var(--vp-c-green-1)',
|
||||
steps: [
|
||||
'count = 1 → Proxy 的 set 陷阱被触发',
|
||||
'通知依赖收集器:"count 变了"',
|
||||
'找到所有依赖 count 的组件',
|
||||
'自动更新 DOM'
|
||||
],
|
||||
info: 'Vue 通过 Proxy 自动拦截数据读写,开发者无需额外操作——写法最自然。'
|
||||
},
|
||||
react: {
|
||||
id: 'react',
|
||||
label: 'React (setState)',
|
||||
color: 'var(--vp-c-brand)',
|
||||
steps: [
|
||||
'调用 setCount(count + 1)',
|
||||
'React 将更新加入队列',
|
||||
'批量处理队列,触发 re-render',
|
||||
'虚拟 DOM Diff → 更新真实 DOM'
|
||||
],
|
||||
info: 'React 要求显式调用 setState,虽然多一步,但数据流更可预测。'
|
||||
},
|
||||
svelte: {
|
||||
id: 'svelte',
|
||||
label: 'Svelte (编译器)',
|
||||
color: 'var(--vp-c-warning-1)',
|
||||
steps: [
|
||||
'count += 1 被编译器识别为赋值',
|
||||
'编译时已生成 $$invalidate(count)',
|
||||
'直接更新对应的 DOM 节点(无 Diff)',
|
||||
'零运行时开销'
|
||||
],
|
||||
info: 'Svelte 在编译时完成分析,运行时零开销——但依赖编译器魔法。'
|
||||
}
|
||||
}
|
||||
|
||||
const activeTab = ref('vue')
|
||||
const count = ref(0)
|
||||
const currentStepIndex = ref(-1)
|
||||
const isAnimating = ref(false)
|
||||
|
||||
const tabs = computed(() => Object.values(TABS))
|
||||
|
||||
const currentTab = computed(() => TABS[activeTab.value])
|
||||
|
||||
const currentSteps = computed(() => currentTab.value?.steps ?? [])
|
||||
|
||||
const infoMessage = computed(() => currentTab.value?.info ?? '')
|
||||
|
||||
function stepState(idx) {
|
||||
if (currentStepIndex.value < idx) return 'pending'
|
||||
if (currentStepIndex.value === idx) return 'active'
|
||||
return 'done'
|
||||
}
|
||||
|
||||
function stepStatus(idx) {
|
||||
if (currentStepIndex.value < idx) return 'pending'
|
||||
if (currentStepIndex.value === idx) return 'active'
|
||||
return 'done'
|
||||
}
|
||||
|
||||
function stepStyle(idx) {
|
||||
if (currentStepIndex.value !== idx) return {}
|
||||
const color = currentTab.value?.color ?? 'var(--vp-c-brand)'
|
||||
return {
|
||||
borderColor: color,
|
||||
boxShadow: `0 0 8px color-mix(in srgb, ${color} 40%, transparent)`
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(id) {
|
||||
if (isAnimating.value) return
|
||||
activeTab.value = id
|
||||
currentStepIndex.value = -1
|
||||
}
|
||||
|
||||
async function modifyData() {
|
||||
if (isAnimating.value) return
|
||||
isAnimating.value = true
|
||||
count.value += 1
|
||||
currentStepIndex.value = -1
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
currentStepIndex.value = i
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
currentStepIndex.value = 4
|
||||
isAnimating.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reactivity-mechanism-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.toggle-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.counter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.counter-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.counter-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.modify-btn {
|
||||
display: block;
|
||||
margin: 0 auto 1rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modify-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.modify-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.steps-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.step-item.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-item.active .step-badge {
|
||||
background: var(--tab-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-item.done .step-badge {
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-check {
|
||||
color: var(--vp-c-green-1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-item.done {
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.toggle-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">虚拟 DOM Diff 过程</span>
|
||||
<span class="subtitle">最小化 DOM 更新的核心机制</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="isModified"
|
||||
@click="modifyData"
|
||||
>
|
||||
修改数据
|
||||
</button>
|
||||
<button
|
||||
class="outline-btn"
|
||||
:disabled="!isModified"
|
||||
@click="reset"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="columns-row">
|
||||
<div class="column">
|
||||
<div class="column-title">Old VTree</div>
|
||||
<div class="tree-container">
|
||||
<div class="tree-node tree-root">div.app</div>
|
||||
<div class="tree-children">
|
||||
<div class="tree-node">h1: 待办清单</div>
|
||||
<div class="tree-node">ul.list</div>
|
||||
<div class="tree-children">
|
||||
<div class="tree-node">li: 学习 Vue</div>
|
||||
<div class="tree-node">li: 写作业</div>
|
||||
<div class="tree-node">li: 打游戏</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="column-title">Diff Result</div>
|
||||
<div v-if="isModified" class="diff-badges">
|
||||
<span class="badge badge-modified">修改: 1 个节点</span>
|
||||
<span class="badge badge-new">新增: 1 个节点</span>
|
||||
</div>
|
||||
<div class="tree-container">
|
||||
<div class="tree-node tree-root">div.app</div>
|
||||
<div class="tree-children">
|
||||
<div class="tree-node node-unchanged">h1: 待办清单</div>
|
||||
<div class="tree-node node-unchanged">ul.list</div>
|
||||
<div class="tree-children">
|
||||
<div
|
||||
:class="[
|
||||
'tree-node',
|
||||
isModified && 'node-unchanged'
|
||||
]"
|
||||
>
|
||||
li: 学习 Vue
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'tree-node',
|
||||
isModified && 'node-modified'
|
||||
]"
|
||||
>
|
||||
li: {{ isModified ? '写代码' : '写作业' }}
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'tree-node',
|
||||
isModified && 'node-unchanged'
|
||||
]"
|
||||
>
|
||||
li: 打游戏
|
||||
</div>
|
||||
<div
|
||||
v-if="isModified"
|
||||
class="tree-node node-new"
|
||||
>
|
||||
li: 看电影
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="column-title">Real DOM</div>
|
||||
<div class="real-dom-preview">
|
||||
<div class="dom-root">
|
||||
<div class="dom-node">div.app</div>
|
||||
<div class="dom-children">
|
||||
<div class="dom-node">h1: 待办清单</div>
|
||||
<ul class="dom-list">
|
||||
<li>学习 Vue</li>
|
||||
<li :class="{ 'dom-changed': isModified }">
|
||||
{{ isModified ? '写代码' : '写作业' }}
|
||||
</li>
|
||||
<li>打游戏</li>
|
||||
<li
|
||||
v-if="isModified"
|
||||
class="dom-new"
|
||||
>
|
||||
看电影
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">7</div>
|
||||
<div class="metric-label">虚拟 DOM 节点总数</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">{{ isModified ? '2' : '0' }}</div>
|
||||
<div class="metric-label">需要更新的真实 DOM</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">{{ isModified ? '71%' : '—' }}</div>
|
||||
<div class="metric-label">节省的 DOM 操作</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
虚拟 DOM 先在内存中对比新旧两棵树,找出最小差异,然后只更新必要的真实 DOM 节点——避免了大量无效操作。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isModified = ref(false)
|
||||
|
||||
function modifyData() {
|
||||
if (isModified.value) return
|
||||
isModified.value = true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
isModified.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.outline-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.outline-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.outline-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.columns-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.diff-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badge-modified {
|
||||
background: rgba(255, 206, 86, 0.2);
|
||||
border: 1px solid var(--vp-c-warning-1);
|
||||
color: var(--vp-c-warning-1);
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.real-dom-preview {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dom-root {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.dom-node {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.dom-children {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.dom-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.dom-list li {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.2rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dom-changed {
|
||||
border-color: var(--vp-c-warning-1);
|
||||
background: rgba(255, 206, 86, 0.1);
|
||||
animation: flash 0.5s ease;
|
||||
}
|
||||
|
||||
.dom-new {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0%,
|
||||
100% {
|
||||
background: rgba(255, 206, 86, 0.1);
|
||||
}
|
||||
50% {
|
||||
background: rgba(255, 206, 86, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tree-container .tree-node {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.tree-container .tree-node.node-modified {
|
||||
border-color: var(--vp-c-warning-1);
|
||||
background: rgba(255, 206, 86, 0.1);
|
||||
}
|
||||
|
||||
.tree-container .tree-node.node-new {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.tree-container .tree-node.node-unchanged {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.columns-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="what-is-dom-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">HTML → DOM 树</span>
|
||||
<span class="subtitle">浏览器如何理解你写的 HTML</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-body">
|
||||
<div class="html-panel">
|
||||
<div class="panel-title">你写的 HTML 代码</div>
|
||||
<div class="code-display">
|
||||
<div
|
||||
v-for="(line, i) in htmlLines"
|
||||
:key="i"
|
||||
:class="['code-line', { highlighted: highlightedTag === line.tag }]"
|
||||
@mouseenter="highlightedTag = line.tag"
|
||||
@mouseleave="highlightedTag = ''"
|
||||
>
|
||||
<span class="line-num">{{ i + 1 }}</span>
|
||||
<span class="line-code" :style="{ paddingLeft: line.indent * 12 + 'px' }">{{ line.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-col">
|
||||
<div class="arrow-label">浏览器解析</div>
|
||||
<div class="arrow-icon">→</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-panel">
|
||||
<div class="panel-title">浏览器生成的 DOM 树</div>
|
||||
<div class="tree-display">
|
||||
<div
|
||||
v-for="node in treeNodes"
|
||||
:key="node.id"
|
||||
:class="['tree-node', { highlighted: highlightedTag === node.tag }]"
|
||||
:style="{ marginLeft: node.depth * 20 + 'px' }"
|
||||
@mouseenter="highlightedTag = node.tag"
|
||||
@mouseleave="highlightedTag = ''"
|
||||
>
|
||||
<span class="connector" v-if="node.depth > 0">└─</span>
|
||||
<span class="node-tag">{{ node.label }}</span>
|
||||
<span v-if="node.text" class="node-text">"{{ node.text }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dom-explain">
|
||||
<div class="explain-item">
|
||||
<span class="explain-icon">📄</span>
|
||||
<div class="explain-content">
|
||||
<strong>节点(Node)</strong>
|
||||
<span>DOM 树上的每一个方块就是一个节点。每个 HTML 标签(如 <code><h1></code>、<code><p></code>)都对应一个节点。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="explain-item">
|
||||
<span class="explain-icon">🌳</span>
|
||||
<div class="explain-content">
|
||||
<strong>父子关系</strong>
|
||||
<span>标签嵌套在另一个标签里面,在 DOM 树上就是父节点和子节点的关系。<code><body></code> 里包含 <code><h1></code>,所以 body 是 h1 的父节点。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="explain-item">
|
||||
<span class="explain-icon">✏️</span>
|
||||
<div class="explain-content">
|
||||
<strong>DOM 操作</strong>
|
||||
<span>JavaScript 可以增加、删除、修改 DOM 树上的节点。修改节点后,浏览器会重新计算布局并重新绘制页面,这就是"DOM 操作"。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>关键概念:</strong>
|
||||
<span>DOM 是浏览器在内存中维护的一棵树,它和你写的 HTML 一一对应。JavaScript 无法直接修改 HTML 文件,它修改的是这棵 DOM 树——浏览器再根据 DOM 树的变化更新屏幕上的显示。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const highlightedTag = ref('')
|
||||
|
||||
const htmlLines = [
|
||||
{ text: '<html>', indent: 0, tag: 'html' },
|
||||
{ text: '<body>', indent: 1, tag: 'body' },
|
||||
{ text: '<h1>我的购物车</h1>', indent: 2, tag: 'h1' },
|
||||
{ text: '<p>共 3 件商品</p>', indent: 2, tag: 'p' },
|
||||
{ text: '<ul>', indent: 2, tag: 'ul' },
|
||||
{ text: '<li>耳机</li>', indent: 3, tag: 'li1' },
|
||||
{ text: '<li>键盘</li>', indent: 3, tag: 'li2' },
|
||||
{ text: '<li>鼠标</li>', indent: 3, tag: 'li3' },
|
||||
{ text: '</ul>', indent: 2, tag: 'ul' },
|
||||
{ text: '<button>结算</button>', indent: 2, tag: 'btn' },
|
||||
{ text: '</body>', indent: 1, tag: 'body' },
|
||||
{ text: '</html>', indent: 0, tag: 'html' }
|
||||
]
|
||||
|
||||
const treeNodes = [
|
||||
{ id: 1, label: 'html', depth: 0, tag: 'html' },
|
||||
{ id: 2, label: 'body', depth: 1, tag: 'body' },
|
||||
{ id: 3, label: 'h1', depth: 2, tag: 'h1', text: '我的购物车' },
|
||||
{ id: 4, label: 'p', depth: 2, tag: 'p', text: '共 3 件商品' },
|
||||
{ id: 5, label: 'ul', depth: 2, tag: 'ul' },
|
||||
{ id: 6, label: 'li', depth: 3, tag: 'li1', text: '耳机' },
|
||||
{ id: 7, label: 'li', depth: 3, tag: 'li2', text: '键盘' },
|
||||
{ id: 8, label: 'li', depth: 3, tag: 'li3', text: '鼠标' },
|
||||
{ id: 9, label: 'button', depth: 2, tag: 'btn', text: '结算' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.what-is-dom-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.demo-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.html-panel,
|
||||
.tree-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.code-line.highlighted {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.line-num {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.65rem;
|
||||
min-width: 1rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.line-code {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.arrow-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.arrow-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--vp-c-text-2);
|
||||
writing-mode: vertical-rl;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tree-display {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tree-node.highlighted {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.connector {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-tag {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 3px;
|
||||
padding: 0 0.3rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.tree-node.highlighted .node-tag {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.node-text {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.dom-explain {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.explain-item {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.explain-icon {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explain-content {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.explain-content strong {
|
||||
display: block;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.explain-content code {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.demo-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.arrow-col {
|
||||
flex-direction: row;
|
||||
padding-top: 0;
|
||||
}
|
||||
.arrow-label {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
.dom-explain {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="why-no-auto-sync-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">变量修改时发生了什么?</span>
|
||||
<span class="subtitle">原生 JavaScript vs 框架</span>
|
||||
</div>
|
||||
|
||||
<div class="toggle-bar">
|
||||
<button
|
||||
:class="['toggle-btn', { active: mode === 'native' }]"
|
||||
@click="switchMode('native')"
|
||||
>
|
||||
原生 JavaScript
|
||||
</button>
|
||||
<button
|
||||
:class="['toggle-btn', { active: mode === 'framework' }]"
|
||||
@click="switchMode('framework')"
|
||||
>
|
||||
使用框架(Vue)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="code-col">
|
||||
<div class="col-title">你写的代码</div>
|
||||
<div class="code-block">
|
||||
<div class="code-line">
|
||||
<span class="code-comment">// 点击按钮时执行</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-highlight', { executing: step >= 1 }]">
|
||||
<span class="code-text">count = count + 1</span>
|
||||
<span v-if="step >= 1" class="step-badge">{{ step >= 1 ? '✓ 执行' : '' }}</span>
|
||||
</div>
|
||||
<template v-if="mode === 'native'">
|
||||
<div class="code-line code-gap" />
|
||||
<div class="code-line">
|
||||
<span class="code-comment">// 你还要手动写下面这些:</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-manual', { executing: step >= 2, missing: step === 1 }]">
|
||||
<span class="code-text">document.getElementById('count')</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-manual', { executing: step >= 2, missing: step === 1 }]">
|
||||
<span class="code-text"> .textContent = count</span>
|
||||
<span v-if="step >= 2" class="step-badge">✓ 手动</span>
|
||||
<span v-else-if="step === 1" class="step-badge miss">需要你写</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-manual', { executing: step >= 3, missing: step < 3 && step >= 1 }]">
|
||||
<span class="code-text">document.getElementById('total')</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-manual', { executing: step >= 3, missing: step < 3 && step >= 1 }]">
|
||||
<span class="code-text"> .textContent = count * 99</span>
|
||||
<span v-if="step >= 3" class="step-badge">✓ 手动</span>
|
||||
<span v-else-if="step >= 1" class="step-badge miss">需要你写</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="code-line code-gap" />
|
||||
<div class="code-line">
|
||||
<span class="code-comment">// 不需要写别的了</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="code-comment">// 框架会自动完成后续步骤</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-col">
|
||||
<div class="col-title">执行流程</div>
|
||||
<div class="flow-steps">
|
||||
<div :class="['flow-step', { active: step >= 1, done: step > 1 }]">
|
||||
<span class="flow-num">1</span>
|
||||
<div class="flow-content">
|
||||
<div class="flow-title">JavaScript 修改变量</div>
|
||||
<div class="flow-desc">count 从 {{ count - 1 }} 变成 {{ count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow" :class="{ active: step >= 1 }">
|
||||
<span v-if="mode === 'native'">{{ step === 1 ? '❌ 到这里就停了' : '↓' }}</span>
|
||||
<span v-else>{{ step >= 1 ? '↓ 框架自动接管' : '↓' }}</span>
|
||||
</div>
|
||||
|
||||
<div :class="['flow-step', { active: step >= 2, done: step > 2, auto: mode === 'framework' }]">
|
||||
<span class="flow-num">2</span>
|
||||
<div class="flow-content">
|
||||
<div class="flow-title">
|
||||
{{ mode === 'native' ? '找到 DOM 节点' : '框架检测到变化' }}
|
||||
</div>
|
||||
<div class="flow-desc">
|
||||
{{ mode === 'native'
|
||||
? '手动调用 document.getElementById()'
|
||||
: 'Proxy 拦截了赋值操作,通知更新系统' }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="mode === 'framework' && step >= 2" class="auto-badge">自动</span>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow" :class="{ active: step >= 2 }">↓</div>
|
||||
|
||||
<div :class="['flow-step', { active: step >= 3, done: step > 3, auto: mode === 'framework' }]">
|
||||
<span class="flow-num">3</span>
|
||||
<div class="flow-content">
|
||||
<div class="flow-title">
|
||||
{{ mode === 'native' ? '修改 DOM 内容' : '框架更新所有相关 DOM' }}
|
||||
</div>
|
||||
<div class="flow-desc">
|
||||
{{ mode === 'native'
|
||||
? '手动调用 .textContent = 新值'
|
||||
: '自动找到所有使用了 count 的位置并更新' }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="mode === 'framework' && step >= 3" class="auto-badge">自动</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-col">
|
||||
<div class="col-title">界面结果</div>
|
||||
<div class="result-card">
|
||||
<div :class="['result-item', { updated: step >= (mode === 'native' ? 2 : 2) }]">
|
||||
<span class="result-label">购物车</span>
|
||||
<span class="result-value">{{ step >= (mode === 'native' ? 2 : 2) ? count : count - 1 }} 件</span>
|
||||
</div>
|
||||
<div :class="['result-item', { updated: step >= (mode === 'native' ? 3 : 2), stale: mode === 'native' && step >= 1 && step < 3 }]">
|
||||
<span class="result-label">总价</span>
|
||||
<span class="result-value">¥{{ step >= (mode === 'native' ? 3 : 2) ? count * 99 : (count - 1) * 99 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mode === 'native' && step === 1" class="stale-warning">
|
||||
变量已经改了,但界面没有任何变化
|
||||
</div>
|
||||
<div v-if="mode === 'native' && step === 2" class="stale-warning partial">
|
||||
购物车更新了,但总价还是旧的
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="action-btn" :disabled="isAnimating" @click="runStep">
|
||||
{{ step === 0 ? '执行 count = count + 1' : mode === 'native' && step < 3 ? '继续手动同步下一个' : '再执行一次' }}
|
||||
</button>
|
||||
<button class="action-btn outline" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box" v-if="mode === 'native'">
|
||||
<strong>为什么不自动?</strong>
|
||||
<span>JavaScript 的变量是"无感知"的。你执行 <code>count = 4</code> 时,JavaScript 引擎只是把内存中 count 的值从 3 改成 4,仅此而已。它不会通知任何人,不会触发任何回调,不会去检查页面上哪里显示了 count。所以界面不会有任何变化——除非你自己写代码去更新 DOM。</span>
|
||||
</div>
|
||||
<div class="info-box" v-else>
|
||||
<strong>框架怎么做到的?</strong>
|
||||
<span>框架把你的数据用特殊机制包裹起来。以 Vue 为例,它用 JavaScript 的 Proxy(代理)功能拦截你对变量的赋值操作。当你写 <code>count = 4</code> 时,Proxy 会在赋值的同时自动执行一段"通知"代码,告诉框架"count 变了",框架再去找到所有用到 count 的 DOM 节点并更新它们。整个过程你不需要写任何额外代码。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const mode = ref('native')
|
||||
const step = ref(0)
|
||||
const count = ref(1)
|
||||
const isAnimating = ref(false)
|
||||
|
||||
function switchMode(m) {
|
||||
if (isAnimating.value) return
|
||||
mode.value = m
|
||||
reset()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step.value = 0
|
||||
count.value = 1
|
||||
isAnimating.value = false
|
||||
}
|
||||
|
||||
async function runStep() {
|
||||
if (isAnimating.value) return
|
||||
|
||||
if (mode.value === 'native') {
|
||||
if (step.value === 0) {
|
||||
isAnimating.value = true
|
||||
count.value++
|
||||
step.value = 1
|
||||
isAnimating.value = false
|
||||
} else if (step.value === 1) {
|
||||
step.value = 2
|
||||
} else if (step.value === 2) {
|
||||
step.value = 3
|
||||
} else {
|
||||
reset()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
runStep()
|
||||
}
|
||||
} else {
|
||||
if (step.value === 0 || step.value >= 3) {
|
||||
if (step.value >= 3) {
|
||||
reset()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
isAnimating.value = true
|
||||
count.value++
|
||||
step.value = 1
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
step.value = 2
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
step.value = 3
|
||||
isAnimating.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.why-no-auto-sync-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: 600; }
|
||||
.demo-header .subtitle { font-size: 0.85rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.toggle-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover { border-color: var(--vp-c-brand); }
|
||||
.toggle-btn.active { background: var(--vp-c-brand); color: white; border-color: var(--vp-c-brand); }
|
||||
|
||||
.visualization-area {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 0.8fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-col, .flow-col, .result-col {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.code-gap { height: 0.3rem; }
|
||||
|
||||
.code-comment { color: var(--vp-c-text-3); }
|
||||
.code-text { color: var(--vp-c-text-1); }
|
||||
|
||||
.code-highlight.executing {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-left: 2px solid var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.code-manual {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.code-manual.executing {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-left: 2px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.code-manual.missing {
|
||||
opacity: 0.5;
|
||||
border-left: 2px dashed var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
font-size: 0.62rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.step-badge.miss {
|
||||
background: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
transition: all 0.3s;
|
||||
opacity: 0.4;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flow-step.active { opacity: 1; border-color: var(--vp-c-brand); }
|
||||
.flow-step.done { opacity: 1; border-color: var(--vp-c-green-1); }
|
||||
.flow-step.auto.active { border-color: var(--vp-c-green-1); background: rgba(16, 185, 129, 0.05); }
|
||||
|
||||
.flow-num {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-step.active .flow-num { background: var(--vp-c-brand); color: white; border-color: var(--vp-c-brand); }
|
||||
.flow-step.done .flow-num { background: var(--vp-c-green-1); color: white; border-color: var(--vp-c-green-1); }
|
||||
|
||||
.flow-content { flex: 1; min-width: 0; }
|
||||
.flow-title { font-size: 0.78rem; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.flow-desc { font-size: 0.7rem; color: var(--vp-c-text-2); margin-top: 0.1rem; }
|
||||
|
||||
.auto-badge {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.35rem;
|
||||
font-size: 0.58rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
padding: 0.1rem 0;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.flow-arrow.active { color: var(--vp-c-brand); font-weight: 600; }
|
||||
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.result-item.updated { border-color: var(--vp-c-green-1); background: rgba(16, 185, 129, 0.06); }
|
||||
.result-item.stale { border-color: var(--vp-c-danger-1); background: rgba(239, 68, 68, 0.06); }
|
||||
|
||||
.result-label { color: var(--vp-c-text-2); }
|
||||
.result-value { font-weight: 700; }
|
||||
|
||||
.stale-warning {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-danger-1);
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stale-warning.partial { color: var(--vp-c-warning-1); background: rgba(255, 206, 86, 0.08); }
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.8rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.action-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.action-btn.outline { background: transparent; border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-1); }
|
||||
.action-btn.outline:hover { 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);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; flex-shrink: 0; color: var(--vp-c-text-1); }
|
||||
|
||||
.info-box code {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.visualization-area { grid-template-columns: 1fr; }
|
||||
.toggle-bar { flex-direction: column; }
|
||||
.toggle-btn { width: 100%; }
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,385 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('all')
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'web', label: '网页' },
|
||||
{ id: 'data', label: '数据库' },
|
||||
{ id: 'dev', label: '开发常用' },
|
||||
{ id: 'remote', label: '远程/传输' }
|
||||
]
|
||||
|
||||
const ports = [
|
||||
{ port: 80, name: 'HTTP', desc: '网页访问(未加密)', category: 'web', risk: 'low', example: 'http://example.com' },
|
||||
{ port: 443, name: 'HTTPS', desc: '网页访问(加密)', category: 'web', risk: 'low', example: 'https://example.com' },
|
||||
{ port: 22, name: 'SSH', desc: '安全远程登录', category: 'remote', risk: 'medium', example: 'ssh user@server' },
|
||||
{ port: 21, name: 'FTP', desc: '文件传输', category: 'remote', risk: 'high', example: 'ftp://server/file.zip' },
|
||||
{ port: 3306, name: 'MySQL', desc: 'MySQL 数据库', category: 'data', risk: 'high', example: 'mysql -h localhost -P 3306' },
|
||||
{ port: 5432, name: 'PostgreSQL', desc: 'PostgreSQL 数据库', category: 'data', risk: 'high', example: 'psql -h localhost -p 5432' },
|
||||
{ port: 27017, name: 'MongoDB', desc: 'MongoDB 数据库', category: 'data', risk: 'high', example: 'mongosh localhost:27017' },
|
||||
{ port: 6379, name: 'Redis', desc: 'Redis 缓存', category: 'data', risk: 'high', example: 'redis-cli -p 6379' },
|
||||
{ port: 3000, name: 'Node/React', desc: 'Node.js / React 开发服务器', category: 'dev', risk: 'low', example: 'npm start → localhost:3000' },
|
||||
{ port: 5173, name: 'Vite', desc: 'Vite 开发服务器', category: 'dev', risk: 'low', example: 'npm run dev → localhost:5173' },
|
||||
{ port: 8080, name: '通用 HTTP', desc: 'HTTP 备用端口 / 代理', category: 'dev', risk: 'low', example: 'localhost:8080/api' },
|
||||
{ port: 8000, name: 'Django/Python', desc: 'Django / Python HTTP 服务', category: 'dev', risk: 'low', example: 'python manage.py runserver' },
|
||||
{ port: 5000, name: 'Flask', desc: 'Flask 开发服务器', category: 'dev', risk: 'low', example: 'flask run → localhost:5000' },
|
||||
{ port: 4200, name: 'Angular', desc: 'Angular 开发服务器', category: 'dev', risk: 'low', example: 'ng serve → localhost:4200' },
|
||||
{ port: 53, name: 'DNS', desc: '域名解析', category: 'remote', risk: 'medium', example: 'dig @8.8.8.8 example.com' },
|
||||
{ port: 25, name: 'SMTP', desc: '邮件发送', category: 'remote', risk: 'medium', example: '邮件服务器发信端口' },
|
||||
]
|
||||
|
||||
const riskLabels = { low: '安全', medium: '注意', high: '敏感' }
|
||||
const riskColors = { low: '#10b981', medium: '#f59e0b', high: '#ef4444' }
|
||||
|
||||
const filteredPorts = computed(() => {
|
||||
return ports.filter(p => {
|
||||
const matchCategory = selectedCategory.value === 'all' || p.category === selectedCategory.value
|
||||
const matchSearch = !searchQuery.value ||
|
||||
p.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
p.port.toString().includes(searchQuery.value) ||
|
||||
p.desc.includes(searchQuery.value)
|
||||
return matchCategory && matchSearch
|
||||
})
|
||||
})
|
||||
|
||||
const expandedPort = ref(null)
|
||||
|
||||
function toggleExpand(port) {
|
||||
expandedPort.value = expandedPort.value === port ? null : port
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="common-ports-demo">
|
||||
<div class="control-panel">
|
||||
<div class="search-bar">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索端口号或服务名..."
|
||||
class="search-input"
|
||||
>
|
||||
</div>
|
||||
<div class="category-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
:class="['tab-btn', { active: selectedCategory === cat.id }]"
|
||||
@click="selectedCategory = cat.id"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="port-table">
|
||||
<div class="table-header">
|
||||
<span class="col-port">端口</span>
|
||||
<span class="col-name">服务</span>
|
||||
<span class="col-desc">说明</span>
|
||||
<span class="col-risk">暴露风险</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="p in filteredPorts"
|
||||
:key="p.port"
|
||||
:class="['table-row', { expanded: expandedPort === p.port }]"
|
||||
@click="toggleExpand(p.port)"
|
||||
>
|
||||
<div class="row-main">
|
||||
<code class="col-port">{{ p.port }}</code>
|
||||
<span class="col-name">{{ p.name }}</span>
|
||||
<span class="col-desc">{{ p.desc }}</span>
|
||||
<span
|
||||
class="col-risk risk-badge"
|
||||
:style="{ color: riskColors[p.risk], borderColor: riskColors[p.risk] }"
|
||||
>
|
||||
{{ riskLabels[p.risk] }}
|
||||
</span>
|
||||
</div>
|
||||
<transition name="expand">
|
||||
<div v-if="expandedPort === p.port" class="row-detail">
|
||||
<span class="detail-label">使用示例:</span>
|
||||
<code>{{ p.example }}</code>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-if="filteredPorts.length === 0" class="empty-state">
|
||||
没有匹配的端口,试试其他关键词?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="range-explain">
|
||||
<div class="range-item">
|
||||
<div class="range-header well-known">0 – 1023</div>
|
||||
<div class="range-body">
|
||||
<strong>系统端口</strong>
|
||||
<span>预留给标准服务(HTTP、SSH 等),普通用户不能随便占用。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="range-item">
|
||||
<div class="range-header registered">1024 – 49151</div>
|
||||
<div class="range-body">
|
||||
<strong>注册端口</strong>
|
||||
<span>留给常见应用(MySQL 3306、Redis 6379 等),开发中最常遇到的范围。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="range-item">
|
||||
<div class="range-header dynamic">49152 – 65535</div>
|
||||
<div class="range-body">
|
||||
<strong>动态端口</strong>
|
||||
<span>操作系统临时分配的端口,比如你的浏览器发请求时,系统会随机给你一个。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>安全提醒:</strong>数据库端口(3306、5432、27017、6379)绝对不要直接暴露到公网!生产环境应只允许内网访问或通过 SSH 隧道连接。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.common-ports-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.search-icon { font-size: 0.9rem; }
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.port-table {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 100px 1fr 70px;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 100px 1fr 70px;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.col-port {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.col-desc {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row-detail {
|
||||
padding: 0.4rem 0.75rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-top: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.row-detail code {
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.range-explain {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.range-item {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.range-header {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.range-header.well-known { background: #ef4444; }
|
||||
.range-header.registered { background: #f59e0b; }
|
||||
.range-header.dynamic { background: #10b981; }
|
||||
|
||||
.range-body {
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.range-body strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.range-body span {
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-red-1);
|
||||
}
|
||||
|
||||
.expand-enter-active, .expand-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.expand-enter-from, .expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.table-header, .row-main {
|
||||
grid-template-columns: 55px 80px 1fr 55px;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.range-explain {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,470 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStep = ref(0)
|
||||
const isPlaying = ref(false)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '1. 你执行 npm run dev',
|
||||
terminal: '$ npm run dev\n\n> vite\n\n 准备就绪...',
|
||||
desc: '你在终端里敲下启动命令',
|
||||
highlight: 'terminal'
|
||||
},
|
||||
{
|
||||
title: '2. Vite 启动 HTTP 服务器',
|
||||
terminal: '$ npm run dev\n\n> vite\n\n VITE v5.4.0 ready in 200 ms\n\n ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.10:5173/',
|
||||
desc: 'Vite 在本机的 5173 端口启动了一个 HTTP 服务器,等待连接',
|
||||
highlight: 'server'
|
||||
},
|
||||
{
|
||||
title: '3. 你打开浏览器访问',
|
||||
terminal: '$ npm run dev\n\n> vite\n\n VITE v5.4.0 ready in 200 ms\n\n ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.10:5173/',
|
||||
browser: 'http://localhost:5173',
|
||||
desc: '浏览器向 localhost:5173 发起 HTTP 请求',
|
||||
highlight: 'browser'
|
||||
},
|
||||
{
|
||||
title: '4. 服务器返回页面',
|
||||
terminal: '$ npm run dev\n\n> vite\n\n VITE v5.4.0 ready in 200 ms\n\n ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.10:5173/\n\n 10:30:01 [200] /\n 10:30:01 [200] /src/main.js\n 10:30:01 [200] /src/App.vue',
|
||||
browser: 'http://localhost:5173',
|
||||
page: '🎉 你的页面出现了!',
|
||||
desc: 'Vite 处理请求,返回 HTML/JS/CSS,浏览器渲染页面',
|
||||
highlight: 'page'
|
||||
},
|
||||
{
|
||||
title: '5. 热更新(HMR)',
|
||||
terminal: '$ npm run dev\n\n VITE v5.4.0 ready in 200 ms\n\n ➜ Local: http://localhost:5173/\n\n 10:30:01 [200] /\n 10:35:22 [vite] hmr update /src/App.vue',
|
||||
browser: 'http://localhost:5173',
|
||||
page: '🔄 页面自动刷新了!',
|
||||
desc: '你修改代码后,Vite 通过 WebSocket 通知浏览器,页面自动更新',
|
||||
highlight: 'hmr'
|
||||
}
|
||||
]
|
||||
|
||||
async function playAll() {
|
||||
if (isPlaying.value) return
|
||||
isPlaying.value = true
|
||||
currentStep.value = 0
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
currentStep.value = i
|
||||
await new Promise(r => setTimeout(r, 1800))
|
||||
}
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
function goStep(i) {
|
||||
currentStep.value = i
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentStep.value = 0
|
||||
isPlaying.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="devserver-flow-demo">
|
||||
<div class="control-panel">
|
||||
<div class="step-indicators">
|
||||
<div
|
||||
v-for="(s, i) in steps"
|
||||
:key="i"
|
||||
:class="['step-dot', { active: currentStep >= i, current: currentStep === i }]"
|
||||
@click="goStep(i)"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-btns">
|
||||
<button class="action-btn" :disabled="isPlaying" @click="playAll">
|
||||
{{ isPlaying ? '播放中...' : '▶ 自动演示' }}
|
||||
</button>
|
||||
<button class="action-btn ghost" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="step-title">{{ steps[currentStep].title }}</div>
|
||||
|
||||
<div class="flow-layout">
|
||||
<div :class="['panel terminal-panel', { highlight: steps[currentStep].highlight === 'terminal' }]">
|
||||
<div class="panel-header">
|
||||
<span class="dot red" /><span class="dot yellow" /><span class="dot green" />
|
||||
<span class="panel-title">终端</span>
|
||||
</div>
|
||||
<pre class="terminal-content">{{ steps[currentStep].terminal }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="arrow-col">
|
||||
<div :class="['flow-arrow', { active: currentStep >= 1 }]">
|
||||
<span class="arrow-label">监听</span>
|
||||
<span class="arrow-char">↕</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['panel browser-panel', {
|
||||
highlight: steps[currentStep].highlight === 'browser' || steps[currentStep].highlight === 'page' || steps[currentStep].highlight === 'hmr'
|
||||
}]">
|
||||
<div class="panel-header">
|
||||
<span class="dot red" /><span class="dot yellow" /><span class="dot green" />
|
||||
<span class="panel-title">浏览器</span>
|
||||
</div>
|
||||
<div class="browser-content">
|
||||
<div v-if="steps[currentStep].browser" class="browser-url-bar">
|
||||
{{ steps[currentStep].browser }}
|
||||
</div>
|
||||
<div v-else class="browser-empty">等待你打开浏览器...</div>
|
||||
<div v-if="steps[currentStep].page" class="browser-page">
|
||||
{{ steps[currentStep].page }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-desc">
|
||||
💡 {{ steps[currentStep].desc }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="http-explain">
|
||||
<div class="http-title">什么是 HTTP 服务器?</div>
|
||||
<div class="http-analogy">
|
||||
<div class="analogy-item">
|
||||
<span class="analogy-icon">🏪</span>
|
||||
<div class="analogy-text">
|
||||
<strong>想象一个前台窗口</strong>
|
||||
<span>HTTP 服务器就像一个"永远开着的服务窗口"——它一直等在那里,有人来问就回答,没人来就静静等着。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analogy-item">
|
||||
<span class="analogy-icon">📋</span>
|
||||
<div class="analogy-text">
|
||||
<strong>只懂一种"暗号"</strong>
|
||||
<span>这个窗口只听得懂 HTTP 协议的请求格式(比如 <code>GET /index.html</code>),然后把对应的文件内容返回给你。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analogy-item">
|
||||
<span class="analogy-icon">⚙️</span>
|
||||
<div class="analogy-text">
|
||||
<strong>开发服务器 = 加强版窗口</strong>
|
||||
<span>Vite、Webpack 的开发服务器不只是"原样返回文件",它还会即时编译你的代码(Vue → JS、TS → JS、Sass → CSS),然后再返回给浏览器。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>一句话总结:</strong>开发服务器 = 一个运行在 localhost 上的 HTTP 服务器 + 即时代码编译器。它监听某个端口,浏览器来请求,它就把编译好的代码返回。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.devserver-flow-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-indicators {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-3);
|
||||
background: var(--vp-c-bg);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.step-dot.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-dot.current {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.control-btns {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.flow-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.panel.highlight {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 12px rgba(100, 108, 255, 0.2);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.red { background: #ef4444; }
|
||||
.dot.yellow { background: #f59e0b; }
|
||||
.dot.green { background: #10b981; }
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.3rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.terminal-content {
|
||||
padding: 0.75rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0;
|
||||
min-height: 140px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.browser-content {
|
||||
padding: 0.75rem;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.browser-url-bar {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.browser-empty {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.browser-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.arrow-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.flow-arrow.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.arrow-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--vp-c-text-3);
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.arrow-char {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.http-explain {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.http-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.http-analogy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.analogy-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.analogy-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.analogy-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.analogy-text strong {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.analogy-text span {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.analogy-text code {
|
||||
font-size: 0.78rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.flow-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.arrow-col {
|
||||
transform: rotate(90deg);
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.arrow-label {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,452 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const requestUrl = ref('http://localhost:3000/api/hello')
|
||||
const isRequesting = ref(false)
|
||||
const requestStep = ref(0)
|
||||
const responseText = ref('')
|
||||
|
||||
const steps = [
|
||||
{ label: '浏览器', desc: '你在地址栏输入 URL', icon: '🌐' },
|
||||
{ label: 'DNS 解析', desc: 'localhost → 127.0.0.1(不出网)', icon: '📖' },
|
||||
{ label: '网络层', desc: '数据包发往 127.0.0.1(环回接口)', icon: '🔄' },
|
||||
{ label: '本机服务', desc: '端口 3000 上的程序接收请求', icon: '⚙️' },
|
||||
{ label: '返回响应', desc: '{ "message": "Hello!" }', icon: '📨' }
|
||||
]
|
||||
|
||||
const aliases = reactive([
|
||||
{ name: 'localhost', ip: '127.0.0.1', desc: '标准域名别名', active: false },
|
||||
{ name: '127.0.0.1', ip: '127.0.0.1', desc: 'IPv4 环回地址', active: false },
|
||||
{ name: '::1', ip: '::1', desc: 'IPv6 环回地址', active: false },
|
||||
{ name: '0.0.0.0', ip: '0.0.0.0', desc: '监听所有网卡', active: false }
|
||||
])
|
||||
|
||||
const selectedAlias = ref(0)
|
||||
|
||||
async function simulateRequest() {
|
||||
if (isRequesting.value) return
|
||||
isRequesting.value = true
|
||||
requestStep.value = 0
|
||||
responseText.value = ''
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
requestStep.value = i + 1
|
||||
await new Promise(r => setTimeout(r, 700))
|
||||
}
|
||||
|
||||
responseText.value = '{ "message": "Hello from localhost!" }'
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
isRequesting.value = false
|
||||
}
|
||||
|
||||
function selectAlias(index) {
|
||||
selectedAlias.value = index
|
||||
aliases.forEach((a, i) => { a.active = i === index })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="localhost-demo">
|
||||
<div class="control-panel">
|
||||
<div class="url-bar">
|
||||
<span class="url-icon">🔗</span>
|
||||
<input
|
||||
v-model="requestUrl"
|
||||
type="text"
|
||||
class="url-input"
|
||||
readonly
|
||||
>
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="isRequesting"
|
||||
@click="simulateRequest"
|
||||
>
|
||||
{{ isRequesting ? '请求中...' : '发送请求' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="flow-container">
|
||||
<div
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
:class="['flow-step', {
|
||||
active: requestStep > i,
|
||||
current: requestStep === i + 1
|
||||
}]"
|
||||
>
|
||||
<div class="step-icon">{{ step.icon }}</div>
|
||||
<div class="step-info">
|
||||
<span class="step-label">{{ step.label }}</span>
|
||||
<span class="step-desc">{{ step.desc }}</span>
|
||||
</div>
|
||||
<div v-if="i < steps.length - 1" :class="['step-arrow', { active: requestStep > i }]">→</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="responseText" class="response-box">
|
||||
<span class="response-label">响应结果:</span>
|
||||
<code>{{ responseText }}</code>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="loopback-explain">
|
||||
<div class="loopback-diagram">
|
||||
<div class="loopback-node app">
|
||||
<span>你的应用</span>
|
||||
<span class="small">(浏览器)</span>
|
||||
</div>
|
||||
<div class="loopback-arrow">
|
||||
<span class="arrow-text">请求不离开本机</span>
|
||||
<svg width="80" height="60" viewBox="0 0 80 60">
|
||||
<path d="M10 10 Q40 55 70 10" stroke="var(--vp-c-brand)" stroke-width="2" fill="none" marker-end="url(#arrowhead)" />
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="6" markerHeight="4" refX="5" refY="2" orient="auto">
|
||||
<polygon points="0 0, 6 2, 0 4" fill="var(--vp-c-brand)" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="loopback-node server">
|
||||
<span>本地服务</span>
|
||||
<span class="small">(:3000)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alias-section">
|
||||
<div class="alias-title">localhost 的"马甲"们(点击查看说明)</div>
|
||||
<div class="alias-grid">
|
||||
<div
|
||||
v-for="(alias, i) in aliases"
|
||||
:key="i"
|
||||
:class="['alias-card', { active: selectedAlias === i }]"
|
||||
@click="selectAlias(i)"
|
||||
>
|
||||
<code class="alias-name">{{ alias.name }}</code>
|
||||
<span class="alias-ip">→ {{ alias.ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alias-desc">
|
||||
{{ aliases[selectedAlias].desc }}:
|
||||
<template v-if="selectedAlias === 0">
|
||||
这是写在你电脑 <code>/etc/hosts</code> 文件里的映射。浏览器看到 <code>localhost</code> 时,直接解析为 <code>127.0.0.1</code>,不会去问 DNS 服务器。
|
||||
</template>
|
||||
<template v-else-if="selectedAlias === 1">
|
||||
<code>127.0.0.1</code> 是 IPv4 的"环回地址"。发到这个地址的数据包永远不会离开本机,操作系统直接在内部把它"折返"回来。
|
||||
</template>
|
||||
<template v-else-if="selectedAlias === 2">
|
||||
<code>::1</code> 是 IPv6 版本的环回地址,功能和 <code>127.0.0.1</code> 完全一样,只不过是 IPv6 格式。
|
||||
</template>
|
||||
<template v-else>
|
||||
<code>0.0.0.0</code> 不是"某一个地址",而是"所有地址"。当服务监听 <code>0.0.0.0:3000</code> 时,意味着无论从哪个网卡(包括局域网 IP 和 127.0.0.1)都能访问。
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心概念:</strong>localhost 就是"自己找自己"。数据包通过环回接口(loopback interface)在本机内部折返,不经过网线、不经过路由器,速度极快且完全安全。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.localhost-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.url-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.url-icon { font-size: 1rem; }
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.flow-container {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
opacity: 0.4;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-step.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.flow-step.current {
|
||||
opacity: 1;
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 8px rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
.step-icon { font-size: 1.2rem; }
|
||||
|
||||
.step-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.step-arrow {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-divider);
|
||||
transition: color 0.3s;
|
||||
margin: 0 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-arrow.active {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.response-box {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.response-label {
|
||||
font-weight: 600;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.response-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.loopback-explain {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.loopback-diagram {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.loopback-node {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.loopback-node .small {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.loopback-node.app {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.loopback-node.server {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.loopback-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow-text {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alias-section {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.alias-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.alias-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.alias-card {
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.alias-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.alias-card.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: rgba(100, 108, 255, 0.08);
|
||||
}
|
||||
|
||||
.alias-name {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alias-ip {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.alias-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
padding: 0.6rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.alias-desc code {
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.alias-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.flow-container {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.step-arrow {
|
||||
display: none;
|
||||
}
|
||||
.loopback-diagram {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const selectedBuilding = ref('web-server')
|
||||
|
||||
const buildings = {
|
||||
'web-server': {
|
||||
name: 'Web 服务器大楼',
|
||||
ip: '192.168.1.100',
|
||||
doors: [
|
||||
{ port: 80, label: 'HTTP', status: 'open', color: '#10b981', desc: '网页访问入口' },
|
||||
{ port: 443, label: 'HTTPS', status: 'open', color: '#3b82f6', desc: '加密网页入口' },
|
||||
{ port: 22, label: 'SSH', status: 'open', color: '#f59e0b', desc: '远程管理通道' },
|
||||
{ port: 3306, label: 'MySQL', status: 'closed', color: '#ef4444', desc: '数据库(已关闭)' }
|
||||
]
|
||||
},
|
||||
'dev-machine': {
|
||||
name: '你的开发电脑',
|
||||
ip: '127.0.0.1',
|
||||
doors: [
|
||||
{ port: 3000, label: 'React', status: 'open', color: '#61dafb', desc: '前端开发服务' },
|
||||
{ port: 5173, label: 'Vite', status: 'open', color: '#646cff', desc: 'Vite 开发服务' },
|
||||
{ port: 8080, label: 'API', status: 'open', color: '#10b981', desc: '后端 API 服务' },
|
||||
{ port: 5432, label: 'PostgreSQL', status: 'open', color: '#336791', desc: '本地数据库' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentBuilding = computed(() => buildings[selectedBuilding.value])
|
||||
const knockingPort = ref(null)
|
||||
const knockResult = ref('')
|
||||
|
||||
function knockDoor(door) {
|
||||
knockingPort.value = door.port
|
||||
if (door.status === 'open') {
|
||||
knockResult.value = `✅ 端口 ${door.port} 开着!${door.label} 服务正在监听,准备接收你的请求。`
|
||||
} else {
|
||||
knockResult.value = `🚫 端口 ${door.port} 关着!没有程序在监听这个端口,连接被拒绝 (Connection Refused)。`
|
||||
}
|
||||
setTimeout(() => { knockingPort.value = null }, 600)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="port-analogy-demo">
|
||||
<div class="control-panel">
|
||||
<span class="panel-label">选择一栋"大楼":</span>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="(b, key) in buildings"
|
||||
:key="key"
|
||||
:class="['tab-btn', { active: selectedBuilding === key }]"
|
||||
@click="selectedBuilding = key; knockResult = ''"
|
||||
>
|
||||
{{ b.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="building">
|
||||
<div class="building-roof">
|
||||
<span class="building-name">{{ currentBuilding.name }}</span>
|
||||
<span class="building-ip">IP: {{ currentBuilding.ip }}</span>
|
||||
</div>
|
||||
<div class="doors-grid">
|
||||
<div
|
||||
v-for="door in currentBuilding.doors"
|
||||
:key="door.port"
|
||||
:class="['door-card', door.status, { knocking: knockingPort === door.port }]"
|
||||
@click="knockDoor(door)"
|
||||
>
|
||||
<div class="door-number" :style="{ backgroundColor: door.color }">
|
||||
{{ door.port }}
|
||||
</div>
|
||||
<div class="door-info">
|
||||
<span class="door-label">{{ door.label }}</span>
|
||||
<span class="door-desc">{{ door.desc }}</span>
|
||||
</div>
|
||||
<div :class="['door-status', door.status]">
|
||||
{{ door.status === 'open' ? '🟢 监听中' : '🔴 已关闭' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="knockResult" :class="['knock-result', { error: knockResult.startsWith('🚫') }]">
|
||||
{{ knockResult }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心比喻:</strong>IP 地址 = 大楼地址,端口号 = 房间门牌号。一台电脑上可以同时运行多个服务,每个服务"占用"一个端口号,就像同一栋大楼里的不同房间。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.port-analogy-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.building {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.building-roof {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.building-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.building-ip {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.doors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.door-card {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.door-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.door-card.knocking {
|
||||
animation: knock 0.3s ease 2;
|
||||
}
|
||||
|
||||
@keyframes knock {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-3px); }
|
||||
75% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
.door-card.closed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.door-number {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.door-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.door-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.door-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.door-status {
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.knock-result {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.knock-result.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-red-1);
|
||||
border-color: var(--vp-c-red-1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.doors-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user