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:
sanbuphy
2026-02-21 10:04:47 +08:00
parent 399913d3ff
commit 6098908eee
52 changed files with 17782 additions and 2725 deletions
@@ -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>预留给标准服务HTTPSSH 普通用户不能随便占用</span>
</div>
</div>
<div class="range-item">
<div class="range-header registered">1024 49151</div>
<div class="range-body">
<strong>注册端口</strong>
<span>留给常见应用MySQL 3306Redis 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>数据库端口33065432270176379绝对不要直接暴露到公网生产环境应只允许内网访问或通过 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>ViteWebpack 的开发服务器不只是"原样返回文件"它还会即时编译你的代码Vue JSTS JSSass 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>