feat: 更新附录交互组件和文档
This commit is contained in:
+161
-928
File diff suppressed because it is too large
Load Diff
@@ -1,700 +1,51 @@
|
||||
<!--
|
||||
DnsLookupDemo.vue
|
||||
DNS查询演示 - 增强技术细节版
|
||||
|
||||
设计理念:
|
||||
1. 循循善诱:通过"接力跑腿"的比喻,展示浏览器如何一步步找到IP。
|
||||
2. 技术硬核:新增终端模拟器,展示真实的 dig/系统命令输出,解决"太抽象"的问题。
|
||||
3. 紧凑布局:横向流式布局,固定底部详情板。
|
||||
-->
|
||||
<template>
|
||||
<div class="dns-compact">
|
||||
<!-- 顶部控制栏 -->
|
||||
<div class="top-bar">
|
||||
<div class="title-section">
|
||||
<span class="app-icon">🌐</span>
|
||||
<span class="app-title">DNS 寻址原理</span>
|
||||
</div>
|
||||
|
||||
<div class="target-select">
|
||||
<span class="label">目标:</span>
|
||||
<select
|
||||
v-model="selectedTargetIndex"
|
||||
:disabled="isSearching"
|
||||
@change="reset"
|
||||
>
|
||||
<option
|
||||
v-for="(t, i) in targets"
|
||||
:key="t.name"
|
||||
:value="i"
|
||||
>
|
||||
{{ t.name }} ({{ t.domain }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
v-if="!isSearching && !isFinished"
|
||||
class="action-btn primary"
|
||||
@click="startAutoSearch"
|
||||
>
|
||||
▶ 开始寻址
|
||||
</button>
|
||||
<button
|
||||
v-if="isSearching && !autoPlay"
|
||||
class="action-btn secondary"
|
||||
@click="nextStep"
|
||||
>
|
||||
⏭ 下一步
|
||||
</button>
|
||||
<button
|
||||
v-if="isFinished || isSearching"
|
||||
class="action-btn outline"
|
||||
@click="reset"
|
||||
>
|
||||
↺ 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条/状态展示 -->
|
||||
<div class="status-bar">
|
||||
<div
|
||||
v-if="!isSearching && !isFinished"
|
||||
class="status-text"
|
||||
>
|
||||
<span class="icon">👋</span>
|
||||
准备出发:去问问 <strong>{{ targets[selectedTargetIndex].domain }}</strong> 的 IP 是多少?
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isSearching"
|
||||
class="status-text running"
|
||||
>
|
||||
<span class="icon spin">⏳</span>
|
||||
正在询问:{{ queryLevels[currentStep]?.analogyName }}...
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="status-text success"
|
||||
>
|
||||
<span class="icon">✅</span>
|
||||
找到了!IP 地址是:<strong>{{ targets[selectedTargetIndex].ip }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可视化流程 (横向) -->
|
||||
<div class="flow-stage">
|
||||
<div
|
||||
v-for="(level, index) in queryLevels"
|
||||
:key="level.id"
|
||||
class="flow-step"
|
||||
:class="{
|
||||
active: currentStep === index,
|
||||
passed: currentStep > index,
|
||||
pending: currentStep < index
|
||||
}"
|
||||
@click="jumpToStep(index)"
|
||||
>
|
||||
<div
|
||||
class="step-icon-box"
|
||||
:style="{ '--step-color': level.color }"
|
||||
>
|
||||
<span class="step-icon">{{ level.analogyIcon }}</span>
|
||||
</div>
|
||||
<div class="step-label">
|
||||
{{ level.analogyName }}
|
||||
</div>
|
||||
|
||||
<!-- 连接线 -->
|
||||
<div
|
||||
v-if="index < queryLevels.length - 1"
|
||||
class="step-line"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部双面板:左侧生活比喻,右侧技术终端 -->
|
||||
<div class="info-panels">
|
||||
<!-- 左侧:生活场景 -->
|
||||
<div class="detail-panel analogy-panel">
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
>
|
||||
<div
|
||||
v-if="currentStep >= 0"
|
||||
:key="currentStep"
|
||||
class="panel-content"
|
||||
>
|
||||
<div
|
||||
class="panel-header"
|
||||
:style="{ color: currentLevel.color }"
|
||||
>
|
||||
<span class="header-icon">{{ currentLevel.analogyIcon }}</span>
|
||||
<span class="header-title">{{ currentLevel.analogyName }} ({{ currentLevel.techName }})</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="analogy-text">
|
||||
{{ currentLevel.analogyAction }}
|
||||
</p>
|
||||
<div class="tech-hint-badge">
|
||||
{{ currentLevel.techAction }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="panel-placeholder"
|
||||
>
|
||||
<span>生活场景视角</span>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:硬核终端 -->
|
||||
<div class="detail-panel terminal-panel">
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-dots">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
<div class="terminal-title">
|
||||
Terminal
|
||||
</div>
|
||||
</div>
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
>
|
||||
<div
|
||||
v-if="currentStep >= 0"
|
||||
:key="currentStep"
|
||||
class="terminal-body"
|
||||
>
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">{{ formatText(currentLevel.techCommand) }}</span>
|
||||
</div>
|
||||
<div class="cmd-output">
|
||||
<pre>{{ formatText(currentLevel.techOutput) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="terminal-placeholder"
|
||||
>
|
||||
<span>Waiting for command...</span>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="dns-lookup-demo">
|
||||
<div class="flow">
|
||||
<span class="domain">google.com</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="dns">DNS</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="ip">142.250.80.46</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } 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' }
|
||||
]
|
||||
|
||||
const selectedTargetIndex = ref(0)
|
||||
const currentStep = ref(-1)
|
||||
const isSearching = ref(false)
|
||||
const isFinished = ref(false)
|
||||
const autoPlay = ref(false)
|
||||
let timer = null
|
||||
|
||||
const queryLevels = [
|
||||
{
|
||||
id: 'browser',
|
||||
analogyName: '通讯录',
|
||||
analogyIcon: '📒',
|
||||
analogyAction: '先翻翻自己的通讯录(缓存),看最近有没有记过。',
|
||||
techIcon: 'Browser',
|
||||
techName: '浏览器缓存',
|
||||
techAction: '检查 Browser DNS Cache',
|
||||
color: '#67c23a',
|
||||
techCommand: 'chrome://net-internals/#dns',
|
||||
techOutput: 'Active entries: 0\nCache miss: No entry found for ${domain}',
|
||||
qa: {
|
||||
title: '🤔 浏览器能记多久?',
|
||||
content: [
|
||||
{
|
||||
q: '是一直记着吗?',
|
||||
a: '不是的。通常只有几分钟(比如 chrome 是一分钟)。而且每家浏览器(Chrome, Firefox)的"记性"都不太一样。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'os',
|
||||
analogyName: '记事本',
|
||||
analogyIcon: '📝',
|
||||
analogyAction: '问问操作系统(管家),查查系统 hosts 文件或缓存。',
|
||||
techIcon: 'OS',
|
||||
techName: '系统缓存/Hosts',
|
||||
techAction: '检查 OS Cache / hosts',
|
||||
color: '#409eff',
|
||||
techCommand: 'cat /etc/hosts',
|
||||
techOutput: '127.0.0.1 localhost\n::1 localhost\n# No match for ${domain}',
|
||||
qa: {
|
||||
title: '🤔 什么是 hosts 文件?',
|
||||
content: [
|
||||
{
|
||||
q: '它有什么用?',
|
||||
a: '它是你电脑里的"私人通讯录"。你可以在这里手动强行指定 IP(比如开发时把 test.com 指向本机)。黑客有时也改这里来劫持网站。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ldns',
|
||||
analogyName: '传达室',
|
||||
analogyIcon: '💁',
|
||||
analogyAction: '问小区的传达室(本地DNS),让他帮忙去外面问。',
|
||||
techIcon: 'LDNS',
|
||||
techName: '本地DNS (递归)',
|
||||
techAction: '向 ISP DNS 发起递归查询',
|
||||
color: '#e6a23c',
|
||||
techCommand: 'dig ${domain} @192.168.1.1',
|
||||
techOutput: ';; QUESTION SECTION:\n;${domain}. IN A\n\n;; ANSWER SECTION:\n(empty) -> Recursion Desired',
|
||||
qa: {
|
||||
title: '🤔 为什么叫"递归"?',
|
||||
content: [
|
||||
{
|
||||
q: '通俗点解释?',
|
||||
a: '就像你问传达室大爷,大爷去帮你跑腿问一圈回来告诉你结果。你只问了一次,大爷跑了好几趟,这就叫递归(帮你跑到底)。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'root',
|
||||
analogyName: '总局',
|
||||
analogyIcon: '🏛️',
|
||||
analogyAction: '传达室问全球总局:".com" 归谁管?',
|
||||
techIcon: 'Root',
|
||||
techName: '根域名服务器',
|
||||
techAction: '查询 Root Server',
|
||||
color: '#f56c6c',
|
||||
techCommand: 'dig ${domain} @a.root-servers.net +norecurse',
|
||||
techOutput: ';; AUTHORITY SECTION:\ncom. 172800 IN NS a.gtld-servers.net.\n;; ADDITIONAL SECTION:\na.gtld-servers.net. IN A 192.5.6.30',
|
||||
qa: {
|
||||
title: '🤔 全球只有13台吗?',
|
||||
content: [
|
||||
{
|
||||
q: '那样不会被挤爆吗?',
|
||||
a: '不是13台,是13组!每组都有几百台"分身"(镜像服务器)分布在全球,包括中国也有,所以不用担心断网。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tld',
|
||||
analogyName: '分局',
|
||||
analogyIcon: '🏢',
|
||||
analogyAction: '去问 ".com" 分局:baidu.com 归谁管?',
|
||||
techIcon: 'TLD',
|
||||
techName: '顶级域名服务器',
|
||||
techAction: '查询 TLD Server',
|
||||
color: '#909399',
|
||||
techCommand: 'dig ${domain} @a.gtld-servers.net +norecurse',
|
||||
techOutput: ';; AUTHORITY SECTION:\n${domain}. 172800 IN NS ns1.${domain}.\n;; ADDITIONAL SECTION:\nns1.${domain}. IN A 202.108.22.5',
|
||||
qa: {
|
||||
title: '🤔 谁在管理 .com?',
|
||||
content: [
|
||||
{
|
||||
q: '所有的后缀都一样吗?',
|
||||
a: '不一样。.com 归 Verisign 公司管,.cn 归中国互联网络信息中心(CNNIC)管。所以要找不同的"分局"。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'auth',
|
||||
analogyName: '办事处',
|
||||
analogyIcon: '📍',
|
||||
analogyAction: '找到目标办事处:请告诉我 www 的 IP。',
|
||||
techIcon: 'Auth',
|
||||
techName: '权威DNS服务器',
|
||||
techAction: '查询 Authoritative Server',
|
||||
color: '#8e44ad',
|
||||
techCommand: 'dig ${domain} @ns1.${domain} +norecurse',
|
||||
techOutput: ';; ANSWER SECTION:\n${domain}. 600 IN A ${ip}\n;; Query time: 24 msec',
|
||||
qa: {
|
||||
title: '🤔 为什么叫"权威"?',
|
||||
content: [
|
||||
{
|
||||
q: '它说的话最准吗?',
|
||||
a: '对!因为它就是域名的主人(比如百度自己)管理的服务器,它说的话是一手资料,不像前面的可能只是传话。'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const currentLevel = computed(() =>
|
||||
currentStep.value >= 0 ? queryLevels[currentStep.value] : {}
|
||||
)
|
||||
|
||||
// 简单的模板替换函数
|
||||
const formatText = (text) => {
|
||||
if (!text) return ''
|
||||
const currentTarget = targets[selectedTargetIndex.value]
|
||||
return text
|
||||
.replace(/\${domain}/g, currentTarget.domain)
|
||||
.replace(/\${ip}/g, currentTarget.ip)
|
||||
}
|
||||
|
||||
const startAutoSearch = () => {
|
||||
reset()
|
||||
isSearching.value = true
|
||||
autoPlay.value = true
|
||||
nextStep()
|
||||
}
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < queryLevels.length - 1) {
|
||||
currentStep.value++
|
||||
if (autoPlay.value) {
|
||||
timer = setTimeout(nextStep, 2500) // 增加时间给用户看终端
|
||||
}
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
isFinished.value = true
|
||||
isSearching.value = false
|
||||
autoPlay.value = false
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
currentStep.value = -1
|
||||
isSearching.value = false
|
||||
isFinished.value = false
|
||||
autoPlay.value = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
const jumpToStep = (index) => {
|
||||
if (isSearching.value && autoPlay.value) return
|
||||
currentStep.value = index
|
||||
isSearching.value = true
|
||||
isFinished.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dns-compact {
|
||||
.dns-lookup-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.6rem 0.8rem;
|
||||
margin: 0.75rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
.flow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
.domain {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.target-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.target-select select {
|
||||
padding: 4px 8px;
|
||||
.dns {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
.ip {
|
||||
color: #059669;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-btn.primary { background: var(--vp-c-brand); color: white; }
|
||||
.action-btn.secondary { background: var(--vp-c-bg-alt); border-color: var(--vp-c-divider); color: var(--vp-c-text-1); }
|
||||
.action-btn.outline { border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-2); background: transparent; }
|
||||
|
||||
.status-bar {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-text strong { color: var(--vp-c-brand); margin: 0 4px; }
|
||||
|
||||
/* 流程图部分 */
|
||||
.flow-stage {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 4px;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 50px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.flow-step:last-child { flex: 0 0 auto; }
|
||||
.flow-step.active, .flow-step.passed { opacity: 1; }
|
||||
|
||||
.step-icon-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: all 0.3s;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.flow-step.active .step-icon-box {
|
||||
border-color: var(--step-color);
|
||||
background: var(--step-color);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.flow-step.passed .step-icon-box {
|
||||
border-color: var(--step-color);
|
||||
color: var(--step-color);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flow-step.active .step-label {
|
||||
color: var(--step-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.flow-step.passed .step-line { background: var(--step-color); }
|
||||
|
||||
/* 底部双面板布局 */
|
||||
.info-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.info-panels { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
overflow: hidden;
|
||||
height: 180px; /* 固定高度防止跳动 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 左侧生活比喻面板 */
|
||||
.analogy-panel {
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.analogy-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tech-hint-badge {
|
||||
display: inline-block;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
align-self: flex-start;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.panel-placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.arrow {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 右侧终端面板 */
|
||||
.terminal-panel {
|
||||
background: #1e1e1e; /* 强制深色背景 */
|
||||
border-color: #333;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #2d2d2d;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.terminal-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.terminal-dots span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
}
|
||||
.terminal-dots span:nth-child(1) { background: #ff5f56; }
|
||||
.terminal-dots span:nth-child(2) { background: #ffbd2e; }
|
||||
.terminal-dots span:nth-child(3) { background: #27c93f; }
|
||||
|
||||
.terminal-title {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cmd-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prompt { color: #27c93f; font-weight: bold; }
|
||||
.cmd { color: #fff; }
|
||||
|
||||
.cmd-output pre {
|
||||
margin: 0;
|
||||
color: #aaa;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.terminal-placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
display: inline-block;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
|
||||
/* Dark mode 适配 - 这里主要针对非终端部分 */
|
||||
:root.dark .dns-compact {
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,531 +1,198 @@
|
||||
<!--
|
||||
HttpExchangeDemo.vue
|
||||
HTTP请求响应演示 - 紧凑交互版
|
||||
|
||||
设计理念:
|
||||
1. 循循善诱:用"快递员投递"类比 HTTP 请求响应。
|
||||
2. 紧凑布局:横向舞台,固定底部详情板。
|
||||
-->
|
||||
<template>
|
||||
<div class="http-compact">
|
||||
<!-- 顶部标题与场景选择 -->
|
||||
<div class="top-bar">
|
||||
<div class="title-section">
|
||||
<span class="app-icon">📦</span>
|
||||
<span class="app-title">HTTP 请求/响应</span>
|
||||
</div>
|
||||
|
||||
<div class="scenario-tabs">
|
||||
<button
|
||||
v-for="s in scenarios"
|
||||
:key="s.id"
|
||||
class="tab-btn"
|
||||
:class="{ active: currentScenario.id === s.id }"
|
||||
:disabled="isAnimating"
|
||||
@click="selectScenario(s)"
|
||||
>
|
||||
{{ s.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
class="action-btn primary"
|
||||
@click="toggleAutoPlay"
|
||||
>
|
||||
{{ isAutoPlaying ? '⏸' : '▶ 演示' }}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn outline"
|
||||
@click="reset"
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
</div>
|
||||
<div class="http-exchange-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">HTTP 请求/响应</span>
|
||||
<span class="subtitle">浏览器与服务器的对话</span>
|
||||
</div>
|
||||
|
||||
<!-- 核心可视化舞台 -->
|
||||
<div class="stage-area">
|
||||
<!-- 客户端 -->
|
||||
<div class="actor client">
|
||||
<div class="avatar-box">
|
||||
<span class="avatar-icon">🧑💻</span>
|
||||
<span class="avatar-label">浏览器</span>
|
||||
<div class="exchange-flow">
|
||||
<div class="actor browser">
|
||||
<span class="actor-icon">🧑💻</span>
|
||||
<span class="actor-name">浏览器</span>
|
||||
</div>
|
||||
|
||||
<div class="messages">
|
||||
<div class="request-box">
|
||||
<span class="method">GET</span>
|
||||
<span class="path">/search?q=hello</span>
|
||||
<span class="arrow">→</span>
|
||||
</div>
|
||||
<div class="response-box">
|
||||
<span class="arrow">←</span>
|
||||
<span class="status">200 OK</span>
|
||||
<span class="size">HTML 页面</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传输通道 -->
|
||||
<div class="channel">
|
||||
<div class="channel-bg" />
|
||||
|
||||
<!-- 请求包 -->
|
||||
<div
|
||||
class="packet request"
|
||||
:class="{ moving: step === 1, done: step > 1 }"
|
||||
>
|
||||
<span class="packet-icon">📤</span>
|
||||
<span class="packet-label">GET</span>
|
||||
</div>
|
||||
|
||||
<!-- 响应包 -->
|
||||
<div
|
||||
v-if="step >= 2"
|
||||
class="packet response"
|
||||
:class="{ moving: step === 2, done: step > 2 }"
|
||||
>
|
||||
<span class="packet-icon">📦</span>
|
||||
<span class="packet-label">{{ currentScenario.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务器 -->
|
||||
<div class="actor server">
|
||||
<div class="avatar-box">
|
||||
<span class="avatar-icon">🏢</span>
|
||||
<span class="avatar-label">服务器</span>
|
||||
</div>
|
||||
<span class="actor-icon">🖥️</span>
|
||||
<span class="actor-name">服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部详情面板 (固定高度) -->
|
||||
<div class="detail-panel">
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
>
|
||||
<div
|
||||
v-if="step > 0"
|
||||
:key="step"
|
||||
class="detail-content"
|
||||
>
|
||||
<!-- 左侧状态徽章 -->
|
||||
<div
|
||||
class="detail-left"
|
||||
:style="{ borderColor: getStatusColor() }"
|
||||
>
|
||||
<div
|
||||
class="status-badge"
|
||||
:class="currentScenario.statusType"
|
||||
>
|
||||
{{ step === 1 ? '请求中' : currentScenario.status + ' ' + currentScenario.statusText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-divider" />
|
||||
<div class="code-preview">
|
||||
<div class="code-block">
|
||||
<div class="code-header">请求</div>
|
||||
<code>GET /search?q=hello HTTP/1.1</code>
|
||||
<code>Host: www.google.com</code>
|
||||
</div>
|
||||
<div class="code-block">
|
||||
<div class="code-header">响应</div>
|
||||
<code>HTTP/1.1 200 OK</code>
|
||||
<code>Content-Type: text/html</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情 -->
|
||||
<div class="detail-right">
|
||||
<div class="info-row">
|
||||
<span class="tag life">快递员说</span>
|
||||
<span class="text highlight">
|
||||
{{ step === 1 ? currentScenario.requestText : currentScenario.responseText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="tag tech">技术报文</span>
|
||||
<span class="text code">
|
||||
{{ step === 1 ? `${currentScenario.method} ${currentScenario.path} HTTP/1.1` : `HTTP/1.1 ${currentScenario.status} ${currentScenario.statusText}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="detail-placeholder"
|
||||
>
|
||||
<span class="guide-bounce">📦</span>
|
||||
<span>选择一个场景,点击"演示"看看发生了什么</span>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
HTTP 是请求-响应模式:浏览器发送请求,服务器返回状态码和响应内容。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
id: 'success',
|
||||
name: '正常送达',
|
||||
method: 'GET',
|
||||
path: '/index.html',
|
||||
requestText: '您好,请给我 index.html 的包裹!',
|
||||
status: '200',
|
||||
statusText: 'OK',
|
||||
statusType: 'success',
|
||||
responseText: '好的,这是您的 index.html,请签收!',
|
||||
qa: {
|
||||
title: '🤔 200 OK 是什么意思?',
|
||||
content: [
|
||||
{
|
||||
q: '200 这个数字代表什么?',
|
||||
a: '就像快递单上的"已妥投"。代表一切顺利,服务器成功找到了你要的东西并给了你。'
|
||||
},
|
||||
{
|
||||
q: 'GET 是什么?',
|
||||
a: '就像你对服务员说"给我来一份菜单"。是向服务器"索要"东西的意思。绝大多数网页访问都是 GET 请求。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'notfound',
|
||||
name: '查无此人',
|
||||
method: 'GET',
|
||||
path: '/nopage',
|
||||
requestText: '您好,我要找 nopage 这个人。',
|
||||
status: '404',
|
||||
statusText: 'Not Found',
|
||||
statusType: 'error',
|
||||
responseText: '抱歉,这里查无此人 (404)。',
|
||||
qa: {
|
||||
title: '🤔 为什么叫 404?',
|
||||
content: [
|
||||
{
|
||||
q: '是谁的错?',
|
||||
a: '通常是"你"(客户端)的错。4开头的代码都代表客户端问题,比如你地址输错了,或者这个网页已经被删除了。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'redirect',
|
||||
name: '搬家了',
|
||||
method: 'GET',
|
||||
path: '/old-path',
|
||||
requestText: '您好,送到 old-path 这里。',
|
||||
status: '301',
|
||||
statusText: 'Moved',
|
||||
statusType: 'warn',
|
||||
responseText: '这里搬家了,请去新地址 (301)。',
|
||||
qa: {
|
||||
title: '🤔 301 重定向是什么?',
|
||||
content: [
|
||||
{
|
||||
q: '浏览器会怎么做?',
|
||||
a: '浏览器收到 301 后,会自动去访问新的地址。这个过程很快,你可能都感觉不到。'
|
||||
},
|
||||
{
|
||||
q: '为什么要重定向?',
|
||||
a: '就像店铺搬迁要在门口贴个告示。保证收藏了旧网址的老顾客也能找到新店。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'servererror',
|
||||
name: '系统崩溃',
|
||||
method: 'GET',
|
||||
path: '/api/data',
|
||||
requestText: '您好,我要取数据。',
|
||||
status: '500',
|
||||
statusText: 'Error',
|
||||
statusType: 'error',
|
||||
responseText: '抱歉,仓库塌了,暂时无法取货 (500)。',
|
||||
qa: {
|
||||
title: '🤔 500 是谁的错?',
|
||||
content: [
|
||||
{
|
||||
q: '我能修好它吗?',
|
||||
a: '不能。5开头的代码代表"服务器"出问题了(比如代码崩了、数据库挂了)。跟你没关系,只能等待网站管理员修复。'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const currentScenario = ref(scenarios[0])
|
||||
const step = ref(0) // 0: Idle, 1: Requesting, 2: Responding, 3: Done
|
||||
const isAnimating = ref(false)
|
||||
const isAutoPlaying = ref(false)
|
||||
let timer = null
|
||||
|
||||
const selectScenario = (s) => {
|
||||
if (isAnimating.value) return
|
||||
currentScenario.value = s
|
||||
reset()
|
||||
}
|
||||
|
||||
const toggleAutoPlay = () => {
|
||||
if (isAutoPlaying.value) {
|
||||
reset()
|
||||
} else {
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
const startAnimation = () => {
|
||||
if (isAnimating.value) return
|
||||
isAnimating.value = true
|
||||
isAutoPlaying.value = true
|
||||
step.value = 1
|
||||
|
||||
// Step 1: Request (Client -> Server)
|
||||
timer = setTimeout(() => {
|
||||
step.value = 2
|
||||
// Step 2: Response (Server -> Client)
|
||||
timer = setTimeout(() => {
|
||||
step.value = 3
|
||||
isAnimating.value = false
|
||||
isAutoPlaying.value = false
|
||||
}, 1500)
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
clearTimeout(timer)
|
||||
step.value = 0
|
||||
isAnimating.value = false
|
||||
isAutoPlaying.value = false
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (step.value === 1) return '#3b82f6' // Blue for request
|
||||
const type = currentScenario.value.statusType
|
||||
if (type === 'success') return '#10b981'
|
||||
if (type === 'warn') return '#f59e0b'
|
||||
if (type === 'error') return '#ef4444'
|
||||
return '#909399'
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.http-compact {
|
||||
.http-exchange-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.scenario-tabs {
|
||||
display: flex;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 2px;
|
||||
border-radius: 6px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.action-btn.outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* 舞台区 */
|
||||
.stage-area {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
padding: 0 30px;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.exchange-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
z-index: 2;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.avatar-icon { font-size: 28px; }
|
||||
.avatar-label { font-size: 12px; color: var(--vp-c-text-2); margin-top: 4px; }
|
||||
|
||||
.channel {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
margin: 0 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.actor-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.channel-bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
top: 50%;
|
||||
.actor-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.packet {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: all 1.5s ease-in-out;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.packet-icon { font-size: 20px; }
|
||||
.packet-label { font-size: 10px; color: var(--vp-c-text-2); background: var(--vp-c-bg); padding: 0 2px; }
|
||||
|
||||
.packet.request { left: 0; opacity: 1; }
|
||||
.packet.request.moving { left: 100%; transform: translateX(-100%); opacity: 0; }
|
||||
/* Request moves from 0 to 100% then disappears */
|
||||
|
||||
.packet.response { left: 100%; transform: translateX(-100%); opacity: 0; }
|
||||
.packet.response.moving { left: 0; transform: translateX(0); opacity: 1; }
|
||||
/* Response starts at 100%, moves to 0 */
|
||||
|
||||
/* 动画调整 */
|
||||
.packet.request.moving {
|
||||
animation: sendRequest 1.5s forwards;
|
||||
}
|
||||
|
||||
.packet.response.moving {
|
||||
animation: sendResponse 1.5s forwards;
|
||||
}
|
||||
|
||||
@keyframes sendRequest {
|
||||
0% { left: 0; opacity: 1; transform: translateX(0); }
|
||||
90% { left: 100%; opacity: 1; transform: translateX(-100%); }
|
||||
100% { left: 100%; opacity: 0; transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
@keyframes sendResponse {
|
||||
0% { left: 100%; opacity: 1; transform: translateX(-100%); }
|
||||
100% { left: 0; opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* 详情面板 */
|
||||
.detail-panel {
|
||||
height: 80px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
padding-right: 16px;
|
||||
border-right: 2px solid transparent;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-badge.success { background: #10b981; }
|
||||
.status-badge.warn { background: #f59e0b; }
|
||||
.status-badge.error { background: #ef4444; }
|
||||
|
||||
.detail-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: var(--vp-c-divider);
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.detail-right {
|
||||
.messages {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
.request-box,
|
||||
.response-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag.life { background: #e6f7ff; color: #1890ff; }
|
||||
.tag.tech { background: #f6ffed; color: #52c41a; }
|
||||
|
||||
.text { font-size: 13px; color: var(--vp-c-text-1); }
|
||||
.text.highlight { font-weight: 500; color: var(--vp-c-brand); }
|
||||
.text.code { font-family: monospace; }
|
||||
|
||||
.detail-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--vp-c-text-3);
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.guide-bounce { animation: bounce 1.5s infinite; }
|
||||
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
||||
.method {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: #2563eb;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.path {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: #059669;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.size {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 0.3rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,810 +1,172 @@
|
||||
<!--
|
||||
TcpHandshakeDemo.vue
|
||||
TCP三次握手演示 - 紧凑交互版
|
||||
|
||||
设计理念:
|
||||
1. 循循善诱:用"打电话"的生活场景类比 TCP 连接建立过程。
|
||||
2. 紧凑布局:保留核心可视化区,使用固定底部详情板代替长列表。
|
||||
-->
|
||||
<template>
|
||||
<div class="tcp-compact">
|
||||
<!-- 顶部标题与控制 -->
|
||||
<div class="top-bar">
|
||||
<div class="title-section">
|
||||
<span class="app-icon">📞</span>
|
||||
<span class="app-title">TCP 三次握手</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
v-if="currentStep < 3"
|
||||
class="action-btn primary"
|
||||
:disabled="currentStep >= 3"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ currentStep === 0 ? '▶ 开始拨号' : '下一步 ➔' }}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn outline"
|
||||
@click="reset"
|
||||
>
|
||||
↺ 重置
|
||||
</button>
|
||||
</div>
|
||||
<div class="tcp-handshake-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">TCP 三次握手</span>
|
||||
<span class="subtitle">建立可靠连接的过程</span>
|
||||
</div>
|
||||
|
||||
<!-- 核心可视化舞台 -->
|
||||
<div class="stage-area">
|
||||
<!-- 左侧:客户端/快递员 -->
|
||||
<div class="handshake-flow">
|
||||
<div class="actor client">
|
||||
<div class="avatar-box">
|
||||
<span class="avatar-icon">🧑💻</span>
|
||||
<span class="avatar-label">客户端 (你)</span>
|
||||
</div>
|
||||
<transition name="pop">
|
||||
<div
|
||||
v-if="currentStep >= 1"
|
||||
class="bubble client"
|
||||
>
|
||||
{{ getBubbleText(1) }}
|
||||
</div>
|
||||
</transition>
|
||||
<span class="actor-icon">🧑💻</span>
|
||||
<span class="actor-name">客户端</span>
|
||||
</div>
|
||||
|
||||
<!-- 中间:连接状态线 -->
|
||||
<div class="connection-line">
|
||||
<div class="line-bg" />
|
||||
<div
|
||||
class="signal-packet"
|
||||
:class="getSignalClass()"
|
||||
>
|
||||
<span
|
||||
v-if="currentStep > 0"
|
||||
class="packet-icon"
|
||||
>{{ getSignalIcon() }}</span>
|
||||
<div class="messages">
|
||||
<div class="message-row">
|
||||
<span class="msg-label">SYN</span>
|
||||
<span class="msg-arrow">→</span>
|
||||
<span class="msg-desc">"我能连你吗?"</span>
|
||||
</div>
|
||||
<div
|
||||
class="status-badge"
|
||||
:class="{ connected: currentStep === 3 }"
|
||||
>
|
||||
{{ currentStep === 3 ? '✅ 连接建立' : '⏳ 连接中...' }}
|
||||
<div class="message-row">
|
||||
<span class="msg-desc">"能,你也能收到我吗?"</span>
|
||||
<span class="msg-arrow">←</span>
|
||||
<span class="msg-label">SYN-ACK</span>
|
||||
</div>
|
||||
<div class="message-row">
|
||||
<span class="msg-label">ACK</span>
|
||||
<span class="msg-arrow">→</span>
|
||||
<span class="msg-desc">"能,开始吧!"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:服务器/收件人 -->
|
||||
<div class="actor server">
|
||||
<div class="avatar-box">
|
||||
<span class="avatar-icon">🖥️</span>
|
||||
<span class="avatar-label">服务器</span>
|
||||
</div>
|
||||
<transition name="pop">
|
||||
<div
|
||||
v-if="currentStep >= 2"
|
||||
class="bubble server"
|
||||
>
|
||||
{{ getBubbleText(2) }}
|
||||
</div>
|
||||
</transition>
|
||||
<span class="actor-icon">🖥️</span>
|
||||
<span class="actor-name">服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤进度条 (可点击跳转) -->
|
||||
<div class="step-indicator">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="step-dot"
|
||||
:class="{ active: currentStep === index + 1, passed: currentStep > index + 1 }"
|
||||
:title="step.techTitle"
|
||||
@click="goToStep(index + 1)"
|
||||
>
|
||||
<span class="dot-num">{{ index + 1 }}</span>
|
||||
<span
|
||||
v-if="index < steps.length - 1"
|
||||
class="dot-line"
|
||||
/>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span class="status-badge success">✓ 连接已建立</span>
|
||||
</div>
|
||||
|
||||
<!-- 底部详情面板 (固定高度) -->
|
||||
<div class="detail-panel">
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
>
|
||||
<div
|
||||
v-if="currentStep > 0"
|
||||
:key="currentStep"
|
||||
class="detail-content"
|
||||
>
|
||||
<div
|
||||
class="detail-left"
|
||||
:style="{ borderColor: getCurrentStepColor() }"
|
||||
>
|
||||
<div
|
||||
class="step-badge"
|
||||
:style="{ background: getCurrentStepColor() }"
|
||||
>
|
||||
步骤 {{ currentStep }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-divider" />
|
||||
|
||||
<div class="detail-right">
|
||||
<div class="info-row">
|
||||
<span class="tag life">生活对话</span>
|
||||
<span class="text highlight">{{ steps[currentStep-1].simpleTitle }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="tag tech">技术原理</span>
|
||||
<div class="tech-content">
|
||||
<div class="tech-desc">
|
||||
{{ steps[currentStep-1].techDesc }}
|
||||
</div>
|
||||
<!-- 动态名词解码卡片 -->
|
||||
<div class="term-glossary">
|
||||
<div
|
||||
v-for="term in steps[currentStep-1].terms"
|
||||
:key="term.key"
|
||||
class="term-item"
|
||||
>
|
||||
<span class="term-key">{{ term.key }}</span>
|
||||
<span class="term-val">{{ term.val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码实现细节 (折叠) -->
|
||||
<details
|
||||
v-if="steps[currentStep-1].codeImpl"
|
||||
class="code-details"
|
||||
>
|
||||
<summary class="code-summary">
|
||||
<span class="summary-icon">🛠️</span>
|
||||
<span class="summary-text">技术深究:底层代码如何实现?</span>
|
||||
</summary>
|
||||
<div class="code-block-wrapper">
|
||||
<div class="code-title">
|
||||
{{ steps[currentStep-1].codeImpl.title }}
|
||||
</div>
|
||||
<pre class="code-block"><code v-html="steps[currentStep-1].codeImpl.code" /></pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 技术问答 (折叠) - 仅在有问答时显示 -->
|
||||
<details
|
||||
v-if="steps[currentStep-1].qa"
|
||||
class="code-details qa-details"
|
||||
>
|
||||
<summary class="code-summary qa-summary">
|
||||
<span class="summary-icon">🎓</span>
|
||||
<span class="summary-text">{{ steps[currentStep-1].qa.title }}</span>
|
||||
</summary>
|
||||
<div class="code-block-wrapper qa-content">
|
||||
<div
|
||||
v-for="(item, idx) in steps[currentStep-1].qa.content"
|
||||
:key="idx"
|
||||
class="qa-item"
|
||||
>
|
||||
<div class="qa-q">
|
||||
Q: {{ item.q }}
|
||||
</div>
|
||||
<div
|
||||
class="qa-a"
|
||||
v-html="item.a"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下一步按钮 -->
|
||||
<button
|
||||
v-if="currentStep < 3"
|
||||
class="next-btn"
|
||||
@click="nextStep"
|
||||
>
|
||||
下一步 ➔
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="detail-placeholder"
|
||||
>
|
||||
<span class="guide-bounce">📞</span>
|
||||
<span>点击"开始拨号"或步骤圆点,开始拨打电话</span>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
三次握手确保双方都能收发数据,就像打电话时互相确认"能听到吗"。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStep = ref(0)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
simpleTitle: '喂,在家吗?我是快递员!',
|
||||
techTitle: 'SYN',
|
||||
techDesc: '客户端发送 SYN 包 (Seq=x),请求建立连接。',
|
||||
color: '#3b82f6',
|
||||
terms: [
|
||||
{ key: 'SYN', val: '是单词 Synchronize (同步) 的缩写。💡 为什么叫"同步"?因为建立连接的第一步,就是双方要"对表",把暗号(序号)对齐,确保后续对话在同一个频道上。' },
|
||||
{ key: 'Seq=x', val: '意思是:"我的数据计数器,从 x 开始"。💡 为什么要特意告诉对方?这就像是两个人约定"暗号"。我告诉你:"我的暗号是从 x 开始算的"。以后我每发给你一个字,暗号就加 1。如果你不知道我的起始暗号是 x,以后收到 x+100 你就不知道它是第几个字,也没法判断中间有没有丢字。' }
|
||||
],
|
||||
codeImpl: {
|
||||
title: '💻 真实 TCP 报文构建 (伪代码)',
|
||||
code: `// 1. 设置标志位:只置 SYN
|
||||
<span class="kw">tcph->syn</span> = 1;
|
||||
<span class="kw">tcph->ack</span> = 0;
|
||||
|
||||
// 2. 生成随机序号 (Seq)
|
||||
// 操作系统内核会维护一个计数器
|
||||
// 这里的 htonl 是为了处理网络字节序
|
||||
<span class="kw">tcph->seq</span> = htonl(<span class="var">random_x</span>);
|
||||
|
||||
// 3. 发送数据包
|
||||
send_packet(client_socket, tcph);`
|
||||
}
|
||||
},
|
||||
{
|
||||
simpleTitle: '在的!我听到了,请说!',
|
||||
techTitle: 'SYN-ACK',
|
||||
techDesc: '服务器回复 SYN-ACK 包 (Seq=y, Ack=x+1),确认并请求连接。',
|
||||
color: '#10b981',
|
||||
terms: [
|
||||
{ key: 'ACK', val: '是单词 Acknowledgment (确认) 的缩写。💡 就像快递签收单,表示"我收到你的请求了"。' },
|
||||
{ key: 'Ack=x+1', val: '确认号。💡 为什么要 +1?这是一种期待机制。意思是:"x 号那一页我已经收好了,请你下次从 x+1 页开始讲"。' },
|
||||
{ key: 'Seq=y', val: '服务器也生成自己的随机序号 y,方便客户端确认服务器发来的话。' }
|
||||
],
|
||||
codeImpl: {
|
||||
title: '💻 服务器内核响应逻辑 (伪代码)',
|
||||
code: `// 1. 检查收到的是否是 SYN
|
||||
if (<span class="kw">recv_tcph->syn</span> == 1) {
|
||||
// 2. 准备回复包
|
||||
<span class="kw">reply_tcph->syn</span> = 1; // 同步
|
||||
<span class="kw">reply_tcph->ack</span> = 1; // 确认
|
||||
|
||||
// 3. 确认号 = 对方 Seq + 1
|
||||
// 表示期待对方下一次发在这个序号之后的数据
|
||||
<span class="kw">reply_tcph->ack_seq</span> = htonl(ntohl(<span class="var">recv_tcph->seq</span>) + 1);
|
||||
|
||||
// 4. 生成服务器自己的序号
|
||||
<span class="kw">reply_tcph->seq</span> = htonl(<span class="var">random_y</span>);
|
||||
|
||||
send_packet(server_socket, reply_tcph);
|
||||
}`
|
||||
}
|
||||
},
|
||||
{
|
||||
simpleTitle: '好的,那我开始说了!',
|
||||
techTitle: 'ACK',
|
||||
techDesc: '客户端发送 ACK 包 (Ack=y+1),连接建立成功,可以传输数据。',
|
||||
color: '#8b5cf6',
|
||||
terms: [
|
||||
{ key: 'Ack=y+1', val: '客户端确认收到。意思是:"服务器你的 y 号信我也收到了,我们正式开始聊天吧!"' },
|
||||
{ key: '连接建立', val: '双方都确认了对方"能听能说",通道正式打通。' }
|
||||
],
|
||||
codeImpl: {
|
||||
title: '💻 客户端最终确认 (伪代码)',
|
||||
code: `// 1. 检查收到的包
|
||||
if (<span class="kw">recv_tcph->syn</span> == 1 && <span class="kw">recv_tcph->ack</span> == 1) {
|
||||
// 2. 准备 ACK 包
|
||||
<span class="kw">ack_tcph->syn</span> = 0; // 第三次握手不需要 SYN 了
|
||||
<span class="kw">ack_tcph->ack</span> = 1;
|
||||
|
||||
// 3. 确认号 = 服务器 Seq + 1
|
||||
<span class="kw">ack_tcph->ack_seq</span> = htonl(ntohl(<span class="var">recv_tcph->seq</span>) + 1);
|
||||
|
||||
// 4. 序号 = 自己的 Seq + 1
|
||||
<span class="kw">ack_tcph->seq</span> = htonl(<span class="var">my_seq</span> + 1);
|
||||
|
||||
// 5. 连接状态变为 ESTABLISHED
|
||||
<span class="hl">socket->state = TCP_ESTABLISHED;</span>
|
||||
send_packet(client_socket, ack_tcph);
|
||||
}`
|
||||
},
|
||||
qa: {
|
||||
title: '🤔 为什么必须是三次?(核心逻辑)',
|
||||
content: [
|
||||
{
|
||||
q: '为什么一定要三次?(双工确认原理)',
|
||||
a: `这其实是在验证<strong>双方的"听说能力"</strong>是否正常。TCP 是全双工的(双方都能同时发和收),所以必须双方都确认对方能发能收:<br>
|
||||
1️⃣ <strong>第一次 (Client -> Server)</strong>:Server 收到,证明 <strong>Client 能发</strong>,<strong>Server 能收</strong>。<br>
|
||||
2️⃣ <strong>第二次 (Server -> Client)</strong>:Client 收到,证明 <strong>Server 能发</strong>,<strong>Client 能收</strong>。同时 Client 知道 Server 收到了自己的第一次请求。<br>
|
||||
3️⃣ <strong>第三次 (Client -> Server)</strong>:Server 收到,证明 <strong>Client 能收</strong>(因为 Client 回复了 Server 的消息)。<br>
|
||||
<br>
|
||||
<strong>结论:</strong> 只有经过这三次,双方都明确知道"自己"和"对方"的发送、接收功能全是好的。少一次都不行(Server 不知道 Client 能不能收),多一次没必要。`
|
||||
},
|
||||
{
|
||||
q: '为什么这就算"连上"了?',
|
||||
a: `所谓的"连接建立",在计算机里并不是真的拉了一根线。它的本质是:<strong>双方内存里都保存好了对方的"状态信息"</strong>。<br>
|
||||
通过这三次握手,双方主要完成了两件事:<br>
|
||||
1. <strong>确认通道畅通</strong>:就是上面说的双工能力确认。<br>
|
||||
2. <strong>同步初始序号 (ISN)</strong>:双方交换了 Seq (x 和 y)。<br>
|
||||
<br>
|
||||
只要双方都记住了对方的 Seq,并且确认了对方在线,操作系统就会把 Socket 状态标记为 <code style="color:#10b981">ESTABLISHED</code> (已建立),这就叫"连上了"。`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < 3) currentStep.value++
|
||||
}
|
||||
|
||||
const goToStep = (step) => {
|
||||
currentStep.value = step
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
currentStep.value = 0
|
||||
}
|
||||
|
||||
const getBubbleText = (stepIndex) => {
|
||||
// stepIndex 1: Client speaks (Step 1)
|
||||
// stepIndex 2: Server speaks (Step 2)
|
||||
if (stepIndex === 1) return steps[0].simpleTitle
|
||||
if (stepIndex === 2) return steps[1].simpleTitle
|
||||
return ''
|
||||
}
|
||||
|
||||
const getSignalClass = () => {
|
||||
if (currentStep.value === 1) return 'sending' // Left to Right
|
||||
if (currentStep.value === 2) return 'receiving' // Right to Left
|
||||
if (currentStep.value === 3) return 'sending-final' // Left to Right
|
||||
return ''
|
||||
}
|
||||
|
||||
const getSignalIcon = () => {
|
||||
return '🔔'
|
||||
}
|
||||
|
||||
const getCurrentStepColor = () => {
|
||||
return steps[currentStep.value - 1]?.color || '#ccc'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tcp-compact {
|
||||
.tcp-handshake-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.action-btn.outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* 舞台区 */
|
||||
.stage-area {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 140px;
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
padding: 0 30px;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.handshake-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 120px;
|
||||
position: relative;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.avatar-box {
|
||||
.actor-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.avatar-icon { font-size: 32px; }
|
||||
.avatar-label { font-size: 12px; color: var(--vp-c-text-2); margin-top: 4px; }
|
||||
|
||||
/* 气泡 */
|
||||
.bubble {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
background: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: #333;
|
||||
}
|
||||
.bubble.client { left: 50%; transform: translateX(-50%); }
|
||||
.bubble.server { left: 50%; transform: translateX(-50%); }
|
||||
|
||||
.pop-enter-active, .pop-leave-active { transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
|
||||
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateX(-50%) scale(0.8); }
|
||||
|
||||
/* 连接线 */
|
||||
.connection-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
margin: 0 20px;
|
||||
position: relative;
|
||||
.message-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.msg-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.msg-arrow {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.msg-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
text-align: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.status-badge.connected { color: var(--vp-c-brand); font-weight: bold; }
|
||||
|
||||
.signal-packet {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--vp-c-brand);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
top: -11px;
|
||||
}
|
||||
|
||||
.signal-packet.sending {
|
||||
animation: moveRight 1.5s forwards;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.signal-packet.receiving {
|
||||
animation: moveLeft 1.5s forwards;
|
||||
opacity: 1;
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.signal-packet.sending-final {
|
||||
animation: moveRight 1.5s forwards;
|
||||
opacity: 1;
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
@keyframes moveRight {
|
||||
0% { left: 0; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
@keyframes moveLeft {
|
||||
0% { left: 100%; }
|
||||
100% { left: 0; }
|
||||
}
|
||||
|
||||
/* 步骤指示器 */
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.step-dot.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.step-dot.passed {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.dot-line {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
/* 详情面板 */
|
||||
.detail-panel {
|
||||
min-height: 80px; /* 改为最小高度 */
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start; /* 顶部对齐 */
|
||||
/* overflow: hidden; 移除隐藏 */
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start; /* 顶部对齐 */
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
padding-right: 16px;
|
||||
border-right: 2px solid transparent;
|
||||
margin-top: 4px; /* 微调对齐 */
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-divider {
|
||||
width: 1px;
|
||||
align-self: stretch; /* 拉伸高度 */
|
||||
background: var(--vp-c-divider);
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.detail-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px; /* 增加间距 */
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: flex-start; /* 顶部对齐 */
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tag.life { background: #e6f7ff; color: #1890ff; }
|
||||
.tag.tech { background: #f6ffed; color: #52c41a; }
|
||||
|
||||
.text {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.text.highlight {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* 新增:术语解释样式 */
|
||||
.term-glossary {
|
||||
margin-top: 8px;
|
||||
.status-badge.success {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: rgba(0,0,0,0.03);
|
||||
padding: 8px;
|
||||
gap: 0.25rem;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.term-item {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.term-key {
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand-dark);
|
||||
margin-right: 6px;
|
||||
background: rgba(var(--vp-c-brand-rgb), 0.1);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
padding: 6px 16px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
margin-top: 4px;
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
align-self: flex-start; /* 按钮顶部对齐 */
|
||||
}
|
||||
|
||||
.detail-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--vp-c-text-3);
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.guide-bounce {
|
||||
animation: bounce 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
/* 代码实现折叠块 */
|
||||
.code-details {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.code-summary {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
user-select: none;
|
||||
background: rgba(0,0,0,0.02);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.code-summary:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.code-block-wrapper {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
background: #282c34; /* 深色背景适合代码 */
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
font-size: 11px;
|
||||
color: #61afef;
|
||||
margin-bottom: 6px;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 语法高亮类 (深色模式) */
|
||||
:deep(.kw) { color: #c678dd; } /* 紫色 - 关键字/字段 */
|
||||
:deep(.var) { color: #d19a66; } /* 橙色 - 变量 */
|
||||
:deep(.hl) { color: #98c379; font-weight: bold; } /* 绿色 - 高亮行 */
|
||||
|
||||
/* 问答折叠块 */
|
||||
.qa-details {
|
||||
background: rgba(255, 165, 0, 0.05); /* 淡淡的橙色背景 */
|
||||
border-color: rgba(255, 165, 0, 0.2);
|
||||
}
|
||||
|
||||
.qa-summary {
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
.qa-summary:hover {
|
||||
color: #ff7a45;
|
||||
background: rgba(255, 165, 0, 0.1);
|
||||
}
|
||||
|
||||
.qa-content {
|
||||
background: var(--vp-c-bg); /* 恢复浅色/深色背景 */
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.qa-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.qa-item:last-child { margin-bottom: 0; }
|
||||
|
||||
.qa-q {
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-brand-dark);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.qa-a {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,654 +1,57 @@
|
||||
<!--
|
||||
UrlParserDemo.vue
|
||||
URL解析演示 - 网购订单隐喻版
|
||||
|
||||
设计理念:
|
||||
1. 隐喻对齐:严格对应 url-to-browser.md 中的"网购订单"比喻。
|
||||
2. 视觉映射:将枯燥的 URL 字符串映射为一张清晰的"购物清单"。
|
||||
3. 实时交互:输入即解析,所见即所得。
|
||||
-->
|
||||
<template>
|
||||
<div class="url-parser-order">
|
||||
<!-- 顶部:输入区 -->
|
||||
<div class="input-section">
|
||||
<div
|
||||
class="url-input-box"
|
||||
:class="{ 'has-error': error }"
|
||||
>
|
||||
<span class="input-label">URL</span>
|
||||
<input
|
||||
v-model="urlInput"
|
||||
type="text"
|
||||
placeholder="https://www.example.com/path?query=1"
|
||||
class="real-input"
|
||||
@input="parseUrl"
|
||||
>
|
||||
<button
|
||||
v-if="urlInput"
|
||||
class="clear-btn"
|
||||
@click="clear"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<span class="action-label">试一试:</span>
|
||||
<button
|
||||
v-for="ex in examples"
|
||||
:key="ex.name"
|
||||
class="action-chip"
|
||||
:class="{ active: currentExample === ex.name }"
|
||||
@click="useExample(ex)"
|
||||
>
|
||||
{{ ex.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="url-parser-demo">
|
||||
<div class="url-line">
|
||||
<span class="part protocol">https://</span><span class="part host">www.google.com</span><span class="part path">/search</span><span class="part query">?q=hello</span>
|
||||
</div>
|
||||
|
||||
<!-- 核心区域:左右对照布局 -->
|
||||
<template v-if="parsed.protocol">
|
||||
<div class="core-stage">
|
||||
<!-- 左侧:解析结果 (技术视角) -->
|
||||
<div class="tech-view">
|
||||
<div class="view-header">
|
||||
<span class="icon">💻</span>
|
||||
<span class="title">技术解析</span>
|
||||
</div>
|
||||
<div class="code-blocks">
|
||||
<div
|
||||
v-for="(field, key) in formFields"
|
||||
v-show="shouldShowField(key)"
|
||||
:key="key"
|
||||
class="code-block"
|
||||
:class="[key, { active: hovered === key }]"
|
||||
:style="{ '--color': field.color }"
|
||||
@mouseenter="hovered = key"
|
||||
@mouseleave="hovered = null"
|
||||
>
|
||||
<span class="field-name">{{ key }}</span>
|
||||
<span class="field-value">{{ getDisplayValue(key) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:转换箭头 -->
|
||||
<div class="transform-arrow">
|
||||
<span class="arrow-icon">➔</span>
|
||||
<span class="arrow-text">映射为</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:购物单 (生活视角) -->
|
||||
<div class="life-view">
|
||||
<div class="view-header">
|
||||
<span class="icon">🧾</span>
|
||||
<span class="title">购物订单</span>
|
||||
</div>
|
||||
<div class="order-ticket">
|
||||
<div class="ticket-hole" />
|
||||
|
||||
<div
|
||||
v-for="(field, key) in formFields"
|
||||
v-show="shouldShowField(key)"
|
||||
:key="key"
|
||||
class="ticket-row"
|
||||
:class="{ active: hovered === key }"
|
||||
:style="{ '--color': field.color }"
|
||||
@mouseenter="hovered = key"
|
||||
@mouseleave="hovered = null"
|
||||
>
|
||||
<div
|
||||
class="ticket-icon"
|
||||
:style="{ backgroundColor: field.color }"
|
||||
>
|
||||
{{ field.icon }}
|
||||
</div>
|
||||
<div class="ticket-content">
|
||||
<span class="ticket-label">{{ field.analogyLabel }}</span>
|
||||
<span class="ticket-desc">{{ field.analogyDesc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术答疑面板 -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="activeQa"
|
||||
class="qa-panel"
|
||||
>
|
||||
<div class="qa-header">
|
||||
{{ activeQa.title }}
|
||||
</div>
|
||||
<div class="qa-content">
|
||||
<div
|
||||
v-for="(item, idx) in activeQa.content"
|
||||
:key="idx"
|
||||
class="qa-item"
|
||||
>
|
||||
<div class="qa-q">
|
||||
Q: {{ item.q }}
|
||||
</div>
|
||||
<div class="qa-a">
|
||||
A: {{ item.a }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="qa-placeholder"
|
||||
>
|
||||
👆 鼠标悬停在上方色块,查看详细技术解释
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<!-- 空状态引导 -->
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
<div class="empty-icon">
|
||||
🛒
|
||||
</div>
|
||||
<div class="empty-text">
|
||||
<p>输入网址,生成你的"数字购物单"</p>
|
||||
<span class="sub-text">看看浏览器如何理解这一长串字符</span>
|
||||
</div>
|
||||
<div class="labels">
|
||||
<span class="label protocol">协议</span>
|
||||
<span class="label host">域名</span>
|
||||
<span class="label path">路径</span>
|
||||
<span class="label query">参数</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const urlInput = ref('')
|
||||
const parsed = ref({})
|
||||
const hovered = ref(null)
|
||||
const currentExample = ref('')
|
||||
const error = ref(false)
|
||||
|
||||
const examples = [
|
||||
{ name: '标准网购', url: 'https://www.nike.com/shoes/running?size=42&color=red' },
|
||||
{ name: '带端口', url: 'http://localhost:8080/api/status' },
|
||||
{ name: '带锚点', url: 'https://vuejs.org/guide.html#setup' }
|
||||
]
|
||||
|
||||
// 定义字段映射(严格对齐 url-to-browser.md)
|
||||
const formFields = {
|
||||
protocol: {
|
||||
color: '#f43f5e', // Red
|
||||
icon: '🚚',
|
||||
analogyLabel: '物流方式',
|
||||
analogyDesc: '决定怎么送货(HTTP普通/HTTPS加密保密)。',
|
||||
qa: {
|
||||
title: '🤔 为什么要写 http/https?',
|
||||
content: [
|
||||
{
|
||||
q: '这两者有什么区别?',
|
||||
a: 'HTTP 就像寄明信片,邮递员(黑客)能看到内容。HTTPS 就像寄密封的信封,只有收件人能拆开看。'
|
||||
},
|
||||
{
|
||||
q: '为什么现在都是 https?',
|
||||
a: '为了安全!现在的浏览器如果发现不是 HTTPS,会提示"不安全",就像快递公司拒收没封口的信件一样。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
hostname: {
|
||||
color: '#3b82f6', // Blue
|
||||
icon: '🏠',
|
||||
analogyLabel: '店铺名称',
|
||||
analogyDesc: '告诉浏览器去哪家店(服务器)买东西。',
|
||||
qa: {
|
||||
title: '🤔 域名还是 IP?',
|
||||
content: [
|
||||
{
|
||||
q: '浏览器认识域名吗?',
|
||||
a: '其实不认识。浏览器只认识 IP 地址(一串数字)。域名是为了方便人记的。下一步(DNS 查询)就是把这个名字翻译成数字。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
port: {
|
||||
color: '#f59e0b', // Amber
|
||||
icon: '🔢',
|
||||
analogyLabel: '柜台编号',
|
||||
analogyDesc: '店铺很大,指定去几号柜台办理业务。',
|
||||
qa: {
|
||||
title: '🤔 这里的数字是什么意思?',
|
||||
content: [
|
||||
{
|
||||
q: '为什么平时上网看不到它?',
|
||||
a: '因为有默认值!就像去银行默认去"综合柜台"一样。HTTP 默认是 80,HTTPS 默认是 443。只有特殊的才需要写出来。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
pathname: {
|
||||
color: '#10b981', // Emerald
|
||||
icon: '📦',
|
||||
analogyLabel: '货架位置',
|
||||
analogyDesc: '商品在仓库里的具体存放位置。',
|
||||
qa: {
|
||||
title: '🤔 这一长串是干嘛的?',
|
||||
content: [
|
||||
{
|
||||
q: '它对应服务器上的文件吗?',
|
||||
a: '通常是的。/shoes/running 就像告诉仓库管理员:去"鞋子区"的"跑步架"上拿货。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
search: {
|
||||
color: '#8b5cf6', // Violet
|
||||
icon: '📝',
|
||||
analogyLabel: '订单备注',
|
||||
analogyDesc: '给商家的额外要求(如:红色、42码)。',
|
||||
qa: {
|
||||
title: '🤔 问号后面的内容?',
|
||||
content: [
|
||||
{
|
||||
q: '这对网页有什么影响?',
|
||||
a: '就像你点外卖备注"不要香菜"。网页内容会根据这些参数变化,比如只显示红色的鞋子。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
hash: {
|
||||
color: '#ec4899', // Pink
|
||||
icon: '🔖',
|
||||
analogyLabel: '说明书页码',
|
||||
analogyDesc: '拿到商品后,直接翻到说明书的第几页。',
|
||||
qa: {
|
||||
title: '🤔 为什么要用 # 号?',
|
||||
content: [
|
||||
{
|
||||
q: '这部分会发给服务器吗?',
|
||||
a: '不会。这只是给你自己(浏览器)看的。就像你买书回家后翻到第10页,书店老板并不需要知道你看哪一页。'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeField = computed(() => hovered.value || null)
|
||||
|
||||
const activeQa = computed(() => {
|
||||
if (!activeField.value) return null
|
||||
return formFields[activeField.value].qa
|
||||
})
|
||||
|
||||
const shouldShowField = (key) => {
|
||||
const val = parsed.value[key]
|
||||
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
|
||||
return val
|
||||
}
|
||||
|
||||
const useExample = (ex) => {
|
||||
urlInput.value = ex.url
|
||||
currentExample.value = ex.name
|
||||
parseUrl()
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
urlInput.value = ''
|
||||
parsed.value = {}
|
||||
currentExample.value = ''
|
||||
hovered.value = null
|
||||
error.value = false
|
||||
}
|
||||
|
||||
const parseUrl = () => {
|
||||
if (!urlInput.value) {
|
||||
parsed.value = {}
|
||||
return
|
||||
}
|
||||
try {
|
||||
let urlStr = urlInput.value.trim()
|
||||
// 自动补全协议以支持 new URL() 解析
|
||||
if (!urlStr.match(/^https?:\/\//)) {
|
||||
urlStr = (urlStr.startsWith('localhost') ? 'http://' : 'https://') + urlStr
|
||||
}
|
||||
const u = new URL(urlStr)
|
||||
|
||||
parsed.value = {
|
||||
protocol: u.protocol.replace(':', ''),
|
||||
hostname: u.hostname,
|
||||
port: u.port || (u.protocol === 'https:' ? '443 (默认)' : '80 (默认)'),
|
||||
pathname: u.pathname,
|
||||
search: u.search,
|
||||
hash: u.hash
|
||||
}
|
||||
error.value = false
|
||||
} catch (e) {
|
||||
// 解析失败时不更新 parsed,或者显示错误状态
|
||||
// 这里选择静默失败,等待用户输入完整
|
||||
if (urlInput.value.length > 10) {
|
||||
// error.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.url-parser-order {
|
||||
.url-parser-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg);
|
||||
padding: 20px;
|
||||
margin: 16px 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 输入区 */
|
||||
.input-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.url-input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.url-input-box:focus-within {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
.input-label {
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-right: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
.real-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.clear-btn {
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.clear-btn:hover { color: var(--vp-c-text-1); }
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.action-label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.action-chip {
|
||||
padding: 4px 12px;
|
||||
border-radius: 100px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.action-chip:hover {
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.action-chip.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 核心展示区 */
|
||||
.core-stage {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 通用标题 */
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.view-header .icon { font-size: 16px; }
|
||||
.view-header .title { font-weight: bold; font-size: 14px; color: var(--vp-c-text-1); }
|
||||
|
||||
/* 左侧:技术视图 */
|
||||
.tech-view {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.code-blocks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 3px solid var(--color);
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
}
|
||||
.code-block:hover, .code-block.active {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.field-name {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.field-value {
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin: 0.5rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.url-line {
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.3rem;
|
||||
word-break: break-all;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 中间:箭头 */
|
||||
.transform-arrow {
|
||||
.part {
|
||||
padding: 0.05rem 0.15rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.part.protocol { background: #fee2e2; color: #dc2626; }
|
||||
.part.host { background: #dbeafe; color: #2563eb; }
|
||||
.part.path { background: #d1fae5; color: #059669; }
|
||||
.part.query { background: #ede9fe; color: #7c3aed; }
|
||||
|
||||
.labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 12px;
|
||||
width: 40px;
|
||||
}
|
||||
.arrow-icon { font-size: 20px; }
|
||||
|
||||
/* 右侧:生活视图 (票据样式) */
|
||||
.life-view {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
/* 暗黑模式适配票据背景 */
|
||||
:root.dark .life-view {
|
||||
background: #1e1e20;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.order-ticket {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
}
|
||||
.ticket-hole {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.ticket-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ticket-row:hover, .ticket-row.active {
|
||||
background: var(--vp-c-bg-soft);
|
||||
opacity: 1;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.ticket-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ticket-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ticket-label {
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.ticket-desc {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
.label {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
|
||||
.empty-text p { font-size: 16px; font-weight: bold; margin: 0 0 8px 0; color: var(--vp-c-text-2); }
|
||||
.sub-text { font-size: 13px; }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.core-stage {
|
||||
flex-direction: column;
|
||||
}
|
||||
.transform-arrow {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
gap: 8px;
|
||||
}
|
||||
.arrow-icon { transform: rotate(90deg); }
|
||||
}
|
||||
|
||||
/* QA Panel */
|
||||
.qa-panel {
|
||||
margin-top: 16px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.qa-header {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
.qa-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.qa-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.qa-q {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.qa-a {
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.qa-placeholder {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
.label.protocol { background: #fee2e2; color: #dc2626; }
|
||||
.label.host { background: #dbeafe; color: #2563eb; }
|
||||
.label.path { background: #d1fae5; color: #059669; }
|
||||
.label.query { background: #ede9fe; color: #7c3aed; }
|
||||
</style>
|
||||
|
||||
@@ -172,7 +172,7 @@ const url = ref('')
|
||||
const isActive = ref(false)
|
||||
const currentStep = ref(0)
|
||||
|
||||
const quickUrls = ['baidu.com', 'bilibili.com', 'github.com']
|
||||
const quickUrls = ['baidu.com', 'google.com', 'github.com']
|
||||
|
||||
const steps = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user