docs: update Chinese documentation and add Vue components
- Update AI capability dictionary by removing redundant mention of Baidu's model - Add new Vue components for context engineering visualization (IntroProblemReasonSolution, MemoryPalaceDemo, MemoryPalaceActionDemo, KVCacheDemo, LostInMiddleDemo) - Register new components in theme index.js - Enhance audio introduction with new interactive demos (AudioQuickStartDemo, MelSpectrogramDemo, TTSPipelineDemo, VoiceCloningDemo, ASRvsTTSDemo, AudioTokenizationDemo, EmotionControlDemo) - Improve existing context engineering demos with Chinese localization and better tokenization - Fix Japanese documentation layout by properly closing NavGrid components
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
<!--
|
||||
BrowserRenderingDemo.vue
|
||||
浏览器渲染演示 - 拆开包裹/装修房子类比
|
||||
压缩版:横向延展,减少纵向高度
|
||||
-->
|
||||
<template>
|
||||
<div class="unboxing-demo">
|
||||
<!-- 紧凑头部:标题+场景+步骤导航合并 -->
|
||||
<div class="compact-header">
|
||||
<div class="header-left">
|
||||
<span class="title-icon">[包裹]</span>
|
||||
<span class="header-title">拆开包裹:代码如何变成画面</span>
|
||||
</div>
|
||||
<div class="header-steps">
|
||||
<button
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
@click="goToStep(index)"
|
||||
class="step-chip"
|
||||
:class="{ active: currentStep === index, completed: currentStep > index }"
|
||||
>
|
||||
<span class="chip-num">{{ index + 1 }}</span>
|
||||
<span class="chip-name">{{ step.shortName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 横向主区域:代码输入 | 处理可视化 | 屏幕输出 -->
|
||||
<div class="main-stage">
|
||||
<!-- 左侧:代码输入 -->
|
||||
<div class="stage-box input-box">
|
||||
<div class="box-label">[代码包] 收到的代码</div>
|
||||
<pre class="code-mini"><div class="box">
|
||||
<h1>Hello</h1>
|
||||
</div>
|
||||
<style>
|
||||
.box { background: blue; }
|
||||
</style></pre>
|
||||
</div>
|
||||
|
||||
<!-- 中间:处理过程 -->
|
||||
<div class="stage-box process-box">
|
||||
<div class="box-label">[{{ stepIcons[currentStep] }}] {{ steps[currentStep]?.name }}</div>
|
||||
|
||||
<!-- 步骤1: 解析HTML -->
|
||||
<div v-if="currentStep === 0" class="process-content">
|
||||
<div class="mini-tree">
|
||||
<span class="tree-line">html</span>
|
||||
<span class="tree-line indent">└─ body</span>
|
||||
<span class="tree-line indent2 highlight">└─ div.box</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 解析CSS -->
|
||||
<div v-else-if="currentStep === 1" class="process-content">
|
||||
<div class="mini-css">
|
||||
<span class="css-sel">.box</span> {<br/>
|
||||
background: <span class="css-val">blue</span>;<br/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 合并渲染树 -->
|
||||
<div v-else-if="currentStep === 2" class="process-content">
|
||||
<div class="mini-render">
|
||||
<span class="render-tag">div.box</span>
|
||||
<span class="render-arrow">→</span>
|
||||
<span class="render-style">蓝色背景</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤4: 计算布局 -->
|
||||
<div v-else-if="currentStep === 3" class="process-content">
|
||||
<div class="mini-layout" :style="{ width: boxSize + 'px', height: boxSize + 'px' }">
|
||||
{{ boxSize }}×{{ boxSize }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤5: 绘制像素 -->
|
||||
<div v-else-if="currentStep === 4" class="process-content">
|
||||
<div class="mini-layers">
|
||||
<div class="layer-bar" :style="{ opacity: layerOp }">背景</div>
|
||||
<div class="layer-bar" :style="{ opacity: layerOp }">文字</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤6: 合成显示 -->
|
||||
<div v-else class="process-content">
|
||||
<div class="mini-final">
|
||||
<strong>Hello</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:屏幕输出 -->
|
||||
<div class="stage-box output-box">
|
||||
<div class="box-label">[屏幕] 显示结果</div>
|
||||
<div class="screen-mini">
|
||||
<div class="browser-bar-mini">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="screen-content" :class="{ styled: currentStep >= 4, final: currentStep >= 5 }">
|
||||
<template v-if="currentStep >= 5">
|
||||
<strong>Hello</strong>
|
||||
</template>
|
||||
<template v-else-if="currentStep >= 3">
|
||||
内容
|
||||
</template>
|
||||
<template v-else>
|
||||
...
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双栏对照说明 - 横向排列 -->
|
||||
<div class="dual-bar">
|
||||
<div class="bar-side">
|
||||
<span class="bar-label">[生活] {{ steps[currentStep]?.analogy }}</span>
|
||||
</div>
|
||||
<div class="bar-divider">↔</div>
|
||||
<div class="bar-side">
|
||||
<span class="bar-label">[技术] {{ steps[currentStep]?.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="control-bar">
|
||||
<button class="bar-btn" @click="prevStep" :disabled="currentStep <= 0">[上一步]</button>
|
||||
<button class="bar-btn primary" @click="nextStep" :disabled="currentStep >= steps.length - 1">
|
||||
{{ currentStep >= steps.length - 1 ? '[完成]' : '[下一步]' }}
|
||||
</button>
|
||||
<button class="bar-btn" @click="currentStep = 0">[重置]</button>
|
||||
</div>
|
||||
|
||||
<!-- 完整流程 - 横向紧凑 -->
|
||||
<div class="flow-bar" v-if="currentStep >= 5">
|
||||
<div class="flow-items">
|
||||
<span v-for="(step, i) in steps" :key="i" class="flow-item-mini" :class="{ highlight: i === 5 }">
|
||||
[{{ stepIcons[i] }}] {{ step.shortName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const currentStep = ref(0)
|
||||
const boxSize = ref(0)
|
||||
const layerOp = ref(0)
|
||||
|
||||
const stepIcons = ['清单', '图纸', '组装', '测量', '上色', '完成']
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 'parse',
|
||||
name: '解析 HTML',
|
||||
shortName: '解析',
|
||||
desc: '浏览器读取HTML代码,理解页面结构。',
|
||||
analogy: '拆开包裹,先看清单。'
|
||||
},
|
||||
{
|
||||
id: 'cssom',
|
||||
name: '解析 CSS',
|
||||
shortName: '样式',
|
||||
desc: '浏览器读取CSS样式,知道每个元素的样子。',
|
||||
analogy: '看装修设计图。'
|
||||
},
|
||||
{
|
||||
id: 'render',
|
||||
name: '合并渲染树',
|
||||
shortName: '合并',
|
||||
desc: '把HTML结构和CSS样式结合。',
|
||||
analogy: '把结构图和设计图结合。'
|
||||
},
|
||||
{
|
||||
id: 'layout',
|
||||
name: '计算布局',
|
||||
shortName: '布局',
|
||||
desc: '计算每个元素在屏幕上的位置和大小。',
|
||||
analogy: '丈量房间尺寸。'
|
||||
},
|
||||
{
|
||||
id: 'paint',
|
||||
name: '绘制像素',
|
||||
shortName: '绘制',
|
||||
desc: '把颜色、文字绘制到屏幕上。',
|
||||
analogy: '刷漆、贴壁纸。'
|
||||
},
|
||||
{
|
||||
id: 'composite',
|
||||
name: '合成显示',
|
||||
shortName: '显示',
|
||||
desc: '把所有图层合成,最终显示。',
|
||||
analogy: '装修完成!'
|
||||
}
|
||||
]
|
||||
|
||||
const goToStep = (step) => { currentStep.value = step }
|
||||
const nextStep = () => { if (currentStep.value < steps.length - 1) currentStep.value++ }
|
||||
const prevStep = () => { if (currentStep.value > 0) currentStep.value-- }
|
||||
|
||||
watch(currentStep, (newStep) => {
|
||||
if (newStep === 3) { boxSize.value = 0; setTimeout(() => boxSize.value = 60, 100) }
|
||||
if (newStep === 4) { layerOp.value = 0; setTimeout(() => layerOp.value = 1, 100) }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.unboxing-demo {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 紧凑头部 */
|
||||
.compact-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title-icon {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.header-steps {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.step-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.step-chip:hover { border-color: var(--vp-c-brand); }
|
||||
.step-chip.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
.step-chip.completed { border-color: #67c23a; color: #67c23a; }
|
||||
.chip-num {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
}
|
||||
.step-chip.active .chip-num { background: rgba(255,255,255,0.3); }
|
||||
|
||||
/* 横向主区域 */
|
||||
.main-stage {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stage-box {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
min-height: 100px;
|
||||
}
|
||||
.box-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
/* 代码输入 */
|
||||
.code-mini {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 处理过程 */
|
||||
.process-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 70px;
|
||||
}
|
||||
.mini-tree {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tree-line { display: block; }
|
||||
.indent { margin-left: 12px; }
|
||||
.indent2 { margin-left: 24px; }
|
||||
.highlight { color: var(--vp-c-brand); font-weight: 600; }
|
||||
|
||||
.mini-css {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.css-sel { color: #e6a23c; }
|
||||
.css-val { color: #409eff; }
|
||||
|
||||
.mini-render {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.render-tag {
|
||||
padding: 3px 8px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.render-arrow { color: var(--vp-c-text-3); }
|
||||
.render-style { color: var(--vp-c-text-2); }
|
||||
|
||||
.mini-layout {
|
||||
background: var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
transition: all 0.4s;
|
||||
}
|
||||
|
||||
.mini-layers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.layer-bar {
|
||||
padding: 4px 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.mini-final {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, #409eff, #67c23a);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 屏幕输出 */
|
||||
.screen-mini {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.browser-bar-mini {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 4px 6px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
.browser-bar-mini span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
}
|
||||
.browser-bar-mini span:nth-child(1) { background: #ff5f57; }
|
||||
.browser-bar-mini span:nth-child(2) { background: #febc2e; }
|
||||
.browser-bar-mini span:nth-child(3) { background: #28c840; }
|
||||
.screen-content {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.screen-content.styled {
|
||||
background: #409eff;
|
||||
color: white;
|
||||
}
|
||||
.screen-content.final {
|
||||
background: linear-gradient(135deg, #409eff, #67c23a);
|
||||
}
|
||||
|
||||
/* 双栏对照 */
|
||||
.dual-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bar-side { text-align: center; }
|
||||
.bar-label {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.bar-divider {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 控制按钮 */
|
||||
.control-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.bar-btn {
|
||||
padding: 6px 16px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.bar-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); color: var(--vp-c-brand); }
|
||||
.bar-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.bar-btn.primary { background: var(--vp-c-brand); border-color: var(--vp-c-brand); color: white; }
|
||||
|
||||
/* 流程条 */
|
||||
.flow-bar {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.flow-items {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flow-item-mini {
|
||||
padding: 4px 10px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
.flow-item-mini.highlight {
|
||||
background: linear-gradient(135deg, #67c23a, #85ce61);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-stage { grid-template-columns: 1fr; }
|
||||
.compact-header { flex-direction: column; align-items: flex-start; }
|
||||
.dual-bar { grid-template-columns: 1fr; gap: 4px; }
|
||||
.bar-divider { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,724 @@
|
||||
<!--
|
||||
DnsLookupDemo.vue
|
||||
DNS查询演示 - 查地址簿 vs 真实DNS查询 双栏对照
|
||||
|
||||
用途:
|
||||
用"查地址簿"的生活化比喻,配合真实DNS查询过程,
|
||||
让0基础用户理解域名如何转换成IP地址。
|
||||
-->
|
||||
<template>
|
||||
<div class="dns-lookup-demo">
|
||||
<!-- 标题区 -->
|
||||
<div class="demo-header">
|
||||
<div class="header-title">
|
||||
<span class="title-icon">[地址簿]</span>
|
||||
<span>查地址簿 vs DNS查询</span>
|
||||
</div>
|
||||
<div class="header-subtitle">生活比喻 ↔ 技术实现 双栏对照</div>
|
||||
</div>
|
||||
|
||||
<!-- 场景设置 -->
|
||||
<div class="scenario-setup">
|
||||
<div class="setup-text">
|
||||
快递员要送包裹给 <strong>"{{ currentTarget.name }}"</strong>({{ currentTarget.domain }}),
|
||||
但他只知道名字,不知道具体门牌号...
|
||||
</div>
|
||||
<div class="target-selector">
|
||||
<span class="selector-label">换个目标:</span>
|
||||
<button
|
||||
v-for="target in targets"
|
||||
:key="target.name"
|
||||
@click="selectTarget(target)"
|
||||
class="target-chip"
|
||||
:class="{ active: currentTarget.name === target.name }"
|
||||
:disabled="isSearching"
|
||||
>
|
||||
{{ target.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开始查询按钮 -->
|
||||
<div class="start-action" v-if="!isSearching && !showResult">
|
||||
<button class="start-btn" @click="startSearch">
|
||||
[查询] 开始查询地址
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 双栏对照展示 -->
|
||||
<div class="comparison-container" v-if="isSearching || showResult">
|
||||
<!-- 左侧:生活比喻(查地址簿) -->
|
||||
<div class="comparison-side analogy-side">
|
||||
<div class="side-header">
|
||||
<span class="side-icon">[生活]</span>
|
||||
<span class="side-title">查地址簿流程</span>
|
||||
</div>
|
||||
<div class="analogy-flow">
|
||||
<div
|
||||
v-for="(level, index) in queryLevels"
|
||||
:key="level.id"
|
||||
class="flow-level"
|
||||
:class="{
|
||||
passed: currentStep > index,
|
||||
current: currentStep === index,
|
||||
pending: currentStep < index
|
||||
}"
|
||||
>
|
||||
<div class="level-icon">{{ level.analogyIcon }}</div>
|
||||
<div class="level-content">
|
||||
<div class="level-name">{{ level.analogyName }}</div>
|
||||
<div class="level-role">{{ level.analogyRole }}</div>
|
||||
<div class="level-action" v-if="currentStep === index">
|
||||
<span class="action-text">{{ level.analogyAction }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:连接指示 -->
|
||||
<div class="connection-indicator">
|
||||
<div class="indicator-line" v-for="i in 5" :key="i">
|
||||
<span class="indicator-arrow">→</span>
|
||||
<span class="indicator-text">对应</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:技术实现(真实DNS) -->
|
||||
<div class="comparison-side tech-side">
|
||||
<div class="side-header">
|
||||
<span class="side-icon">[技术]</span>
|
||||
<span class="side-title">DNS查询流程</span>
|
||||
</div>
|
||||
<div class="tech-flow">
|
||||
<div
|
||||
v-for="(level, index) in queryLevels"
|
||||
:key="level.id"
|
||||
class="flow-level"
|
||||
:class="{
|
||||
passed: currentStep > index,
|
||||
current: currentStep === index,
|
||||
pending: currentStep < index
|
||||
}"
|
||||
>
|
||||
<div class="level-icon" :style="{ background: level.techColor }">{{ level.techIcon }}</div>
|
||||
<div class="level-content">
|
||||
<div class="level-name">{{ level.techName }}</div>
|
||||
<div class="level-role">{{ level.techRole }}</div>
|
||||
<div class="level-action" v-if="currentStep === index">
|
||||
<code class="action-code">{{ level.techAction }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查询结果 -->
|
||||
<div class="result-section" v-if="showResult">
|
||||
<div class="result-card">
|
||||
<div class="result-header">[成功] 查询成功!</div>
|
||||
<div class="result-body">
|
||||
<div class="result-row">
|
||||
<span class="result-label">域名(名字):</span>
|
||||
<code class="result-value">{{ currentTarget.domain }}</code>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">IP地址(门牌号):</span>
|
||||
<code class="result-value highlight">{{ currentTarget.ip }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术说明卡片 -->
|
||||
<div class="tech-explanation">
|
||||
<div class="explanation-header">
|
||||
<span class="explanation-icon">[详解]</span>
|
||||
<span>DNS查询技术详解</span>
|
||||
</div>
|
||||
<div class="explanation-body">
|
||||
<div class="explanation-item">
|
||||
<strong>[查询] 查询类型:</strong>
|
||||
<p><strong>递归查询</strong>:浏览器只发一次请求,本地DNS负责层层查询后返回结果(像委托代理)</p>
|
||||
<p><strong>迭代查询</strong>:每层只告诉下一层去哪查,浏览器需要多次查询(像自己跑腿)</p>
|
||||
</div>
|
||||
<div class="explanation-item">
|
||||
<strong>[缓存] 缓存机制:</strong>
|
||||
<p>查询结果会被缓存在浏览器、操作系统、路由器、本地DNS服务器等多个层级,下次直接返回,大大加速访问。</p>
|
||||
</div>
|
||||
<div class="explanation-item">
|
||||
<strong>[根] 根域名服务器:</strong>
|
||||
<p>全球只有13组根服务器(字母A-M命名),管理所有顶级域(.com/.org/.cn等)。它们知道每个顶级域由谁管理。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="reset-btn" @click="reset">[重置] 再查一次</button>
|
||||
</div>
|
||||
|
||||
<!-- 层级说明(未开始查询时显示) -->
|
||||
<div class="levels-info" v-if="!isSearching && !showResult">
|
||||
<div class="info-title">[对照] DNS查询层级对照表</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-card" v-for="level in queryLevels" :key="level.id">
|
||||
<div class="info-analogy">
|
||||
<span class="info-icon">{{ level.analogyIcon }}</span>
|
||||
<span class="info-name">{{ level.analogyName }}</span>
|
||||
</div>
|
||||
<div class="info-arrow">↓</div>
|
||||
<div class="info-tech">
|
||||
<span class="info-icon" :style="{ background: level.techColor }">{{ level.techIcon }}</span>
|
||||
<span class="info-name">{{ level.techName }}</span>
|
||||
</div>
|
||||
<div class="info-desc">{{ level.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const targets = [
|
||||
{ name: '百度', domain: 'baidu.com', ip: '110.242.68.66' },
|
||||
{ name: '谷歌', domain: 'google.com', ip: '142.250.80.46' },
|
||||
{ name: 'GitHub', domain: 'github.com', ip: '140.82.114.4' },
|
||||
{ name: 'B站', domain: 'bilibili.com', ip: '120.92.78.57' }
|
||||
]
|
||||
|
||||
const currentTarget = ref(targets[0])
|
||||
const isSearching = ref(false)
|
||||
const showResult = ref(false)
|
||||
const currentStep = ref(0)
|
||||
|
||||
const queryLevels = [
|
||||
{
|
||||
id: 'browser',
|
||||
analogyIcon: '自',
|
||||
analogyName: '翻通讯录',
|
||||
analogyRole: '快递员自己',
|
||||
analogyAction: '先看看自己记没记过这个地址',
|
||||
techIcon: '浏',
|
||||
techName: '浏览器缓存',
|
||||
techRole: '本地缓存',
|
||||
techAction: '检查 DNS cache',
|
||||
techColor: '#67c23a',
|
||||
description: '浏览器会缓存最近访问过的域名,避免重复查询'
|
||||
},
|
||||
{
|
||||
id: 'os',
|
||||
analogyIcon: '本',
|
||||
analogyName: '查记事本',
|
||||
analogyRole: '自己的记录',
|
||||
analogyAction: '看看之前有没有记过',
|
||||
techIcon: '系',
|
||||
techName: '操作系统缓存',
|
||||
techRole: 'OS DNS Cache',
|
||||
techAction: '检查 hosts 文件',
|
||||
techColor: '#95d475',
|
||||
description: '操作系统也有DNS缓存,hosts文件可手动指定域名映射'
|
||||
},
|
||||
{
|
||||
id: 'recursive',
|
||||
analogyIcon: '服',
|
||||
analogyName: '社区服务中心',
|
||||
analogyRole: '帮跑腿的人',
|
||||
analogyAction: '帮用户查询,自己跑遍各部门',
|
||||
techIcon: '递',
|
||||
techName: '本地DNS服务器',
|
||||
techRole: 'Recursive Resolver',
|
||||
techAction: 'ISP DNS 查询',
|
||||
techColor: '#409eff',
|
||||
description: '通常由网络运营商提供,负责递归查询并缓存结果'
|
||||
},
|
||||
{
|
||||
id: 'root',
|
||||
analogyIcon: '根',
|
||||
analogyName: '国务院',
|
||||
analogyRole: '最高管理机构',
|
||||
analogyAction: '.com 归谁管?去问它!',
|
||||
techIcon: '根',
|
||||
techName: '根域名服务器',
|
||||
techRole: 'Root Server',
|
||||
techAction: '返回 TLD 服务器地址',
|
||||
techColor: '#e6a23c',
|
||||
description: '全球13组,管理所有顶级域,知道.com/.cn等归谁管'
|
||||
},
|
||||
{
|
||||
id: 'tld',
|
||||
analogyIcon: '省',
|
||||
analogyName: '省政府',
|
||||
analogyRole: '省级管理机构',
|
||||
analogyAction: 'baidu.com 归谁管?',
|
||||
techIcon: '顶',
|
||||
techName: '顶级域服务器',
|
||||
techRole: 'TLD Server',
|
||||
techAction: '返回权威DNS地址',
|
||||
techColor: '#f56c6c',
|
||||
description: '管理特定顶级域(如Verisign管理.com),知道具体域名归谁管'
|
||||
},
|
||||
{
|
||||
id: 'auth',
|
||||
analogyIcon: '户',
|
||||
analogyName: '户籍系统',
|
||||
analogyRole: '最终档案',
|
||||
analogyAction: '查到具体门牌号了!',
|
||||
techIcon: '权',
|
||||
techName: '权威DNS服务器',
|
||||
techRole: 'Authoritative DNS',
|
||||
techAction: '返回 A 记录',
|
||||
techColor: '#b37feb',
|
||||
description: '域名所有者设置的DNS服务器,保存着域名到IP的最终映射'
|
||||
}
|
||||
]
|
||||
|
||||
const selectTarget = (target) => {
|
||||
currentTarget.value = target
|
||||
reset()
|
||||
}
|
||||
|
||||
const startSearch = () => {
|
||||
isSearching.value = true
|
||||
showResult.value = false
|
||||
currentStep.value = 0
|
||||
|
||||
// 模拟查询过程
|
||||
const steps = [0, 1, 2, 3, 4, 5]
|
||||
let i = 0
|
||||
|
||||
const nextStep = () => {
|
||||
if (i < steps.length) {
|
||||
currentStep.value = steps[i]
|
||||
i++
|
||||
setTimeout(nextStep, 800)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
showResult.value = true
|
||||
isSearching.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
nextStep()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
isSearching.value = false
|
||||
showResult.value = false
|
||||
currentStep.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dns-lookup-demo {
|
||||
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title-icon {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 场景设置 */
|
||||
.scenario-setup {
|
||||
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(103, 194, 58, 0.1));
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.setup-text {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.setup-text strong {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.target-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.selector-label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
.target-chip {
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.target-chip:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.target-chip.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
.target-chip:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 开始按钮 */
|
||||
.start-action {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.start-btn {
|
||||
padding: 12px 32px;
|
||||
background: linear-gradient(135deg, var(--vp-c-brand), #67c23a);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.start-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 双栏对照容器 */
|
||||
.comparison-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.comparison-side {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.side-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
.analogy-side .side-header {
|
||||
background: linear-gradient(90deg, #67c23a, #95d475);
|
||||
}
|
||||
.tech-side .side-header {
|
||||
background: linear-gradient(90deg, #409eff, #79bbff);
|
||||
}
|
||||
.side-icon {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 流程展示 */
|
||||
.analogy-flow, .tech-flow {
|
||||
padding: 12px;
|
||||
}
|
||||
.flow-level {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
transition: all 0.3s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.flow-level.passed {
|
||||
opacity: 0.7;
|
||||
border-color: #67c23a;
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
}
|
||||
.flow-level.current {
|
||||
opacity: 1;
|
||||
border-color: var(--vp-c-brand);
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.level-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.analogy-flow .level-icon {
|
||||
background: #fff3e0;
|
||||
color: #666;
|
||||
}
|
||||
.level-content {
|
||||
flex: 1;
|
||||
}
|
||||
.level-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.level-role {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
.level-action {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.action-text {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-brand);
|
||||
background: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.action-code {
|
||||
font-size: 10px;
|
||||
color: var(--vp-c-brand);
|
||||
background: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* 连接指示器 */
|
||||
.connection-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
padding: 40px 0;
|
||||
}
|
||||
.indicator-line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.indicator-arrow {
|
||||
font-size: 18px;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
.indicator-text {
|
||||
font-size: 10px;
|
||||
color: var(--vp-c-text-3);
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
/* 结果区域 */
|
||||
.result-section {
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
.result-card {
|
||||
background: linear-gradient(135deg, #67c23a, #85ce61);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.result-header {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.result-label {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.result-value {
|
||||
font-family: monospace;
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.result-value.highlight {
|
||||
background: rgba(255,255,255,0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 技术说明 */
|
||||
.tech-explanation {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.explanation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.explanation-icon {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.explanation-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.explanation-item strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.explanation-item p {
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 重置按钮 */
|
||||
.reset-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.reset-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* 层级信息 */
|
||||
.levels-info {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.info-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.info-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.info-analogy, .info-tech {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.info-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.info-analogy .info-icon {
|
||||
background: #fff3e0;
|
||||
color: #666;
|
||||
}
|
||||
.info-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.info-arrow {
|
||||
font-size: 16px;
|
||||
color: var(--vp-c-brand);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.info-desc {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.comparison-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.connection-indicator {
|
||||
flex-direction: row;
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.indicator-text {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,533 @@
|
||||
<!--
|
||||
HttpExchangeDemo.vue
|
||||
HTTP请求响应演示 - 快递员送达对话类比
|
||||
|
||||
用途:
|
||||
用"快递员和收件人对话"的生活化比喻,让用户理解HTTP请求和响应的过程。
|
||||
把枯燥的HTTP协议变成直观的对话场景。
|
||||
-->
|
||||
<template>
|
||||
<div class="delivery-dialog-demo">
|
||||
<!-- 标题 -->
|
||||
<div class="dialog-header">
|
||||
<span class="dialog-icon">[送达]</span>
|
||||
<span class="dialog-title">送达对话:请求与响应</span>
|
||||
</div>
|
||||
|
||||
<!-- 场景选择 -->
|
||||
<div class="scenario-selector">
|
||||
<div class="selector-label">选择送达场景:</div>
|
||||
<div class="scenario-buttons">
|
||||
<button
|
||||
v-for="scene in scenarios"
|
||||
:key="scene.id"
|
||||
@click="selectScenario(scene)"
|
||||
class="scenario-btn"
|
||||
:class="{ active: currentScenario?.id === scene.id }"
|
||||
:disabled="isDelivering"
|
||||
>
|
||||
<span class="btn-text">{{ scene.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话场景 -->
|
||||
<div class="dialog-scene" v-if="currentScenario">
|
||||
<div class="scene-background">
|
||||
<!-- 快递员(请求方) -->
|
||||
<div class="character courier">
|
||||
<div class="char-avatar">送</div>
|
||||
<div class="char-name">快递员(浏览器)</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话区域 -->
|
||||
<div class="conversation-area">
|
||||
<!-- 请求消息 -->
|
||||
<div class="message request" :class="{ sent: step >= 1 }">
|
||||
<div class="message-bubble">
|
||||
<div class="bubble-header">
|
||||
<span class="method-badge" :class="currentScenario.method.toLowerCase()">
|
||||
{{ currentScenario.method }}
|
||||
</span>
|
||||
<span class="path-text">{{ currentScenario.path }}</span>
|
||||
</div>
|
||||
<div class="bubble-body">{{ currentScenario.requestText }}</div>
|
||||
</div>
|
||||
<div class="message-meta">请求</div>
|
||||
</div>
|
||||
|
||||
<!-- 传输动画 -->
|
||||
<div class="transit-animation" v-if="step === 2">
|
||||
<div class="transit-line"></div>
|
||||
<div class="transit-package">包</div>
|
||||
</div>
|
||||
|
||||
<!-- 响应消息 -->
|
||||
<div class="message response" :class="{ sent: step >= 3 }">
|
||||
<div class="message-meta">响应</div>
|
||||
<div class="message-bubble" :class="currentScenario.statusType">
|
||||
<div class="bubble-header">
|
||||
<span class="status-badge" :class="currentScenario.statusType">
|
||||
{{ currentScenario.status }}
|
||||
</span>
|
||||
<span class="status-text">{{ currentScenario.statusText }}</span>
|
||||
</div>
|
||||
<div class="bubble-body">{{ currentScenario.responseText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收件人(响应方) -->
|
||||
<div class="character recipient">
|
||||
<div class="char-avatar">收</div>
|
||||
<div class="char-name">收件人(服务器)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="dialog-controls">
|
||||
<button
|
||||
class="control-btn primary"
|
||||
@click="nextStep"
|
||||
:disabled="isDelivering || step >= 3"
|
||||
>
|
||||
{{ step === 0 ? '[开始]' : step === 3 ? '对话完成' : '[下一步]' }}
|
||||
</button>
|
||||
<button class="control-btn" @click="reset" v-if="step > 0">
|
||||
[重置]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 状态码说明 -->
|
||||
<div class="status-legend">
|
||||
<div class="legend-title">[对照] HTTP状态码速查:</div>
|
||||
<div class="legend-grid">
|
||||
<div class="legend-item success">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-code">2xx</span>
|
||||
<span class="status-meaning">成功送达</span>
|
||||
</div>
|
||||
<div class="legend-item redirect">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-code">3xx</span>
|
||||
<span class="status-meaning">地址变更</span>
|
||||
</div>
|
||||
<div class="legend-item client-error">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-code">4xx</span>
|
||||
<span class="status-meaning">请求有误</span>
|
||||
</div>
|
||||
<div class="legend-item server-error">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-code">5xx</span>
|
||||
<span class="status-meaning">服务器问题</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
id: 'success',
|
||||
name: '正常送达',
|
||||
method: 'GET',
|
||||
path: '/index.html',
|
||||
requestText: '您好,这是您的包裹,请签收!',
|
||||
status: '200',
|
||||
statusText: 'OK',
|
||||
statusType: 'success',
|
||||
responseText: '好的,收到了,谢谢!'
|
||||
},
|
||||
{
|
||||
id: 'notfound',
|
||||
name: '地址错误',
|
||||
method: 'GET',
|
||||
path: '/nopage',
|
||||
requestText: '您好,送包裹到这个地方。',
|
||||
status: '404',
|
||||
statusText: 'Not Found',
|
||||
statusType: 'client-error',
|
||||
responseText: '这里没有这个人,您送错地方了。'
|
||||
},
|
||||
{
|
||||
id: 'redirect',
|
||||
name: '地址变更',
|
||||
method: 'GET',
|
||||
path: '/old-address',
|
||||
requestText: '您好,送包裹到这个地址。',
|
||||
status: '301',
|
||||
statusText: 'Moved',
|
||||
statusType: 'redirect',
|
||||
responseText: '这里搬走了,请送到新地址。'
|
||||
},
|
||||
{
|
||||
id: 'error',
|
||||
name: '家中故障',
|
||||
method: 'POST',
|
||||
path: '/api/order',
|
||||
requestText: '您好,我来送您订购的商品。',
|
||||
status: '500',
|
||||
statusText: 'Error',
|
||||
statusType: 'server-error',
|
||||
responseText: '抱歉,我们家系统出问题了,暂时无法接收。'
|
||||
}
|
||||
]
|
||||
|
||||
const currentScenario = ref(scenarios[0])
|
||||
const step = ref(0)
|
||||
const isDelivering = ref(false)
|
||||
|
||||
const selectScenario = (scenario) => {
|
||||
currentScenario.value = scenario
|
||||
reset()
|
||||
}
|
||||
|
||||
const nextStep = () => {
|
||||
if (step.value < 3) {
|
||||
isDelivering.value = true
|
||||
step.value++
|
||||
|
||||
if (step.value === 2) {
|
||||
setTimeout(() => {
|
||||
step.value++
|
||||
isDelivering.value = false
|
||||
}, 1000)
|
||||
} else {
|
||||
isDelivering.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
step.value = 0
|
||||
isDelivering.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.delivery-dialog-demo {
|
||||
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.dialog-icon {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dialog-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* 场景选择 */
|
||||
.scenario-selector {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.selector-label {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.scenario-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.scenario-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.scenario-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.scenario-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
.scenario-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-text { font-size: 13px; }
|
||||
|
||||
/* 对话场景 */
|
||||
.dialog-scene {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.scene-background {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 角色 */
|
||||
.character {
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.char-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.courier .char-avatar {
|
||||
background: linear-gradient(135deg, #409eff, #67c23a);
|
||||
}
|
||||
.recipient .char-avatar {
|
||||
background: linear-gradient(135deg, #e6a23c, #f56c6c);
|
||||
}
|
||||
.char-name {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 对话区域 */
|
||||
.conversation-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
opacity: 0.3;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.4s;
|
||||
}
|
||||
.message.sent {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.message.request {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.message.response {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 280px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
}
|
||||
.message.request .message-bubble {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
color: white;
|
||||
}
|
||||
.message.response .message-bubble.success {
|
||||
background: #67c23a;
|
||||
border-color: #67c23a;
|
||||
color: white;
|
||||
}
|
||||
.message.response .message-bubble.redirect {
|
||||
background: #e6a23c;
|
||||
border-color: #e6a23c;
|
||||
color: white;
|
||||
}
|
||||
.message.response .message-bubble.client-error {
|
||||
background: #f56c6c;
|
||||
border-color: #f56c6c;
|
||||
color: white;
|
||||
}
|
||||
.message.response .message-bubble.server-error {
|
||||
background: #909399;
|
||||
border-color: #909399;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bubble-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
.method-badge, .status-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
.path-text, .status-text {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.bubble-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 传输动画 */
|
||||
.transit-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: 40px;
|
||||
}
|
||||
.transit-line {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
.transit-package {
|
||||
position: absolute;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
animation: deliver 1s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes deliver {
|
||||
0% { transform: translateX(-100px); }
|
||||
50% { transform: translateX(0) scale(1.2); }
|
||||
100% { transform: translateX(100px); }
|
||||
}
|
||||
|
||||
/* 控制按钮 */
|
||||
.dialog-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.control-btn {
|
||||
padding: 12px 24px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.control-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.control-btn.primary {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
.control-btn.primary:hover:not(:disabled) {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
/* 状态码说明 */
|
||||
.status-legend {
|
||||
padding: 16px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.legend-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.legend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.legend-item.success .status-dot { background: #67c23a; }
|
||||
.legend-item.redirect .status-dot { background: #e6a23c; }
|
||||
.legend-item.client-error .status-dot { background: #f56c6c; }
|
||||
.legend-item.server-error .status-dot { background: #909399; }
|
||||
.status-code {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.status-meaning {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.scene-background {
|
||||
flex-direction: column;
|
||||
}
|
||||
.character {
|
||||
order: -1;
|
||||
}
|
||||
.legend-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,551 @@
|
||||
<!--
|
||||
TcpHandshakeDemo.vue
|
||||
TCP三次握手演示 - 打电话确认类比
|
||||
|
||||
用途:
|
||||
用"打电话确认对方是否在家"的生活化比喻,让用户理解TCP三次握手的必要性。
|
||||
把枯燥的技术概念变成直观的对话过程。
|
||||
-->
|
||||
<template>
|
||||
<div class="phone-call-demo">
|
||||
<!-- 标题 -->
|
||||
<div class="call-header">
|
||||
<span class="call-icon">[电话]</span>
|
||||
<span class="call-title">打电话确认:建立可靠连接</span>
|
||||
</div>
|
||||
|
||||
<!-- 场景说明 -->
|
||||
<div class="scenario-box">
|
||||
<div class="scenario-text">
|
||||
快递员到了<strong>"{{ targetAddress }}"</strong>附近,但他需要确认收件人是否在家,才能确保包裹能成功送达。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模式切换 -->
|
||||
<div class="mode-toggle">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode.id"
|
||||
@click="currentMode = mode.id"
|
||||
class="mode-btn"
|
||||
:class="{ active: currentMode === mode.id }"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 通话可视化 -->
|
||||
<div class="call-visualization">
|
||||
<!-- 左侧:快递员(客户端) -->
|
||||
<div class="caller-side">
|
||||
<div class="avatar-box client">
|
||||
<div class="avatar">员</div>
|
||||
<div class="avatar-label">快递员(你的电脑)</div>
|
||||
</div>
|
||||
<div class="speech-bubble client" v-if="currentStep >= 1">
|
||||
<div class="bubble-content">
|
||||
{{ currentMode === 'simple' ? '喂,在家吗?我是快递员!' : 'SYN: 请求连接' }}
|
||||
</div>
|
||||
<div class="bubble-arrow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:信号传输 -->
|
||||
<div class="signal-area">
|
||||
<div class="signal-line">
|
||||
<div
|
||||
v-for="(signal, i) in signals"
|
||||
:key="i"
|
||||
class="signal-dot"
|
||||
:class="[signal.type, { active: currentStep >= signal.step }]"
|
||||
>
|
||||
{{ signal.icon }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="connection-status" v-if="currentStep > 0">
|
||||
<span class="status-text" :class="{ connected: currentStep >= 3 }">
|
||||
{{ currentStep >= 3 ? '[已接通]' : '[连接中...]' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:收件人(服务器) -->
|
||||
<div class="receiver-side">
|
||||
<div class="speech-bubble server" v-if="currentStep >= 2">
|
||||
<div class="bubble-arrow"></div>
|
||||
<div class="bubble-content">
|
||||
{{ currentMode === 'simple' ? '在的!我听到了,请说!' : 'SYN-ACK: 确认收到' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-box server">
|
||||
<div class="avatar">主</div>
|
||||
<div class="avatar-label">收件人(服务器)</div>
|
||||
</div>
|
||||
<div class="speech-bubble server final" v-if="currentStep >= 3">
|
||||
<div class="bubble-arrow"></div>
|
||||
<div class="bubble-content">
|
||||
{{ currentMode === 'simple' ? '好的,开始说吧!' : 'ACK: 确认连接' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤说明 -->
|
||||
<div class="steps-explanation">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="step-card"
|
||||
:class="{ active: currentStep === index + 1, completed: currentStep > index + 1 }"
|
||||
@click="goToStep(index + 1)"
|
||||
>
|
||||
<div class="step-number">{{ index + 1 }}</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">{{ currentMode === 'simple' ? step.simpleTitle : step.techTitle }}</div>
|
||||
<div class="step-desc">{{ currentMode === 'simple' ? step.simpleDesc : step.techDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="control-panel">
|
||||
<button class="ctrl-btn" @click="prevStep" :disabled="currentStep <= 0">
|
||||
[上一步]
|
||||
</button>
|
||||
<button
|
||||
class="ctrl-btn primary"
|
||||
@click="nextStep"
|
||||
:disabled="currentStep >= 3"
|
||||
>
|
||||
{{ currentStep >= 3 ? '已接通' : '[下一步]' }}
|
||||
</button>
|
||||
<button class="ctrl-btn" @click="reset">
|
||||
[重置]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 为什么是三次? -->
|
||||
<div class="why-three-box" v-if="currentStep >= 3">
|
||||
<div class="why-icon">[提示]</div>
|
||||
<div class="why-content">
|
||||
<strong>为什么是三次,不是两次?</strong>
|
||||
<p>两次对话只能确认"你能发、对方能收",但对方不知道他的回复你有没有收到。三次对话确保<strong>双方都能发、双方都能收</strong>,通信才是可靠的。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentMode = ref('simple')
|
||||
const currentStep = ref(0)
|
||||
const targetAddress = ref('北京市朝阳区XX小区')
|
||||
|
||||
const modes = [
|
||||
{ id: 'simple', label: '生活语言' },
|
||||
{ id: 'tech', label: '技术术语' }
|
||||
]
|
||||
|
||||
const steps = [
|
||||
{
|
||||
simpleTitle: '快递员:喂,在家吗?',
|
||||
simpleDesc: '快递员拨通电话,确认对方是否在家',
|
||||
techTitle: '客户端发送 SYN',
|
||||
techDesc: 'Synchronize: 请求建立连接,携带初始序列号'
|
||||
},
|
||||
{
|
||||
simpleTitle: '收件人:在的,请说!',
|
||||
simpleDesc: '收件人确认在家,也表示可以接收包裹',
|
||||
techTitle: '服务器回复 SYN-ACK',
|
||||
techDesc: 'Synchronize-Acknowledge: 确认收到,也请求连接'
|
||||
},
|
||||
{
|
||||
simpleTitle: '快递员:好的,开始送!',
|
||||
simpleDesc: '快递员确认对方已准备好,开始运送',
|
||||
techTitle: '客户端回复 ACK',
|
||||
techDesc: 'Acknowledge: 确认收到,连接建立成功'
|
||||
}
|
||||
]
|
||||
|
||||
const signals = [
|
||||
{ type: 'syn', step: 1, icon: '发' },
|
||||
{ type: 'synack', step: 2, icon: '收' },
|
||||
{ type: 'ack', step: 3, icon: '通' }
|
||||
]
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
const goToStep = (step) => {
|
||||
currentStep.value = step
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
currentStep.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.phone-call-demo {
|
||||
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.call-icon {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
.call-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* 场景 */
|
||||
.scenario-box {
|
||||
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(103, 194, 58, 0.1));
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.scenario-text {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.scenario-text strong {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* 模式切换 */
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.mode-btn {
|
||||
padding: 8px 20px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.mode-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.mode-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 通话可视化 */
|
||||
.call-visualization {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.caller-side, .receiver-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar-box {
|
||||
text-align: center;
|
||||
}
|
||||
.avatar-box.client .avatar {
|
||||
background: linear-gradient(135deg, #409eff, #67c23a);
|
||||
}
|
||||
.avatar-box.server .avatar {
|
||||
background: linear-gradient(135deg, #e6a23c, #f56c6c);
|
||||
}
|
||||
.avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.avatar-label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.speech-bubble {
|
||||
position: relative;
|
||||
max-width: 160px;
|
||||
}
|
||||
.speech-bubble.client {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.speech-bubble.server {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.bubble-content {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.speech-bubble.client .bubble-content {
|
||||
background: #409eff;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.speech-bubble.server .bubble-content {
|
||||
background: #67c23a;
|
||||
color: white;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* 信号区域 */
|
||||
.signal-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.signal-line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.signal-dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
opacity: 0.2;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.4s;
|
||||
}
|
||||
.signal-dot.active {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
animation: pulseSignal 1s infinite;
|
||||
}
|
||||
.signal-dot.syn { background: #409eff; }
|
||||
.signal-dot.synack { background: #67c23a; }
|
||||
.signal-dot.ack { background: #e6a23c; }
|
||||
|
||||
@keyframes pulseSignal {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.4); }
|
||||
50% { box-shadow: 0 0 0 10px rgba(64, 158, 255, 0); }
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
padding: 6px 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
.status-text.connected {
|
||||
color: #67c23a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 步骤说明 */
|
||||
.steps-explanation {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.step-card {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.step-card:hover {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.step-card.active {
|
||||
opacity: 1;
|
||||
border-color: var(--vp-c-brand);
|
||||
background: rgba(64, 158, 255, 0.05);
|
||||
}
|
||||
.step-card.completed {
|
||||
opacity: 0.8;
|
||||
border-color: #67c23a;
|
||||
}
|
||||
.step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.step-card.active .step-number {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
.step-card.completed .step-number {
|
||||
background: #67c23a;
|
||||
color: white;
|
||||
}
|
||||
.step-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.step-desc {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 控制面板 */
|
||||
.control-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ctrl-btn {
|
||||
padding: 10px 20px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.ctrl-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.ctrl-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ctrl-btn.primary {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
.ctrl-btn.primary:hover:not(:disabled) {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
/* 为什么是三次 */
|
||||
.why-three-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, rgba(103, 194, 58, 0.1), rgba(64, 158, 255, 0.1));
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid #67c23a;
|
||||
animation: slideIn 0.4s ease;
|
||||
}
|
||||
.why-icon {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.why-content {
|
||||
flex: 1;
|
||||
}
|
||||
.why-content strong {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.why-content p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.call-visualization {
|
||||
flex-direction: column;
|
||||
}
|
||||
.signal-area {
|
||||
flex-direction: row;
|
||||
order: -1;
|
||||
}
|
||||
.signal-line {
|
||||
flex-direction: row;
|
||||
}
|
||||
.steps-explanation {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,517 @@
|
||||
<!--
|
||||
UrlParserDemo.vue
|
||||
URL解析演示 - 交互式可视化组件
|
||||
|
||||
用途:
|
||||
将 URL 解析过程可视化,通过颜色编码和分块展示,
|
||||
直观地展示 URL 的各个组成部分及其对应的技术含义和生活比喻。
|
||||
-->
|
||||
<template>
|
||||
<div class="url-parser-demo">
|
||||
<!-- 头部控制区 -->
|
||||
<div class="control-panel">
|
||||
<div class="header-section">
|
||||
<span class="icon">🔍</span>
|
||||
<span class="title">URL 解析器</span>
|
||||
</div>
|
||||
|
||||
<div class="examples-section">
|
||||
<span class="label">试一试:</span>
|
||||
<div class="button-group">
|
||||
<button
|
||||
v-for="ex in examples"
|
||||
:key="ex.name"
|
||||
@click="useExample(ex)"
|
||||
class="action-btn outline small"
|
||||
:class="{ active: currentExample === ex.name }"
|
||||
>
|
||||
{{ ex.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-section">
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
v-model="urlInput"
|
||||
type="text"
|
||||
placeholder="输入或粘贴一个网址..."
|
||||
@input="parseUrl"
|
||||
class="url-input"
|
||||
/>
|
||||
<button class="clear-btn" @click="clear" v-if="urlInput">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可视化展示区 -->
|
||||
<div class="visualization-area" v-if="parsed.protocol">
|
||||
<div class="url-blocks">
|
||||
<div
|
||||
v-for="(field, key) in formFields"
|
||||
:key="key"
|
||||
v-show="shouldShowField(key)"
|
||||
class="url-block"
|
||||
:class="[key, { active: hovered === key }]"
|
||||
:style="{ '--block-color': field.color, '--block-bg': hexToRgba(field.color, 0.15) }"
|
||||
@mouseenter="hovered = key"
|
||||
@mouseleave="hovered = null"
|
||||
>
|
||||
<span class="block-value">{{ getDisplayValue(key) }}</span>
|
||||
<span class="block-label">{{ field.techName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情说明卡片 -->
|
||||
<div class="info-card" v-if="hovered && formFields[hovered]">
|
||||
<div class="info-header" :style="{ borderLeftColor: formFields[hovered].color }">
|
||||
<span class="info-title">{{ formFields[hovered].techLabel }} ({{ formFields[hovered].techName }})</span>
|
||||
<span class="info-badge" :style="{ backgroundColor: formFields[hovered].color }">
|
||||
{{ formFields[hovered].icon }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="info-content">
|
||||
<div class="info-row">
|
||||
<div class="info-label">技术含义</div>
|
||||
<div class="info-value">
|
||||
<strong>{{ formFields[hovered].techDesc }}</strong>
|
||||
<div class="info-detail">{{ formFields[hovered].techDetail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-divider"></div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">生活比喻</div>
|
||||
<div class="info-value">
|
||||
<strong>{{ formFields[hovered].analogyLabel }}</strong>
|
||||
<div class="info-detail">{{ formFields[hovered].analogyDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div class="empty-state" v-else-if="!urlInput">
|
||||
<p>👆 在上方输入网址,查看它是由哪些部分组成的</p>
|
||||
</div>
|
||||
|
||||
<div class="default-info" v-else>
|
||||
<p>👆 鼠标悬停在上方色块上,查看详细解释</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const urlInput = ref('')
|
||||
const parsed = ref({})
|
||||
const hovered = ref(null)
|
||||
const currentExample = ref('')
|
||||
|
||||
const examples = [
|
||||
{ name: '百度搜索', url: 'https://www.baidu.com/s?wd=hello' },
|
||||
{ name: 'GitHub项目', url: 'https://github.com/vuejs/core' },
|
||||
{ name: '带端口', url: 'http://localhost:8080/api/users' },
|
||||
{ name: '带锚点', url: 'https://vuejs.org/guide/introduction.html#what-is-vue' }
|
||||
]
|
||||
|
||||
const formFields = {
|
||||
protocol: {
|
||||
techName: 'Protocol',
|
||||
techLabel: '协议',
|
||||
color: '#f43f5e', // Red
|
||||
icon: '规',
|
||||
analogyLabel: '快递公司',
|
||||
techDesc: '通信规则',
|
||||
analogyDesc: '决定是用"顺丰"(HTTPS)还是"平邮"(HTTP)传输',
|
||||
techDetail: 'https (加密) 或 http (明文)'
|
||||
},
|
||||
hostname: {
|
||||
techName: 'Hostname',
|
||||
techLabel: '域名',
|
||||
color: '#3b82f6', // Blue
|
||||
icon: '名',
|
||||
analogyLabel: '收件人',
|
||||
techDesc: '服务器地址',
|
||||
analogyDesc: '如 "google.com",方便人类记忆的名字',
|
||||
techDetail: '最终需要通过 DNS 解析为 IP 地址'
|
||||
},
|
||||
port: {
|
||||
techName: 'Port',
|
||||
techLabel: '端口',
|
||||
color: '#f59e0b', // Amber
|
||||
icon: '门',
|
||||
analogyLabel: '门牌号',
|
||||
techDesc: '服务入口',
|
||||
analogyDesc: '如 ":8080",大楼里的具体房间号',
|
||||
techDetail: '默认端口(80/443)通常会被浏览器省略'
|
||||
},
|
||||
pathname: {
|
||||
techName: 'Path',
|
||||
techLabel: '路径',
|
||||
color: '#10b981', // Emerald
|
||||
icon: '径',
|
||||
analogyLabel: '具体位置',
|
||||
techDesc: '资源路径',
|
||||
analogyDesc: '如 "/files/doc.txt",文件柜的位置',
|
||||
techDetail: '指向服务器上的具体资源'
|
||||
},
|
||||
search: {
|
||||
techName: 'Query',
|
||||
techLabel: '参数',
|
||||
color: '#8b5cf6', // Violet
|
||||
icon: '参',
|
||||
analogyLabel: '备注',
|
||||
techDesc: '查询参数',
|
||||
analogyDesc: '如 "?q=hello",告诉对方具体要求',
|
||||
techDetail: '键值对形式的附加数据'
|
||||
},
|
||||
hash: {
|
||||
techName: 'Hash',
|
||||
techLabel: '锚点',
|
||||
color: '#ec4899', // Pink
|
||||
icon: '锚',
|
||||
analogyLabel: '页码',
|
||||
techDesc: '页内定位',
|
||||
analogyDesc: '如 "#section1",书的某一页',
|
||||
techDetail: '浏览器滚动到指定位置,不会发送给服务器'
|
||||
}
|
||||
}
|
||||
|
||||
const hexToRgba = (hex, alpha) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
const shouldShowField = (key) => {
|
||||
const val = parsed.value[key]
|
||||
if (!val) return false
|
||||
if (val === '无') return false
|
||||
if (key === 'search' && (val === '' || val === '?')) return false
|
||||
if (key === 'hash' && (val === '' || val === '#')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getDisplayValue = (key) => {
|
||||
let val = parsed.value[key]
|
||||
|
||||
if (key === 'protocol') return val + '://'
|
||||
if (key === 'port') return ':' + val.replace('(默认)', '')
|
||||
// simple formatting
|
||||
return val
|
||||
}
|
||||
|
||||
const useExample = (ex) => {
|
||||
urlInput.value = ex.url
|
||||
currentExample.value = ex.name
|
||||
parseUrl()
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
urlInput.value = ''
|
||||
parsed.value = {}
|
||||
currentExample.value = ''
|
||||
}
|
||||
|
||||
const parseUrl = () => {
|
||||
if (!urlInput.value) {
|
||||
parsed.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let urlStr = urlInput.value.trim()
|
||||
// Auto-add protocol if missing for better UX
|
||||
if (!urlStr.match(/^https?:\/\//)) {
|
||||
if (urlStr.startsWith('localhost')) {
|
||||
urlStr = 'http://' + urlStr
|
||||
} else {
|
||||
urlStr = 'https://' + urlStr
|
||||
}
|
||||
}
|
||||
|
||||
const u = new URL(urlStr)
|
||||
|
||||
// Determine if port is explicit or default
|
||||
let portDisplay = u.port
|
||||
if (!portDisplay) {
|
||||
// Just for display logic in the parser, we might not show it if it's default/hidden
|
||||
// But let's show it if we want to be educational
|
||||
// Actually, let's only show if it's in the string or we want to be explicit
|
||||
// For visualizer, maybe better to show what's THERE.
|
||||
// But to be educational, maybe show implied?
|
||||
// Let's stick to what's in the URL object but handle defaults
|
||||
// If the user typed it, u.port is set. If implied, it's empty string.
|
||||
}
|
||||
|
||||
parsed.value = {
|
||||
protocol: u.protocol.replace(':', ''),
|
||||
hostname: u.hostname,
|
||||
port: u.port, // Only show if explicit
|
||||
pathname: u.pathname === '/' ? '/' : u.pathname,
|
||||
search: u.search,
|
||||
hash: u.hash
|
||||
}
|
||||
} catch (e) {
|
||||
// simplistic fallback or error state could go here
|
||||
// parsed.value = {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.url-parser-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.examples-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.action-btn.active {
|
||||
background-color: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.input-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.url-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.url-blocks {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.url-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
background-color: var(--block-bg);
|
||||
border: 1px solid var(--block-color);
|
||||
cursor: help;
|
||||
transition: all 0.2s;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.url-block:hover, .url-block.active {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.block-value {
|
||||
font-weight: bold;
|
||||
color: var(--block-color);
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.25rem;
|
||||
opacity: 0.7;
|
||||
color: var(--block-color);
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
animation: slide-up 0.3s ease;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.info-badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1px 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-divider {
|
||||
background: var(--vp-c-divider);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-detail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state, .default-info {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.info-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.info-divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,914 @@
|
||||
<!--
|
||||
UrlToBrowserQuickStart.vue
|
||||
网络快递之旅 - 全流程快速体验组件
|
||||
|
||||
用途:
|
||||
用"寄快递"的故事主线,让0基础用户在30秒内体验从输入URL到页面显示的完整过程。
|
||||
设计原则:故事化、可视化、即时反馈
|
||||
-->
|
||||
<template>
|
||||
<div class="delivery-journey">
|
||||
<!-- 故事标题 -->
|
||||
<div class="journey-header">
|
||||
<span class="journey-icon">[包裹]</span>
|
||||
<span class="journey-title">体验一次"网络快递"之旅</span>
|
||||
</div>
|
||||
|
||||
<!-- 快递单填写 -->
|
||||
<div class="delivery-form">
|
||||
<div class="form-label">填写快递单(输入网址):</div>
|
||||
<div class="address-input">
|
||||
<span class="protocol-badge">https://</span>
|
||||
<input
|
||||
v-model="url"
|
||||
type="text"
|
||||
placeholder="比如:baidu.com"
|
||||
@keyup.enter="startJourney"
|
||||
:disabled="isRunning"
|
||||
/>
|
||||
<button class="send-btn" @click="startJourney" :disabled="!url || isRunning">
|
||||
{{ isRunning ? '运送中...' : '寄出' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速选择 -->
|
||||
<div class="quick-select" v-if="!isRunning && !showResult">
|
||||
<span class="quick-hint">快速体验:</span>
|
||||
<button
|
||||
v-for="u in quickUrls"
|
||||
:key="u.domain"
|
||||
@click="url = u.domain; startJourney()"
|
||||
class="quick-btn"
|
||||
:title="u.desc"
|
||||
>
|
||||
{{ u.domain }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 双栏对照展示 -->
|
||||
<div class="comparison-view" v-if="isRunning || showResult">
|
||||
<div class="comparison-header">
|
||||
<div class="side-label delivery-side">寄快递流程</div>
|
||||
<div class="connection-hint">对应关系</div>
|
||||
<div class="side-label network-side">网络访问流程</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-steps">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
class="step-row"
|
||||
:class="{
|
||||
passed: currentStep > index,
|
||||
current: currentStep === index,
|
||||
waiting: currentStep < index
|
||||
}"
|
||||
>
|
||||
<!-- 左侧:快递流程 -->
|
||||
<div class="step-delivery">
|
||||
<div class="step-icon">{{ step.deliveryIcon }}</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">{{ step.deliveryTitle }}</div>
|
||||
<div class="step-desc">{{ step.deliveryDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:对应指示 -->
|
||||
<div class="step-connector">
|
||||
<div class="connector-line"></div>
|
||||
<div class="connector-arrow">→</div>
|
||||
<div class="connector-label">{{ step.mappingLabel }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:网络流程 -->
|
||||
<div class="step-network">
|
||||
<div class="step-icon">{{ step.networkIcon }}</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">{{ step.networkTitle }}</div>
|
||||
<div class="step-desc">{{ step.networkDesc }}</div>
|
||||
<div class="step-tech" v-if="currentStep >= index">{{ step.techDetail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前步骤高亮提示 -->
|
||||
<div class="current-step-hint" v-if="isRunning && steps[currentStep]">
|
||||
<div class="hint-label">当前阶段</div>
|
||||
<div class="hint-content">
|
||||
<span class="hint-delivery">{{ steps[currentStep].deliveryTitle }}</span>
|
||||
<span class="hint-equals">=</span>
|
||||
<span class="hint-network">{{ steps[currentStep].networkTitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-track" v-if="isRunning">
|
||||
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 送达结果 -->
|
||||
<div class="delivery-result" v-if="showResult">
|
||||
<div class="success-banner">
|
||||
包裹送达!耗时 {{ time }}ms
|
||||
</div>
|
||||
|
||||
<!-- 网页预览 -->
|
||||
<div class="page-preview">
|
||||
<div class="browser-chrome">
|
||||
<div class="chrome-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="chrome-address">{{ url }}</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div class="skeleton-line" style="width: 70%"></div>
|
||||
<div class="skeleton-line" style="width: 50%"></div>
|
||||
<div class="skeleton-img"></div>
|
||||
<div class="skeleton-line" style="width: 80%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="retry-btn" @click="reset">再寄一次</button>
|
||||
</div>
|
||||
|
||||
<!-- 名词对照卡片(默认显示) -->
|
||||
<div class="glossary-cards" v-if="!isRunning && !showResult">
|
||||
<div class="glossary-title">快递 vs 网络 名词对照</div>
|
||||
<div class="cards-grid">
|
||||
<div
|
||||
v-for="item in glossary"
|
||||
:key="item.delivery"
|
||||
class="glossary-card"
|
||||
@mouseenter="hoveredCard = item"
|
||||
@mouseleave="hoveredCard = null"
|
||||
>
|
||||
<div class="card-delivery">
|
||||
<span class="card-label">快递</span>
|
||||
<span class="card-value">{{ item.delivery }}</span>
|
||||
</div>
|
||||
<div class="card-arrow">↔</div>
|
||||
<div class="card-network">
|
||||
<span class="card-label">网络</span>
|
||||
<span class="card-value">{{ item.network }}</span>
|
||||
</div>
|
||||
<div class="card-explanation" v-if="hoveredCard === item">
|
||||
{{ item.explanation }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const url = ref('')
|
||||
const isRunning = ref(false)
|
||||
const showResult = ref(false)
|
||||
const currentStep = ref(0)
|
||||
const progress = ref(0)
|
||||
const time = ref(0)
|
||||
const hoveredCard = ref(null)
|
||||
|
||||
const quickUrls = [
|
||||
{ domain: 'baidu.com', desc: '百度搜索引擎' },
|
||||
{ domain: 'github.com', desc: '代码托管平台' },
|
||||
{ domain: 'vuejs.org', desc: 'Vue.js 官网' }
|
||||
]
|
||||
|
||||
// 步骤定义
|
||||
const steps = [
|
||||
{
|
||||
id: 'parse',
|
||||
deliveryIcon: '写',
|
||||
deliveryTitle: '填写快递单',
|
||||
deliveryDesc: '写明收件人、地址、电话',
|
||||
mappingLabel: '对应',
|
||||
networkIcon: '输',
|
||||
networkTitle: '输入网址',
|
||||
networkDesc: '浏览器解析 URL',
|
||||
techDetail: '协议 + 域名 + 路径 + 参数'
|
||||
},
|
||||
{
|
||||
id: 'dns',
|
||||
deliveryIcon: '查',
|
||||
deliveryTitle: '查地址簿',
|
||||
deliveryDesc: '姓名 → 门牌号',
|
||||
mappingLabel: '对应',
|
||||
networkIcon: 'DNS',
|
||||
networkTitle: 'DNS 查询',
|
||||
networkDesc: '域名 → IP 地址',
|
||||
techDetail: 'google.com → 142.250.80.46'
|
||||
},
|
||||
{
|
||||
id: 'tcp',
|
||||
deliveryIcon: '电',
|
||||
deliveryTitle: '打电话确认',
|
||||
deliveryDesc: '"在家吗?能收件吗?"',
|
||||
mappingLabel: '对应',
|
||||
networkIcon: 'TCP',
|
||||
networkTitle: 'TCP 三次握手',
|
||||
networkDesc: '建立可靠连接',
|
||||
techDetail: 'SYN → SYN-ACK → ACK'
|
||||
},
|
||||
{
|
||||
id: 'http',
|
||||
deliveryIcon: '送',
|
||||
deliveryTitle: '快递员送货',
|
||||
deliveryDesc: '把包裹送到对方手中',
|
||||
mappingLabel: '对应',
|
||||
networkIcon: 'HTTP',
|
||||
networkTitle: 'HTTP 传输',
|
||||
networkDesc: '请求网页数据',
|
||||
techDetail: 'GET /index.html → 200 OK'
|
||||
},
|
||||
{
|
||||
id: 'render',
|
||||
deliveryIcon: '拆',
|
||||
deliveryTitle: '拆开包裹',
|
||||
deliveryDesc: '看到礼物内容',
|
||||
mappingLabel: '对应',
|
||||
networkIcon: '渲染',
|
||||
networkTitle: '浏览器渲染',
|
||||
networkDesc: '显示网页内容',
|
||||
techDetail: 'HTML + CSS + JS → 像素'
|
||||
}
|
||||
]
|
||||
|
||||
// 名词对照表
|
||||
const glossary = [
|
||||
{
|
||||
delivery: '快递单',
|
||||
network: 'URL',
|
||||
explanation: '网页的完整地址,包含去哪里找、找什么资源'
|
||||
},
|
||||
{
|
||||
delivery: '收件人姓名',
|
||||
network: '域名',
|
||||
explanation: '服务器的名字,如 google.com,方便人类记忆'
|
||||
},
|
||||
{
|
||||
delivery: '门牌号',
|
||||
network: 'IP 地址',
|
||||
explanation: '服务器的数字地址,如 142.250.80.46,计算机使用'
|
||||
},
|
||||
{
|
||||
delivery: '查地址簿',
|
||||
network: 'DNS 查询',
|
||||
explanation: '把域名转换成 IP 地址的查询系统'
|
||||
},
|
||||
{
|
||||
delivery: '打电话确认',
|
||||
network: 'TCP 握手',
|
||||
explanation: '确保双方在线且能正常通信的确认过程'
|
||||
},
|
||||
{
|
||||
delivery: '快递员送货',
|
||||
network: 'HTTP 请求',
|
||||
explanation: '浏览器向服务器请求数据,服务器返回响应'
|
||||
},
|
||||
{
|
||||
delivery: '拆开包裹',
|
||||
network: '浏览器渲染',
|
||||
explanation: '把代码转换成屏幕上看到的图文页面'
|
||||
}
|
||||
]
|
||||
|
||||
const startJourney = () => {
|
||||
if (!url.value) return
|
||||
isRunning.value = true
|
||||
showResult.value = false
|
||||
currentStep.value = 0
|
||||
progress.value = 0
|
||||
const startTime = Date.now()
|
||||
|
||||
let step = 0
|
||||
const runStep = () => {
|
||||
if (step >= steps.length) {
|
||||
isRunning.value = false
|
||||
showResult.value = true
|
||||
time.value = Date.now() - startTime
|
||||
return
|
||||
}
|
||||
currentStep.value = step
|
||||
|
||||
// 进度动画
|
||||
let p = step * 20
|
||||
const interval = setInterval(() => {
|
||||
p += 1
|
||||
progress.value = p
|
||||
if (p >= (step + 1) * 20) {
|
||||
clearInterval(interval)
|
||||
step++
|
||||
setTimeout(runStep, 600)
|
||||
}
|
||||
}, 80)
|
||||
}
|
||||
runStep()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
url.value = ''
|
||||
isRunning.value = false
|
||||
showResult.value = false
|
||||
currentStep.value = 0
|
||||
progress.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.delivery-journey {
|
||||
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.journey-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.journey-icon {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.journey-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* 快递单 */
|
||||
.delivery-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.address-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.address-input:focus-within {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
.protocol-badge {
|
||||
padding: 8px 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
}
|
||||
.address-input input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
padding: 8px;
|
||||
outline: none;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.send-btn {
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, var(--vp-c-brand), #67c23a);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.send-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
.send-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 快速选择 */
|
||||
.quick-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.quick-hint {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
.quick-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.quick-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 双栏对照视图 */
|
||||
.comparison-view {
|
||||
margin-top: 20px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.comparison-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.side-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.delivery-side {
|
||||
background: linear-gradient(135deg, #e6f7ff, #bae7ff);
|
||||
color: #096dd9;
|
||||
}
|
||||
|
||||
.network-side {
|
||||
background: linear-gradient(135deg, #f6ffed, #d9f7be);
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.connection-hint {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
padding: 4px 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 步骤行 */
|
||||
.comparison-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.step-row.passed {
|
||||
opacity: 0.7;
|
||||
background: rgba(103, 194, 58, 0.05);
|
||||
}
|
||||
|
||||
.step-row.current {
|
||||
opacity: 1;
|
||||
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(103, 194, 58, 0.1));
|
||||
border: 2px solid var(--vp-c-brand);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 左侧:快递流程 */
|
||||
.step-delivery {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #e6f7ff, #f0f5ff);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #91d5ff;
|
||||
}
|
||||
|
||||
.step-delivery .step-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-delivery .step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #096dd9;
|
||||
}
|
||||
|
||||
.step-delivery .step-desc {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 中间:连接器 */
|
||||
.step-connector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.connector-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.connector-arrow {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.connector-label {
|
||||
font-size: 10px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 4px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 右侧:网络流程 */
|
||||
.step-network {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #f6ffed, #f0f9ff);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.step-network .step-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-network .step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.step-network .step-desc {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.step-network .step-tech {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-brand);
|
||||
margin-top: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* 当前步骤提示 */
|
||||
.current-step-hint {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(103, 194, 58, 0.1));
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint-label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hint-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-delivery {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #096dd9;
|
||||
padding: 6px 12px;
|
||||
background: #e6f7ff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.hint-equals {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.hint-network {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #389e0d;
|
||||
padding: 6px 12px;
|
||||
background: #f6ffed;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--vp-c-brand), #67c23a);
|
||||
border-radius: 4px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* 送达结果 */
|
||||
.delivery-result {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.success-banner {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #67c23a;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px;
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.page-preview {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.browser-chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.chrome-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.chrome-dots span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
.chrome-dots span:nth-child(1) { background: #ff5f57; }
|
||||
.chrome-dots span:nth-child(2) { background: #febc2e; }
|
||||
.chrome-dots span:nth-child(3) { background: #28c840; }
|
||||
.chrome-address {
|
||||
flex: 1;
|
||||
padding: 4px 10px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-align: left;
|
||||
}
|
||||
.page-content {
|
||||
padding: 20px;
|
||||
}
|
||||
.skeleton-line {
|
||||
height: 12px;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.skeleton-img {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, var(--vp-c-divider), var(--vp-c-bg-soft));
|
||||
border-radius: 8px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.retry-btn {
|
||||
padding: 10px 24px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.retry-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* 名词对照卡片 */
|
||||
.glossary-cards {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.glossary-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.glossary-card {
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.glossary-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-delivery,
|
||||
.card-network {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-network {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-delivery .card-label {
|
||||
background: #e6f7ff;
|
||||
color: #096dd9;
|
||||
}
|
||||
|
||||
.card-network .card-label {
|
||||
background: #f6ffed;
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.card-arrow {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.card-explanation {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--vp-c-divider);
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.comparison-header {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
flex-direction: row;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.connector-line {
|
||||
position: static;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.address-input {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user