feat(docs): add interactive demos and complete content for development tools
- Add Vue components for interactive demos (SSH auth, regex, env vars, ports) - Complete markdown content for SSH, regex, environment variables, and ports - Remove placeholder "待实现" sections and replace with detailed guides - Add visual explanations for key concepts like ports and localhost - Include practical examples and troubleshooting tips - Add component for showing evolution from transistors to CPU - Improve documentation structure and navigation - Add security best practices for API keys and environment variables
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('all')
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'web', label: '网页' },
|
||||
{ id: 'data', label: '数据库' },
|
||||
{ id: 'dev', label: '开发常用' },
|
||||
{ id: 'remote', label: '远程/传输' }
|
||||
]
|
||||
|
||||
const ports = [
|
||||
{ port: 80, name: 'HTTP', desc: '网页访问(未加密)', category: 'web', risk: 'low', example: 'http://example.com' },
|
||||
{ port: 443, name: 'HTTPS', desc: '网页访问(加密)', category: 'web', risk: 'low', example: 'https://example.com' },
|
||||
{ port: 22, name: 'SSH', desc: '安全远程登录', category: 'remote', risk: 'medium', example: 'ssh user@server' },
|
||||
{ port: 21, name: 'FTP', desc: '文件传输', category: 'remote', risk: 'high', example: 'ftp://server/file.zip' },
|
||||
{ port: 3306, name: 'MySQL', desc: 'MySQL 数据库', category: 'data', risk: 'high', example: 'mysql -h localhost -P 3306' },
|
||||
{ port: 5432, name: 'PostgreSQL', desc: 'PostgreSQL 数据库', category: 'data', risk: 'high', example: 'psql -h localhost -p 5432' },
|
||||
{ port: 27017, name: 'MongoDB', desc: 'MongoDB 数据库', category: 'data', risk: 'high', example: 'mongosh localhost:27017' },
|
||||
{ port: 6379, name: 'Redis', desc: 'Redis 缓存', category: 'data', risk: 'high', example: 'redis-cli -p 6379' },
|
||||
{ port: 3000, name: 'Node/React', desc: 'Node.js / React 开发服务器', category: 'dev', risk: 'low', example: 'npm start → localhost:3000' },
|
||||
{ port: 5173, name: 'Vite', desc: 'Vite 开发服务器', category: 'dev', risk: 'low', example: 'npm run dev → localhost:5173' },
|
||||
{ port: 8080, name: '通用 HTTP', desc: 'HTTP 备用端口 / 代理', category: 'dev', risk: 'low', example: 'localhost:8080/api' },
|
||||
{ port: 8000, name: 'Django/Python', desc: 'Django / Python HTTP 服务', category: 'dev', risk: 'low', example: 'python manage.py runserver' },
|
||||
{ port: 5000, name: 'Flask', desc: 'Flask 开发服务器', category: 'dev', risk: 'low', example: 'flask run → localhost:5000' },
|
||||
{ port: 4200, name: 'Angular', desc: 'Angular 开发服务器', category: 'dev', risk: 'low', example: 'ng serve → localhost:4200' },
|
||||
{ port: 53, name: 'DNS', desc: '域名解析', category: 'remote', risk: 'medium', example: 'dig @8.8.8.8 example.com' },
|
||||
{ port: 25, name: 'SMTP', desc: '邮件发送', category: 'remote', risk: 'medium', example: '邮件服务器发信端口' },
|
||||
]
|
||||
|
||||
const riskLabels = { low: '安全', medium: '注意', high: '敏感' }
|
||||
const riskColors = { low: '#10b981', medium: '#f59e0b', high: '#ef4444' }
|
||||
|
||||
const filteredPorts = computed(() => {
|
||||
return ports.filter(p => {
|
||||
const matchCategory = selectedCategory.value === 'all' || p.category === selectedCategory.value
|
||||
const matchSearch = !searchQuery.value ||
|
||||
p.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
p.port.toString().includes(searchQuery.value) ||
|
||||
p.desc.includes(searchQuery.value)
|
||||
return matchCategory && matchSearch
|
||||
})
|
||||
})
|
||||
|
||||
const expandedPort = ref(null)
|
||||
|
||||
function toggleExpand(port) {
|
||||
expandedPort.value = expandedPort.value === port ? null : port
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="common-ports-demo">
|
||||
<div class="control-panel">
|
||||
<div class="search-bar">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索端口号或服务名..."
|
||||
class="search-input"
|
||||
>
|
||||
</div>
|
||||
<div class="category-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
:class="['tab-btn', { active: selectedCategory === cat.id }]"
|
||||
@click="selectedCategory = cat.id"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="port-table">
|
||||
<div class="table-header">
|
||||
<span class="col-port">端口</span>
|
||||
<span class="col-name">服务</span>
|
||||
<span class="col-desc">说明</span>
|
||||
<span class="col-risk">暴露风险</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="p in filteredPorts"
|
||||
:key="p.port"
|
||||
:class="['table-row', { expanded: expandedPort === p.port }]"
|
||||
@click="toggleExpand(p.port)"
|
||||
>
|
||||
<div class="row-main">
|
||||
<code class="col-port">{{ p.port }}</code>
|
||||
<span class="col-name">{{ p.name }}</span>
|
||||
<span class="col-desc">{{ p.desc }}</span>
|
||||
<span
|
||||
class="col-risk risk-badge"
|
||||
:style="{ color: riskColors[p.risk], borderColor: riskColors[p.risk] }"
|
||||
>
|
||||
{{ riskLabels[p.risk] }}
|
||||
</span>
|
||||
</div>
|
||||
<transition name="expand">
|
||||
<div v-if="expandedPort === p.port" class="row-detail">
|
||||
<span class="detail-label">使用示例:</span>
|
||||
<code>{{ p.example }}</code>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-if="filteredPorts.length === 0" class="empty-state">
|
||||
没有匹配的端口,试试其他关键词?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="range-explain">
|
||||
<div class="range-item">
|
||||
<div class="range-header well-known">0 – 1023</div>
|
||||
<div class="range-body">
|
||||
<strong>系统端口</strong>
|
||||
<span>预留给标准服务(HTTP、SSH 等),普通用户不能随便占用。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="range-item">
|
||||
<div class="range-header registered">1024 – 49151</div>
|
||||
<div class="range-body">
|
||||
<strong>注册端口</strong>
|
||||
<span>留给常见应用(MySQL 3306、Redis 6379 等),开发中最常遇到的范围。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="range-item">
|
||||
<div class="range-header dynamic">49152 – 65535</div>
|
||||
<div class="range-body">
|
||||
<strong>动态端口</strong>
|
||||
<span>操作系统临时分配的端口,比如你的浏览器发请求时,系统会随机给你一个。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>安全提醒:</strong>数据库端口(3306、5432、27017、6379)绝对不要直接暴露到公网!生产环境应只允许内网访问或通过 SSH 隧道连接。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.common-ports-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.search-icon { font-size: 0.9rem; }
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.port-table {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 100px 1fr 70px;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 100px 1fr 70px;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.col-port {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.col-desc {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row-detail {
|
||||
padding: 0.4rem 0.75rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-top: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.row-detail code {
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.range-explain {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.range-item {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.range-header {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.range-header.well-known { background: #ef4444; }
|
||||
.range-header.registered { background: #f59e0b; }
|
||||
.range-header.dynamic { background: #10b981; }
|
||||
|
||||
.range-body {
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.range-body strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.range-body span {
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-red-1);
|
||||
}
|
||||
|
||||
.expand-enter-active, .expand-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.expand-enter-from, .expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.table-header, .row-main {
|
||||
grid-template-columns: 55px 80px 1fr 55px;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.range-explain {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,470 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStep = ref(0)
|
||||
const isPlaying = ref(false)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '1. 你执行 npm run dev',
|
||||
terminal: '$ npm run dev\n\n> vite\n\n 准备就绪...',
|
||||
desc: '你在终端里敲下启动命令',
|
||||
highlight: 'terminal'
|
||||
},
|
||||
{
|
||||
title: '2. Vite 启动 HTTP 服务器',
|
||||
terminal: '$ npm run dev\n\n> vite\n\n VITE v5.4.0 ready in 200 ms\n\n ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.10:5173/',
|
||||
desc: 'Vite 在本机的 5173 端口启动了一个 HTTP 服务器,等待连接',
|
||||
highlight: 'server'
|
||||
},
|
||||
{
|
||||
title: '3. 你打开浏览器访问',
|
||||
terminal: '$ npm run dev\n\n> vite\n\n VITE v5.4.0 ready in 200 ms\n\n ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.10:5173/',
|
||||
browser: 'http://localhost:5173',
|
||||
desc: '浏览器向 localhost:5173 发起 HTTP 请求',
|
||||
highlight: 'browser'
|
||||
},
|
||||
{
|
||||
title: '4. 服务器返回页面',
|
||||
terminal: '$ npm run dev\n\n> vite\n\n VITE v5.4.0 ready in 200 ms\n\n ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.10:5173/\n\n 10:30:01 [200] /\n 10:30:01 [200] /src/main.js\n 10:30:01 [200] /src/App.vue',
|
||||
browser: 'http://localhost:5173',
|
||||
page: '🎉 你的页面出现了!',
|
||||
desc: 'Vite 处理请求,返回 HTML/JS/CSS,浏览器渲染页面',
|
||||
highlight: 'page'
|
||||
},
|
||||
{
|
||||
title: '5. 热更新(HMR)',
|
||||
terminal: '$ npm run dev\n\n VITE v5.4.0 ready in 200 ms\n\n ➜ Local: http://localhost:5173/\n\n 10:30:01 [200] /\n 10:35:22 [vite] hmr update /src/App.vue',
|
||||
browser: 'http://localhost:5173',
|
||||
page: '🔄 页面自动刷新了!',
|
||||
desc: '你修改代码后,Vite 通过 WebSocket 通知浏览器,页面自动更新',
|
||||
highlight: 'hmr'
|
||||
}
|
||||
]
|
||||
|
||||
async function playAll() {
|
||||
if (isPlaying.value) return
|
||||
isPlaying.value = true
|
||||
currentStep.value = 0
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
currentStep.value = i
|
||||
await new Promise(r => setTimeout(r, 1800))
|
||||
}
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
function goStep(i) {
|
||||
currentStep.value = i
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentStep.value = 0
|
||||
isPlaying.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="devserver-flow-demo">
|
||||
<div class="control-panel">
|
||||
<div class="step-indicators">
|
||||
<div
|
||||
v-for="(s, i) in steps"
|
||||
:key="i"
|
||||
:class="['step-dot', { active: currentStep >= i, current: currentStep === i }]"
|
||||
@click="goStep(i)"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-btns">
|
||||
<button class="action-btn" :disabled="isPlaying" @click="playAll">
|
||||
{{ isPlaying ? '播放中...' : '▶ 自动演示' }}
|
||||
</button>
|
||||
<button class="action-btn ghost" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="step-title">{{ steps[currentStep].title }}</div>
|
||||
|
||||
<div class="flow-layout">
|
||||
<div :class="['panel terminal-panel', { highlight: steps[currentStep].highlight === 'terminal' }]">
|
||||
<div class="panel-header">
|
||||
<span class="dot red" /><span class="dot yellow" /><span class="dot green" />
|
||||
<span class="panel-title">终端</span>
|
||||
</div>
|
||||
<pre class="terminal-content">{{ steps[currentStep].terminal }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="arrow-col">
|
||||
<div :class="['flow-arrow', { active: currentStep >= 1 }]">
|
||||
<span class="arrow-label">监听</span>
|
||||
<span class="arrow-char">↕</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['panel browser-panel', {
|
||||
highlight: steps[currentStep].highlight === 'browser' || steps[currentStep].highlight === 'page' || steps[currentStep].highlight === 'hmr'
|
||||
}]">
|
||||
<div class="panel-header">
|
||||
<span class="dot red" /><span class="dot yellow" /><span class="dot green" />
|
||||
<span class="panel-title">浏览器</span>
|
||||
</div>
|
||||
<div class="browser-content">
|
||||
<div v-if="steps[currentStep].browser" class="browser-url-bar">
|
||||
{{ steps[currentStep].browser }}
|
||||
</div>
|
||||
<div v-else class="browser-empty">等待你打开浏览器...</div>
|
||||
<div v-if="steps[currentStep].page" class="browser-page">
|
||||
{{ steps[currentStep].page }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-desc">
|
||||
💡 {{ steps[currentStep].desc }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="http-explain">
|
||||
<div class="http-title">什么是 HTTP 服务器?</div>
|
||||
<div class="http-analogy">
|
||||
<div class="analogy-item">
|
||||
<span class="analogy-icon">🏪</span>
|
||||
<div class="analogy-text">
|
||||
<strong>想象一个前台窗口</strong>
|
||||
<span>HTTP 服务器就像一个"永远开着的服务窗口"——它一直等在那里,有人来问就回答,没人来就静静等着。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analogy-item">
|
||||
<span class="analogy-icon">📋</span>
|
||||
<div class="analogy-text">
|
||||
<strong>只懂一种"暗号"</strong>
|
||||
<span>这个窗口只听得懂 HTTP 协议的请求格式(比如 <code>GET /index.html</code>),然后把对应的文件内容返回给你。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analogy-item">
|
||||
<span class="analogy-icon">⚙️</span>
|
||||
<div class="analogy-text">
|
||||
<strong>开发服务器 = 加强版窗口</strong>
|
||||
<span>Vite、Webpack 的开发服务器不只是"原样返回文件",它还会即时编译你的代码(Vue → JS、TS → JS、Sass → CSS),然后再返回给浏览器。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>一句话总结:</strong>开发服务器 = 一个运行在 localhost 上的 HTTP 服务器 + 即时代码编译器。它监听某个端口,浏览器来请求,它就把编译好的代码返回。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.devserver-flow-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-indicators {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-3);
|
||||
background: var(--vp-c-bg);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.step-dot.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-dot.current {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.control-btns {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.flow-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.panel.highlight {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 12px rgba(100, 108, 255, 0.2);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.red { background: #ef4444; }
|
||||
.dot.yellow { background: #f59e0b; }
|
||||
.dot.green { background: #10b981; }
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.3rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.terminal-content {
|
||||
padding: 0.75rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0;
|
||||
min-height: 140px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.browser-content {
|
||||
padding: 0.75rem;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.browser-url-bar {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.browser-empty {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.browser-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.arrow-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.flow-arrow.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.arrow-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--vp-c-text-3);
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.arrow-char {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.http-explain {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.http-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.http-analogy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.analogy-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.analogy-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.analogy-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.analogy-text strong {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.analogy-text span {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.analogy-text code {
|
||||
font-size: 0.78rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.flow-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.arrow-col {
|
||||
transform: rotate(90deg);
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.arrow-label {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,452 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const requestUrl = ref('http://localhost:3000/api/hello')
|
||||
const isRequesting = ref(false)
|
||||
const requestStep = ref(0)
|
||||
const responseText = ref('')
|
||||
|
||||
const steps = [
|
||||
{ label: '浏览器', desc: '你在地址栏输入 URL', icon: '🌐' },
|
||||
{ label: 'DNS 解析', desc: 'localhost → 127.0.0.1(不出网)', icon: '📖' },
|
||||
{ label: '网络层', desc: '数据包发往 127.0.0.1(环回接口)', icon: '🔄' },
|
||||
{ label: '本机服务', desc: '端口 3000 上的程序接收请求', icon: '⚙️' },
|
||||
{ label: '返回响应', desc: '{ "message": "Hello!" }', icon: '📨' }
|
||||
]
|
||||
|
||||
const aliases = reactive([
|
||||
{ name: 'localhost', ip: '127.0.0.1', desc: '标准域名别名', active: false },
|
||||
{ name: '127.0.0.1', ip: '127.0.0.1', desc: 'IPv4 环回地址', active: false },
|
||||
{ name: '::1', ip: '::1', desc: 'IPv6 环回地址', active: false },
|
||||
{ name: '0.0.0.0', ip: '0.0.0.0', desc: '监听所有网卡', active: false }
|
||||
])
|
||||
|
||||
const selectedAlias = ref(0)
|
||||
|
||||
async function simulateRequest() {
|
||||
if (isRequesting.value) return
|
||||
isRequesting.value = true
|
||||
requestStep.value = 0
|
||||
responseText.value = ''
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
requestStep.value = i + 1
|
||||
await new Promise(r => setTimeout(r, 700))
|
||||
}
|
||||
|
||||
responseText.value = '{ "message": "Hello from localhost!" }'
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
isRequesting.value = false
|
||||
}
|
||||
|
||||
function selectAlias(index) {
|
||||
selectedAlias.value = index
|
||||
aliases.forEach((a, i) => { a.active = i === index })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="localhost-demo">
|
||||
<div class="control-panel">
|
||||
<div class="url-bar">
|
||||
<span class="url-icon">🔗</span>
|
||||
<input
|
||||
v-model="requestUrl"
|
||||
type="text"
|
||||
class="url-input"
|
||||
readonly
|
||||
>
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="isRequesting"
|
||||
@click="simulateRequest"
|
||||
>
|
||||
{{ isRequesting ? '请求中...' : '发送请求' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="flow-container">
|
||||
<div
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
:class="['flow-step', {
|
||||
active: requestStep > i,
|
||||
current: requestStep === i + 1
|
||||
}]"
|
||||
>
|
||||
<div class="step-icon">{{ step.icon }}</div>
|
||||
<div class="step-info">
|
||||
<span class="step-label">{{ step.label }}</span>
|
||||
<span class="step-desc">{{ step.desc }}</span>
|
||||
</div>
|
||||
<div v-if="i < steps.length - 1" :class="['step-arrow', { active: requestStep > i }]">→</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="responseText" class="response-box">
|
||||
<span class="response-label">响应结果:</span>
|
||||
<code>{{ responseText }}</code>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="loopback-explain">
|
||||
<div class="loopback-diagram">
|
||||
<div class="loopback-node app">
|
||||
<span>你的应用</span>
|
||||
<span class="small">(浏览器)</span>
|
||||
</div>
|
||||
<div class="loopback-arrow">
|
||||
<span class="arrow-text">请求不离开本机</span>
|
||||
<svg width="80" height="60" viewBox="0 0 80 60">
|
||||
<path d="M10 10 Q40 55 70 10" stroke="var(--vp-c-brand)" stroke-width="2" fill="none" marker-end="url(#arrowhead)" />
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="6" markerHeight="4" refX="5" refY="2" orient="auto">
|
||||
<polygon points="0 0, 6 2, 0 4" fill="var(--vp-c-brand)" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="loopback-node server">
|
||||
<span>本地服务</span>
|
||||
<span class="small">(:3000)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alias-section">
|
||||
<div class="alias-title">localhost 的"马甲"们(点击查看说明)</div>
|
||||
<div class="alias-grid">
|
||||
<div
|
||||
v-for="(alias, i) in aliases"
|
||||
:key="i"
|
||||
:class="['alias-card', { active: selectedAlias === i }]"
|
||||
@click="selectAlias(i)"
|
||||
>
|
||||
<code class="alias-name">{{ alias.name }}</code>
|
||||
<span class="alias-ip">→ {{ alias.ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alias-desc">
|
||||
{{ aliases[selectedAlias].desc }}:
|
||||
<template v-if="selectedAlias === 0">
|
||||
这是写在你电脑 <code>/etc/hosts</code> 文件里的映射。浏览器看到 <code>localhost</code> 时,直接解析为 <code>127.0.0.1</code>,不会去问 DNS 服务器。
|
||||
</template>
|
||||
<template v-else-if="selectedAlias === 1">
|
||||
<code>127.0.0.1</code> 是 IPv4 的"环回地址"。发到这个地址的数据包永远不会离开本机,操作系统直接在内部把它"折返"回来。
|
||||
</template>
|
||||
<template v-else-if="selectedAlias === 2">
|
||||
<code>::1</code> 是 IPv6 版本的环回地址,功能和 <code>127.0.0.1</code> 完全一样,只不过是 IPv6 格式。
|
||||
</template>
|
||||
<template v-else>
|
||||
<code>0.0.0.0</code> 不是"某一个地址",而是"所有地址"。当服务监听 <code>0.0.0.0:3000</code> 时,意味着无论从哪个网卡(包括局域网 IP 和 127.0.0.1)都能访问。
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心概念:</strong>localhost 就是"自己找自己"。数据包通过环回接口(loopback interface)在本机内部折返,不经过网线、不经过路由器,速度极快且完全安全。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.localhost-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.url-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.url-icon { font-size: 1rem; }
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.flow-container {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
opacity: 0.4;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-step.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.flow-step.current {
|
||||
opacity: 1;
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 8px rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
.step-icon { font-size: 1.2rem; }
|
||||
|
||||
.step-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.step-arrow {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-divider);
|
||||
transition: color 0.3s;
|
||||
margin: 0 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-arrow.active {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.response-box {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.response-label {
|
||||
font-weight: 600;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.response-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.loopback-explain {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.loopback-diagram {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.loopback-node {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.loopback-node .small {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.loopback-node.app {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.loopback-node.server {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.loopback-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow-text {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alias-section {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.alias-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.alias-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.alias-card {
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.alias-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.alias-card.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: rgba(100, 108, 255, 0.08);
|
||||
}
|
||||
|
||||
.alias-name {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alias-ip {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.alias-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
padding: 0.6rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.alias-desc code {
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.alias-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.flow-container {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.step-arrow {
|
||||
display: none;
|
||||
}
|
||||
.loopback-diagram {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const selectedBuilding = ref('web-server')
|
||||
|
||||
const buildings = {
|
||||
'web-server': {
|
||||
name: 'Web 服务器大楼',
|
||||
ip: '192.168.1.100',
|
||||
doors: [
|
||||
{ port: 80, label: 'HTTP', status: 'open', color: '#10b981', desc: '网页访问入口' },
|
||||
{ port: 443, label: 'HTTPS', status: 'open', color: '#3b82f6', desc: '加密网页入口' },
|
||||
{ port: 22, label: 'SSH', status: 'open', color: '#f59e0b', desc: '远程管理通道' },
|
||||
{ port: 3306, label: 'MySQL', status: 'closed', color: '#ef4444', desc: '数据库(已关闭)' }
|
||||
]
|
||||
},
|
||||
'dev-machine': {
|
||||
name: '你的开发电脑',
|
||||
ip: '127.0.0.1',
|
||||
doors: [
|
||||
{ port: 3000, label: 'React', status: 'open', color: '#61dafb', desc: '前端开发服务' },
|
||||
{ port: 5173, label: 'Vite', status: 'open', color: '#646cff', desc: 'Vite 开发服务' },
|
||||
{ port: 8080, label: 'API', status: 'open', color: '#10b981', desc: '后端 API 服务' },
|
||||
{ port: 5432, label: 'PostgreSQL', status: 'open', color: '#336791', desc: '本地数据库' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentBuilding = computed(() => buildings[selectedBuilding.value])
|
||||
const knockingPort = ref(null)
|
||||
const knockResult = ref('')
|
||||
|
||||
function knockDoor(door) {
|
||||
knockingPort.value = door.port
|
||||
if (door.status === 'open') {
|
||||
knockResult.value = `✅ 端口 ${door.port} 开着!${door.label} 服务正在监听,准备接收你的请求。`
|
||||
} else {
|
||||
knockResult.value = `🚫 端口 ${door.port} 关着!没有程序在监听这个端口,连接被拒绝 (Connection Refused)。`
|
||||
}
|
||||
setTimeout(() => { knockingPort.value = null }, 600)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="port-analogy-demo">
|
||||
<div class="control-panel">
|
||||
<span class="panel-label">选择一栋"大楼":</span>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="(b, key) in buildings"
|
||||
:key="key"
|
||||
:class="['tab-btn', { active: selectedBuilding === key }]"
|
||||
@click="selectedBuilding = key; knockResult = ''"
|
||||
>
|
||||
{{ b.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="building">
|
||||
<div class="building-roof">
|
||||
<span class="building-name">{{ currentBuilding.name }}</span>
|
||||
<span class="building-ip">IP: {{ currentBuilding.ip }}</span>
|
||||
</div>
|
||||
<div class="doors-grid">
|
||||
<div
|
||||
v-for="door in currentBuilding.doors"
|
||||
:key="door.port"
|
||||
:class="['door-card', door.status, { knocking: knockingPort === door.port }]"
|
||||
@click="knockDoor(door)"
|
||||
>
|
||||
<div class="door-number" :style="{ backgroundColor: door.color }">
|
||||
{{ door.port }}
|
||||
</div>
|
||||
<div class="door-info">
|
||||
<span class="door-label">{{ door.label }}</span>
|
||||
<span class="door-desc">{{ door.desc }}</span>
|
||||
</div>
|
||||
<div :class="['door-status', door.status]">
|
||||
{{ door.status === 'open' ? '🟢 监听中' : '🔴 已关闭' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="knockResult" :class="['knock-result', { error: knockResult.startsWith('🚫') }]">
|
||||
{{ knockResult }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心比喻:</strong>IP 地址 = 大楼地址,端口号 = 房间门牌号。一台电脑上可以同时运行多个服务,每个服务"占用"一个端口号,就像同一栋大楼里的不同房间。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.port-analogy-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.building {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.building-roof {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.building-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.building-ip {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.doors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.door-card {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.door-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.door-card.knocking {
|
||||
animation: knock 0.3s ease 2;
|
||||
}
|
||||
|
||||
@keyframes knock {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-3px); }
|
||||
75% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
.door-card.closed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.door-number {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.door-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.door-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.door-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.door-status {
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.knock-result {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.knock-result.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-red-1);
|
||||
border-color: var(--vp-c-red-1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.doors-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const services = ref([
|
||||
{ id: 1, name: 'Vite 前端', port: 5173, status: 'running', color: '#646cff' },
|
||||
])
|
||||
|
||||
const nextServices = [
|
||||
{ name: 'React 项目', defaultPort: 5173, color: '#61dafb' },
|
||||
{ name: 'Express API', defaultPort: 3000, color: '#10b981' },
|
||||
{ name: 'Flask 后端', defaultPort: 5000, color: '#f59e0b' },
|
||||
]
|
||||
|
||||
const nextServiceIndex = ref(0)
|
||||
const conflictMessage = ref('')
|
||||
const resolveMessage = ref('')
|
||||
let idCounter = 2
|
||||
|
||||
const nextService = computed(() => nextServices[nextServiceIndex.value])
|
||||
|
||||
const occupiedPorts = computed(() => services.value.map(s => s.port))
|
||||
|
||||
function tryStart() {
|
||||
conflictMessage.value = ''
|
||||
resolveMessage.value = ''
|
||||
|
||||
const svc = nextService.value
|
||||
if (occupiedPorts.value.includes(svc.defaultPort)) {
|
||||
conflictMessage.value = `❌ 端口 ${svc.defaultPort} 已被「${services.value.find(s => s.port === svc.defaultPort).name}」占用!Error: EADDRINUSE :::${svc.defaultPort}`
|
||||
} else {
|
||||
services.value.push({
|
||||
id: idCounter++,
|
||||
name: svc.name,
|
||||
port: svc.defaultPort,
|
||||
status: 'running',
|
||||
color: svc.color
|
||||
})
|
||||
resolveMessage.value = `✅ ${svc.name} 成功启动在端口 ${svc.defaultPort}`
|
||||
advanceNext()
|
||||
}
|
||||
}
|
||||
|
||||
function autoResolve() {
|
||||
const svc = nextService.value
|
||||
let newPort = svc.defaultPort
|
||||
while (occupiedPorts.value.includes(newPort)) {
|
||||
newPort++
|
||||
}
|
||||
|
||||
services.value.push({
|
||||
id: idCounter++,
|
||||
name: svc.name,
|
||||
port: newPort,
|
||||
status: 'running',
|
||||
color: svc.color
|
||||
})
|
||||
|
||||
if (newPort !== svc.defaultPort) {
|
||||
resolveMessage.value = `✅ 端口 ${svc.defaultPort} 被占用,自动换到端口 ${newPort}!(很多框架会自动帮你做这件事)`
|
||||
} else {
|
||||
resolveMessage.value = `✅ ${svc.name} 成功启动在端口 ${newPort}`
|
||||
}
|
||||
conflictMessage.value = ''
|
||||
advanceNext()
|
||||
}
|
||||
|
||||
function killService(id) {
|
||||
const svc = services.value.find(s => s.id === id)
|
||||
if (svc) {
|
||||
services.value = services.value.filter(s => s.id !== id)
|
||||
resolveMessage.value = `🗑️ 已停止「${svc.name}」,端口 ${svc.port} 已释放`
|
||||
conflictMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function advanceNext() {
|
||||
nextServiceIndex.value = (nextServiceIndex.value + 1) % nextServices.length
|
||||
}
|
||||
|
||||
function reset() {
|
||||
services.value = [
|
||||
{ id: 1, name: 'Vite 前端', port: 5173, status: 'running', color: '#646cff' }
|
||||
]
|
||||
idCounter = 2
|
||||
nextServiceIndex.value = 0
|
||||
conflictMessage.value = ''
|
||||
resolveMessage.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="port-conflict-demo">
|
||||
<div class="control-panel">
|
||||
<div class="control-left">
|
||||
<span class="panel-label">尝试启动:</span>
|
||||
<span class="next-svc" :style="{ color: nextService.color }">{{ nextService.name }}</span>
|
||||
<span class="next-port">(默认端口 {{ nextService.defaultPort }})</span>
|
||||
</div>
|
||||
<div class="control-btns">
|
||||
<button class="action-btn" @click="tryStart">直接启动</button>
|
||||
<button class="action-btn secondary" @click="autoResolve">智能启动</button>
|
||||
<button class="action-btn ghost" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="port-list">
|
||||
<div class="port-list-header">
|
||||
<span>当前运行的服务</span>
|
||||
<span class="port-count">{{ services.length }} 个</span>
|
||||
</div>
|
||||
<transition-group name="list" tag="div" class="port-items">
|
||||
<div
|
||||
v-for="svc in services"
|
||||
:key="svc.id"
|
||||
class="port-item"
|
||||
>
|
||||
<div class="port-dot" :style="{ backgroundColor: svc.color }" />
|
||||
<span class="svc-name">{{ svc.name }}</span>
|
||||
<code class="svc-port">:{{ svc.port }}</code>
|
||||
<span class="svc-status">🟢 运行中</span>
|
||||
<button class="kill-btn" title="停止服务" @click="killService(svc.id)">✕</button>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="conflictMessage" class="msg-box error">
|
||||
<div class="msg-content">{{ conflictMessage }}</div>
|
||||
<div class="msg-hint">
|
||||
<strong>解决办法:</strong>
|
||||
① 停掉占用端口的进程(点击上方 ✕ 按钮);
|
||||
② 改用其他端口(点击"智能启动");
|
||||
③ 命令行排查:<code>lsof -i :{{ nextService.defaultPort }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="resolveMessage && !conflictMessage" class="msg-box success">
|
||||
{{ resolveMessage }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>端口冲突:</strong>一个端口同一时刻只能被一个程序监听。如果你看到 <code>EADDRINUSE</code> 错误,说明这个端口已经被占了。要么杀掉旧进程,要么换个端口。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.port-conflict-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.next-svc {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.next-port {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.control-btns {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.action-btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.port-list {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.port-list-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.port-count {
|
||||
color: var(--vp-c-text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.port-items {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.port-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.port-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.port-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.svc-name {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.svc-port {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.82rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.svc-status {
|
||||
font-size: 0.75rem;
|
||||
margin-left: auto;
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.kill-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.kill-btn:hover {
|
||||
color: var(--vp-c-red-1);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.msg-box {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.msg-box.error {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid var(--vp-c-red-1);
|
||||
color: var(--vp-c-red-1);
|
||||
}
|
||||
|
||||
.msg-box.success {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.msg-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.msg-hint code {
|
||||
font-size: 0.78rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
font-size: 0.82rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
|
||||
.list-enter-from { opacity: 0; transform: translateX(-20px); }
|
||||
.list-leave-to { opacity: 0; transform: translateX(20px); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,348 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const selectedProblem = ref(0)
|
||||
|
||||
const problems = [
|
||||
{
|
||||
symptom: '端口被占用',
|
||||
error: 'Error: listen EADDRINUSE :::3000',
|
||||
icon: '🔴',
|
||||
steps: [
|
||||
{ cmd: 'lsof -i :3000', desc: '查看谁在用这个端口', output: 'COMMAND PID USER FD TYPE SIZE/OFF NODE NAME\nnode 1234 sanbu 22u IPv6 0t0 TCP *:3000 (LISTEN)' },
|
||||
{ cmd: 'kill -9 1234', desc: '强制结束该进程(PID 为 1234)', output: '(进程已终止)' },
|
||||
{ cmd: 'npm run dev', desc: '重新启动你的服务', output: '✅ Server running at http://localhost:3000' }
|
||||
]
|
||||
},
|
||||
{
|
||||
symptom: '拒绝连接',
|
||||
error: 'ERR_CONNECTION_REFUSED (localhost:8080)',
|
||||
icon: '🚫',
|
||||
steps: [
|
||||
{ cmd: 'curl http://localhost:8080', desc: '确认服务是否真的在运行', output: 'curl: (7) Failed to connect to localhost port 8080: Connection refused' },
|
||||
{ cmd: 'lsof -i :8080', desc: '检查是否有程序在监听', output: '(没有输出 = 没有程序在监听)' },
|
||||
{ cmd: 'npm run dev', desc: '启动你的后端服务', output: '✅ API server listening on port 8080' }
|
||||
]
|
||||
},
|
||||
{
|
||||
symptom: '跨域被拦截',
|
||||
error: 'Access-Control-Allow-Origin 错误',
|
||||
icon: '🛡️',
|
||||
steps: [
|
||||
{ cmd: '检查前端请求地址', desc: '确认是否从 localhost:5173 请求 localhost:3000', output: '前端 http://localhost:5173 → 后端 http://localhost:3000/api\n不同端口 = 不同源 = 触发跨域策略!' },
|
||||
{ cmd: '后端添加 CORS 配置', desc: '允许前端域名跨域访问', output: "app.use(cors({ origin: 'http://localhost:5173' }))" },
|
||||
{ cmd: '或者配置前端代理', desc: '在 vite.config.js 中设置 proxy', output: "server: {\n proxy: {\n '/api': 'http://localhost:3000'\n }\n}" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const currentProblem = computed(() => problems[selectedProblem.value])
|
||||
const currentStepIndex = ref(0)
|
||||
const showingOutput = ref(false)
|
||||
|
||||
function selectProblem(i) {
|
||||
selectedProblem.value = i
|
||||
currentStepIndex.value = 0
|
||||
showingOutput.value = false
|
||||
}
|
||||
|
||||
function runStep() {
|
||||
showingOutput.value = true
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (currentStepIndex.value < currentProblem.value.steps.length - 1) {
|
||||
currentStepIndex.value++
|
||||
showingOutput.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSteps() {
|
||||
currentStepIndex.value = 0
|
||||
showingOutput.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="port-troubleshoot-demo">
|
||||
<div class="control-panel">
|
||||
<span class="panel-label">选择一个常见问题:</span>
|
||||
<div class="problem-tabs">
|
||||
<button
|
||||
v-for="(p, i) in problems"
|
||||
:key="i"
|
||||
:class="['tab-btn', { active: selectedProblem === i }]"
|
||||
@click="selectProblem(i)"
|
||||
>
|
||||
{{ p.icon }} {{ p.symptom }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="error-display">
|
||||
<span class="error-icon">{{ currentProblem.icon }}</span>
|
||||
<div class="error-info">
|
||||
<span class="error-symptom">{{ currentProblem.symptom }}</span>
|
||||
<code class="error-message">{{ currentProblem.error }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fix-steps">
|
||||
<div class="fix-header">
|
||||
<span>排查步骤 ({{ currentStepIndex + 1 }}/{{ currentProblem.steps.length }})</span>
|
||||
<button class="reset-btn" @click="resetSteps">重来</button>
|
||||
</div>
|
||||
|
||||
<div class="step-content">
|
||||
<div class="step-cmd">
|
||||
<span class="prompt">$</span>
|
||||
<code>{{ currentProblem.steps[currentStepIndex].cmd }}</code>
|
||||
</div>
|
||||
<div class="step-desc">
|
||||
{{ currentProblem.steps[currentStepIndex].desc }}
|
||||
</div>
|
||||
<button v-if="!showingOutput" class="run-btn" @click="runStep">
|
||||
▶ 执行
|
||||
</button>
|
||||
<transition name="fade">
|
||||
<div v-if="showingOutput" class="step-output">
|
||||
<pre>{{ currentProblem.steps[currentStepIndex].output }}</pre>
|
||||
</div>
|
||||
</transition>
|
||||
<button
|
||||
v-if="showingOutput && currentStepIndex < currentProblem.steps.length - 1"
|
||||
class="next-btn"
|
||||
@click="nextStep"
|
||||
>
|
||||
下一步 →
|
||||
</button>
|
||||
<div
|
||||
v-if="showingOutput && currentStepIndex === currentProblem.steps.length - 1"
|
||||
class="done-badge"
|
||||
>
|
||||
✅ 问题解决!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>排查口诀:</strong>先确认服务有没有启动(lsof / netstat),再确认端口对不对,最后确认是不是跨域问题。90% 的 localhost 问题都逃不出这三步。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.port-troubleshoot-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.problem-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.error-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid var(--vp-c-red-1);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.error-symptom {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-red-1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.fix-steps {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fix-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 3px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.step-cmd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1e1e2e;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: #10b981;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-cmd code {
|
||||
color: #cdd6f4;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.run-btn, .next-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
background: var(--vp-c-green-1);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.step-output {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1e1e2e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.step-output pre {
|
||||
color: #a6adc8;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.done-badge {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-green-1);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user