feat: add AI and Backend evolution history with interactive demos, and refine Frontend evolution demo
This commit is contained in:
@@ -1,102 +1,109 @@
|
||||
<template>
|
||||
<div class="browser-rendering-demo">
|
||||
<div class="control-bar">
|
||||
<div class="step-indicator">Step: {{ currentStep + 1 }} / 4</div>
|
||||
<div class="steps-nav">
|
||||
<button
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
:class="{ active: currentStep === index }"
|
||||
@click="currentStep = index"
|
||||
>
|
||||
{{ step.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<button
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="step-btn"
|
||||
:class="{ active: currentStep === index, completed: currentStep > index }"
|
||||
@click="currentStep = index"
|
||||
>
|
||||
<span class="step-num">{{ index + 1 }}</span>
|
||||
<span class="step-label">{{ step.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<!-- Left: Source Code -->
|
||||
<div class="source-panel">
|
||||
<div class="panel-label">HTML / CSS</div>
|
||||
<div class="code-block">
|
||||
<div class="line"><div id="app"></div>
|
||||
<div class="line indent"><h1>Hello</h1></div>
|
||||
<div class="line indent"><p>World</p></div>
|
||||
<div class="line"></div></div>
|
||||
<div class="line mt-2">h1 { color: red; }</div>
|
||||
<div class="stage-container">
|
||||
<div class="stage-info">
|
||||
<h3>{{ steps[currentStep].title }}</h3>
|
||||
<p>{{ steps[currentStep].desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="visualization-window">
|
||||
<!-- HTML/CSS Source -->
|
||||
<div class="source-view">
|
||||
<div class="window-title">积木说明书 (HTML/CSS)</div>
|
||||
<div class="code-content">
|
||||
<!-- HTML Highlighted always after Step 0 -->
|
||||
<div class="line" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'html' }" @mouseenter="hoveredPart = 'html'" @mouseleave="hoveredPart = null"><!DOCTYPE html></div>
|
||||
<div class="line" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'html' }" @mouseenter="hoveredPart = 'html'" @mouseleave="hoveredPart = null"><html></div>
|
||||
<div class="line indent" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'body' }" @mouseenter="hoveredPart = 'body'" @mouseleave="hoveredPart = null"><body></div>
|
||||
<div class="line indent-2" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'card' }" @mouseenter="hoveredPart = 'card'" @mouseleave="hoveredPart = null"><div class="card"></div>
|
||||
<div class="line indent-3" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'img' }" @mouseenter="hoveredPart = 'img'" @mouseleave="hoveredPart = null"><img class="icon" src="castle.png" /></div>
|
||||
<div class="line indent-3" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'title' }" @mouseenter="hoveredPart = 'title'" @mouseleave="hoveredPart = null"><h2 class="title">乐高城堡</h2></div>
|
||||
<div class="line indent-3" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'btn' }" @mouseenter="hoveredPart = 'btn'" @mouseleave="hoveredPart = null"><button class="btn">购买</button></div>
|
||||
<div class="line indent-2" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'card' }" @mouseenter="hoveredPart = 'card'" @mouseleave="hoveredPart = null"></div></div>
|
||||
<div class="line indent" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'body' }" @mouseenter="hoveredPart = 'body'" @mouseleave="hoveredPart = null"></body></div>
|
||||
<div class="line" :class="{ active: currentStep >= 0, hovered: hoveredPart === 'html' }" @mouseenter="hoveredPart = 'html'" @mouseleave="hoveredPart = null"></html></div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- CSS Highlighted precisely based on step usage -->
|
||||
<!-- Layout properties -->
|
||||
<div class="line" :class="{ active: currentStep === 2, hovered: hoveredPart === 'card' }" @mouseenter="hoveredPart = 'card'" @mouseleave="hoveredPart = null">.card { display: flex; padding: 10px; }</div>
|
||||
<div class="line" :class="{ active: currentStep === 2, hovered: hoveredPart === 'img' }" @mouseenter="hoveredPart = 'img'" @mouseleave="hoveredPart = null">.icon { width: 50px; height: 50px; }</div>
|
||||
<!-- Style properties -->
|
||||
<div class="line" :class="{ active: currentStep === 1 || currentStep === 3, hovered: hoveredPart === 'title' }" @mouseenter="hoveredPart = 'title'" @mouseleave="hoveredPart = null">.title { color: red; }</div>
|
||||
<div class="line" :class="{ active: currentStep === 1 || currentStep === 3, hovered: hoveredPart === 'btn' }" @mouseenter="hoveredPart = 'btn'" @mouseleave="hoveredPart = null">.btn { background: blue; }</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="transform-arrow">→</div>
|
||||
|
||||
<!-- Render Result -->
|
||||
<div class="result-view">
|
||||
<div class="window-title">{{ steps[currentStep].resultTitle }}</div>
|
||||
|
||||
<div class="render-canvas">
|
||||
<!-- Step 1: DOM (Skeleton) -->
|
||||
<transition-group name="block">
|
||||
<div v-if="currentStep >= 0" key="html" class="block-box root" :class="{ hovered: hoveredPart === 'html' }" @mouseenter.stop="hoveredPart = 'html'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">html</span>
|
||||
<div class="block-box body" :class="{ hovered: hoveredPart === 'body' }" @mouseenter.stop="hoveredPart = 'body'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">body</span>
|
||||
|
||||
<!-- Product Card -->
|
||||
<div class="block-box card" :class="{ layout: currentStep >= 2, hovered: hoveredPart === 'card' }" @mouseenter.stop="hoveredPart = 'card'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">div.card</span>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="block-box img" :class="{ layout: currentStep >= 2, hovered: hoveredPart === 'img' }" @mouseenter.stop="hoveredPart = 'img'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">img.icon</span>
|
||||
<span v-if="currentStep >= 3" class="content-img">🏰</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="block-box title" :class="{ styled: currentStep >= 1, layout: currentStep >= 2, hovered: hoveredPart === 'title' }" @mouseenter.stop="hoveredPart = 'title'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">h2.title</span>
|
||||
<span v-if="currentStep >= 3" class="content">乐高城堡</span>
|
||||
</div>
|
||||
|
||||
<!-- Button -->
|
||||
<div class="block-box btn" :class="{ styled: currentStep >= 1, layout: currentStep >= 2, hovered: hoveredPart === 'btn' }" @mouseenter.stop="hoveredPart = 'btn'" @mouseleave="hoveredPart = null">
|
||||
<span class="block-label">button.btn</span>
|
||||
<span v-if="currentStep >= 3" class="content-btn">购买</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<!-- Overlays for different steps -->
|
||||
<div v-if="currentStep === 1" class="overlay-info style-info">
|
||||
<div class="brush">🖌️ 正在上色 (Style)...</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 2" class="overlay-info layout-info">
|
||||
<div class="ruler">📏 正在排版 (Layout)...</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 3" class="overlay-info paint-info">
|
||||
<div class="paint">✨ 绘制完成 (Paint)!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="arrow">→</div>
|
||||
|
||||
<!-- Right: Visualization -->
|
||||
<div class="viz-panel">
|
||||
<div class="panel-label">{{ steps[currentStep].title }}</div>
|
||||
|
||||
<transition name="fade" mode="out-in">
|
||||
<!-- Step 1: DOM Tree -->
|
||||
<div v-if="currentStep === 0" class="tree-viz">
|
||||
<div class="node root">Document</div>
|
||||
<div class="tree-lines">
|
||||
<div class="line-v"></div>
|
||||
</div>
|
||||
<div class="node element">html</div>
|
||||
<div class="tree-lines">
|
||||
<div class="line-v"></div>
|
||||
</div>
|
||||
<div class="node element">body</div>
|
||||
<div class="tree-children">
|
||||
<div class="node element active">div#app</div>
|
||||
<div class="tree-children row">
|
||||
<div class="node element">h1</div>
|
||||
<div class="node element">p</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Render Tree -->
|
||||
<div v-else-if="currentStep === 1" class="tree-viz render-tree">
|
||||
<div class="node render-obj">RenderBlock (div)</div>
|
||||
<div class="tree-children row">
|
||||
<div class="node render-obj red">
|
||||
RenderText (h1) <br /><small>color: red</small>
|
||||
</div>
|
||||
<div class="node render-obj">RenderText (p)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Layout -->
|
||||
<div v-else-if="currentStep === 2" class="layout-viz">
|
||||
<div class="layout-box root">
|
||||
<span class="dims">100% x 100%</span>
|
||||
<div class="layout-box container">
|
||||
<span class="dims">div: 100% x auto</span>
|
||||
<div class="layout-box item h1">h1: 100% x 32px (x:0, y:0)</div>
|
||||
<div class="layout-box item p">p: 100% x 16px (x:0, y:32)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Paint -->
|
||||
<div v-else-if="currentStep === 3" class="paint-viz">
|
||||
<div class="browser-window">
|
||||
<div class="painted-content">
|
||||
<h1 style="color: red; margin: 0">Hello</h1>
|
||||
<p style="margin: 0">World</p>
|
||||
</div>
|
||||
<div class="paint-layers">
|
||||
<div class="layer-item">Layer 1: Background</div>
|
||||
<div class="layer-item">Layer 2: Text</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-footer">
|
||||
<p>{{ steps[currentStep].desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -104,204 +111,340 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStep = ref(0)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: '1. DOM',
|
||||
title: 'DOM Tree Construction',
|
||||
desc: '浏览器解析 HTML 标记,构建 DOM (文档对象模型) 树。每个标签成为一个节点。'
|
||||
label: 'DOM (搭骨架)',
|
||||
title: '1. 搭建骨架 (DOM)',
|
||||
desc: '浏览器工头 (Parser) 解析 HTML 代码,构建出完整的文档树结构。注意:即使代码中省略了 html/body,浏览器也会自动补全。',
|
||||
resultTitle: 'DOM 树结构'
|
||||
},
|
||||
{
|
||||
label: '2. Render Tree',
|
||||
title: 'Render Tree Construction',
|
||||
desc: '结合 DOM 和 CSSOM,生成渲染树。只有可见元素会被包含(display: none 的元素会被排除)。'
|
||||
label: 'Style (上色)',
|
||||
title: '2. 计算样式 (Recalculate Style)',
|
||||
desc: '装修工 (CSS Parser) 匹配 CSS 规则。比如发现 .title 需要红色,.btn 需要蓝色背景。此时只关心"长什么样",不关心"在哪"。',
|
||||
resultTitle: '附带样式的节点'
|
||||
},
|
||||
{
|
||||
label: '3. Layout',
|
||||
title: 'Layout (Reflow)',
|
||||
desc: '计算每个节点在屏幕上的确切位置和大小。这一步也叫"回流"。'
|
||||
label: 'Layout (排版)',
|
||||
title: '3. 布局排版 (Layout/Reflow)',
|
||||
desc: '测量员 (Layout) 根据 display:flex 和 padding 等属性,计算每个盒子的精确位置和大小。图片在左,文字在右。',
|
||||
resultTitle: '几何布局'
|
||||
},
|
||||
{
|
||||
label: '4. Paint',
|
||||
title: 'Painting & Composite',
|
||||
desc: '将各个节点绘制到屏幕像素。现代浏览器会将不同部分绘制到不同图层,最后合成。'
|
||||
label: 'Paint (绘制)',
|
||||
title: '4. 像素绘制 (Paint)',
|
||||
desc: '画家 (Paint) 按照计算好的位置和样式,真正把像素点画在屏幕上。最终你看到了一个完整的商品卡片。',
|
||||
resultTitle: '最终画面'
|
||||
}
|
||||
]
|
||||
|
||||
const currentStep = ref(0)
|
||||
const hoveredPart = ref(null)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.browser-rendering-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
.stepper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.steps-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.steps-nav button {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.steps-nav button.active {
|
||||
.step-btn:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.step-btn.active {
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
background: var(--vp-c-bg-alt);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.step-btn.active .step-num,
|
||||
.step-btn.completed .step-num {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
.stage-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.source-panel,
|
||||
.viz-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--vp-c-text-2);
|
||||
.stage-info {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
.stage-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.line.indent {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.line.mt-2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.viz-panel {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Visualization Styles */
|
||||
.node {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
.node.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.node.root {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.tree-viz {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tree-children.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.render-obj.red {
|
||||
border-color: red;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.layout-box {
|
||||
border: 1px dashed var(--vp-c-text-3);
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layout-box .dims {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
font-size: 0.6rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.layout-box.container {
|
||||
border-color: var(--vp-c-brand);
|
||||
margin: 0.5rem;
|
||||
}
|
||||
.layout-box.item {
|
||||
border-style: solid;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.painted-content {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.paint-layers {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.info-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
.stage-info p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
.visualization-window {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
.source-view, .result-view {
|
||||
flex: 1;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: monospace;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.line {
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.5s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line.active {
|
||||
opacity: 1;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
font-weight: bold;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.line.indent { padding-left: 1rem; }
|
||||
.line.indent-2 { padding-left: 2rem; }
|
||||
.line.indent-3 { padding-left: 3rem; }
|
||||
.line.mt-2 { margin-top: 1rem; }
|
||||
|
||||
.transform-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.result-view {
|
||||
background: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.render-canvas {
|
||||
padding: 2rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Blocks Animation */
|
||||
.block-box {
|
||||
border: 1px dashed #9ca3af;
|
||||
background: #f3f4f6;
|
||||
padding: 0.5rem;
|
||||
margin: 0.2rem;
|
||||
border-radius: 2px;
|
||||
transition: all 0.8s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
min-width: 50px;
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block-box.root { width: 95%; border-color: #e5e7eb; background: #fff; }
|
||||
.block-box.body { width: 90%; border-color: #d1d5db; background: #f9fafb; }
|
||||
.block-box.card { width: 80%; border-color: #9ca3af; background: #e5e7eb; }
|
||||
|
||||
.block-label {
|
||||
font-size: 0.6rem;
|
||||
color: #9ca3af;
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 4px;
|
||||
background: white;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* Step 2: Style */
|
||||
.block-box.title.styled {
|
||||
color: red; /* Text color applied but not painted yet */
|
||||
border: 1px solid red; /* Visual cue for style applied */
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.block-box.btn.styled {
|
||||
background: blue;
|
||||
color: white;
|
||||
border: 1px solid blue;
|
||||
}
|
||||
|
||||
/* Step 3: Layout */
|
||||
.block-box.card.layout {
|
||||
display: flex;
|
||||
flex-direction: row; /* Horizontal layout */
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.block-box.img.layout {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: #eee;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.block-box.title.layout {
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.block-box.btn.layout {
|
||||
margin-left: auto; /* Push to right */
|
||||
padding: 5px 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Content visibility for Paint step */
|
||||
.content, .content-img, .content-btn {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
animation: fadeIn 0.5s;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.content-img { font-size: 2rem; }
|
||||
.content-btn { font-size: 0.8rem; }
|
||||
|
||||
/* Overlay Info */
|
||||
.overlay-info {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
animation: bounceIn 0.5s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brush, .ruler, .paint {
|
||||
display: inline-block;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Vue Transitions */
|
||||
.block-enter-active,
|
||||
.block-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.block-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% { transform: scale(0.8); opacity: 0; }
|
||||
60% { transform: scale(1.1); opacity: 1; }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Hover Interactions */
|
||||
.line.hovered {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
opacity: 1 !important;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.block-box.hovered {
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
z-index: 10;
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
cursor: crosshair;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="component-reusability-demo">
|
||||
<div class="toolbox">
|
||||
<div class="tool-title">Component Library</div>
|
||||
<button class="spawn-btn" @click="spawn('counter')">➕ New Counter</button>
|
||||
<button class="spawn-btn" @click="spawn('card')">➕ New Card</button>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<div class="workspace-label">App Workspace</div>
|
||||
<div class="instances-container">
|
||||
<transition-group name="list">
|
||||
<div
|
||||
v-for="item in instances"
|
||||
:key="item.id"
|
||||
class="instance-wrapper"
|
||||
>
|
||||
<!-- Counter Component -->
|
||||
<div v-if="item.type === 'counter'" class="comp-instance counter">
|
||||
<div class="comp-header">
|
||||
<span>Counter #{{ item.id }}</span>
|
||||
<button class="close-btn" @click="remove(item.id)">×</button>
|
||||
</div>
|
||||
<div class="comp-body">
|
||||
<span class="count-val">{{ item.data.count }}</span>
|
||||
<button class="mini-btn" @click="item.data.count++">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Component -->
|
||||
<div v-if="item.type === 'card'" class="comp-instance card">
|
||||
<div class="comp-header">
|
||||
<span>Card #{{ item.id }}</span>
|
||||
<button class="close-btn" @click="remove(item.id)">×</button>
|
||||
</div>
|
||||
<div class="comp-body">
|
||||
<div class="skeleton-img"></div>
|
||||
<div class="skeleton-text"></div>
|
||||
<button
|
||||
class="like-btn"
|
||||
:class="{ liked: item.data.liked }"
|
||||
@click="item.data.liked = !item.data.liked"
|
||||
>
|
||||
{{ item.data.liked ? '❤️ Liked' : '♡ Like' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div v-if="instances.length === 0" class="empty-hint">
|
||||
Click buttons above to add components.
|
||||
<br>
|
||||
Notice how each one works independently!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const instances = ref([])
|
||||
let nextId = 1
|
||||
|
||||
const spawn = (type) => {
|
||||
if (type === 'counter') {
|
||||
instances.value.push({
|
||||
id: nextId++,
|
||||
type: 'counter',
|
||||
data: { count: 0 }
|
||||
})
|
||||
} else if (type === 'card') {
|
||||
instances.value.push({
|
||||
id: nextId++,
|
||||
type: 'card',
|
||||
data: { liked: false }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (id) => {
|
||||
instances.value = instances.value.filter(i => i.id !== id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.component-reusability-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbox {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.spawn-btn {
|
||||
background: white;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.spawn-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 1.5rem;
|
||||
min-height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.instances-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 3rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.instance-wrapper {
|
||||
transition: all 0.4s;
|
||||
}
|
||||
|
||||
.comp-instance {
|
||||
background: white;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
width: 140px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comp-header {
|
||||
background: #f1f5f9;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.close-btn:hover { color: #ef4444; }
|
||||
|
||||
.comp-body {
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Counter Style */
|
||||
.counter .count-val {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.mini-btn {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-btn:hover { background: #e2e8f0; }
|
||||
|
||||
/* Card Style */
|
||||
.skeleton-img {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.skeleton-text {
|
||||
width: 80%;
|
||||
height: 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.like-btn {
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.like-btn.liked {
|
||||
border-color: #fecaca;
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="css-props-ref">
|
||||
<div class="intro">
|
||||
CSS 属性就像装修队的“施工指令”。常用的其实只有几十个,这里有一份“装修菜单”供你参考:
|
||||
</div>
|
||||
|
||||
<div class="categories">
|
||||
<div
|
||||
v-for="(cat, index) in categories"
|
||||
:key="index"
|
||||
class="category"
|
||||
>
|
||||
<div class="cat-title">{{ cat.title }}</div>
|
||||
<div class="props-grid">
|
||||
<div
|
||||
v-for="prop in cat.props"
|
||||
:key="prop.name"
|
||||
class="prop-item"
|
||||
@click="activeProp = prop"
|
||||
:class="{ active: activeProp && activeProp.name === prop.name }"
|
||||
>
|
||||
<div class="prop-name">{{ prop.name }}</div>
|
||||
<div class="prop-desc">{{ prop.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeProp" class="prop-detail">
|
||||
<div class="detail-header">
|
||||
<span class="detail-name">{{ activeProp.name }}</span>
|
||||
<span class="detail-cat-badge">{{ activeProp.categoryLabel }}</span>
|
||||
</div>
|
||||
<div class="detail-desc">{{ activeProp.fullDesc }}</div>
|
||||
<div class="detail-code">
|
||||
<div class="code-label">示例代码:</div>
|
||||
<pre><code>{{ activeProp.example }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="prop-detail empty">
|
||||
点击上面的属性看看它能做什么 👆
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeProp = ref(null)
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: '📝 文字与排版',
|
||||
props: [
|
||||
{ name: 'color', desc: '文字颜色', categoryLabel: '文字', fullDesc: '改变文字的颜色。可以使用英文单词(red)、十六进制(#ff0000)或RGB值。', example: 'color: #333333;' },
|
||||
{ name: 'font-size', desc: '字号大小', categoryLabel: '文字', fullDesc: '设置文字的大小。常用单位是 px (像素) 或 rem。', example: 'font-size: 16px;' },
|
||||
{ name: 'font-weight', desc: '字体粗细', categoryLabel: '文字', fullDesc: '设置文字的粗细。bold 是加粗,normal 是正常。', example: 'font-weight: bold;' },
|
||||
{ name: 'text-align', desc: '对齐方式', categoryLabel: '排版', fullDesc: '设置文字水平对齐方式:左对齐(left)、居中(center)、右对齐(right)。', example: 'text-align: center;' },
|
||||
{ name: 'line-height', desc: '行高', categoryLabel: '排版', fullDesc: '设置行间距。通常设为 1.5 左右让阅读更舒服。', example: 'line-height: 1.5;' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '📦 盒子与大小',
|
||||
props: [
|
||||
{ name: 'width / height', desc: '宽 / 高', categoryLabel: '尺寸', fullDesc: '设置元素的宽度和高度。', example: 'width: 100px;\nheight: 50px;' },
|
||||
{ name: 'padding', desc: '内边距', categoryLabel: '间距', fullDesc: '盒子内部的空间(内容距离边框的距离)。像填充泡沫一样撑大盒子。', example: 'padding: 20px;' },
|
||||
{ name: 'margin', desc: '外边距', categoryLabel: '间距', fullDesc: '盒子外部的空间(盒子与其他元素之间的距离)。', example: 'margin: 20px;' },
|
||||
{ name: 'background', desc: '背景', categoryLabel: '外观', fullDesc: '设置背景颜色或背景图片。', example: 'background: #f0f0f0;' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '🎨 边框与装饰',
|
||||
props: [
|
||||
{ name: 'border', desc: '边框', categoryLabel: '边框', fullDesc: '设置边框的粗细、样式和颜色。', example: 'border: 1px solid #ccc;' },
|
||||
{ name: 'border-radius', desc: '圆角', categoryLabel: '边框', fullDesc: '让盒子的角变圆润。现在的按钮通常都有点圆角。', example: 'border-radius: 8px;' },
|
||||
{ name: 'box-shadow', desc: '阴影', categoryLabel: '装饰', fullDesc: '给盒子添加阴影效果,增加立体感和层次感。', example: 'box-shadow: 0 4px 6px rgba(0,0,0,0.1);' },
|
||||
{ name: 'opacity', desc: '透明度', categoryLabel: '装饰', fullDesc: '设置元素的透明度,0 是全透明(看不见但还在),1 是不透明。', example: 'opacity: 0.8;' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '📐 布局与定位',
|
||||
props: [
|
||||
{ name: 'display', desc: '显示模式', categoryLabel: '布局', fullDesc: '决定盒子怎么摆。block(独占一行), flex(弹性布局), none(隐藏)。', example: 'display: flex;' },
|
||||
{ name: 'position', desc: '定位方式', categoryLabel: '定位', fullDesc: '决定盒子怎么定位。relative(相对), absolute(绝对), fixed(固定在屏幕)。', example: 'position: absolute;\ntop: 0;\nleft: 0;' },
|
||||
{ name: 'z-index', desc: '层级', categoryLabel: '定位', fullDesc: '决定谁叠在谁上面。数字越大越靠上。', example: 'z-index: 100;' },
|
||||
{ name: 'cursor', desc: '鼠标手势', categoryLabel: '交互', fullDesc: '鼠标移上去变成什么样。pointer(小手), text(输入光标)。', example: 'cursor: pointer;' },
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.css-props-ref {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cat-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.props-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prop-item {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.prop-item:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.prop-item.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.prop-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.prop-desc {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.prop-detail {
|
||||
margin-top: 20px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.prop-detail.empty {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 13px;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.detail-cat-badge {
|
||||
font-size: 11px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-code {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-label {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<!--
|
||||
CssLayoutDemo.vue
|
||||
布局演示:Flexbox 核心概念交互
|
||||
-->
|
||||
<template>
|
||||
<div class="layout-demo">
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>排列方向 (flex-direction)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="val in ['row', 'column']"
|
||||
:key="val"
|
||||
:class="{ active: direction === val }"
|
||||
@click="direction = val"
|
||||
>
|
||||
{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>主轴对齐 (justify-content)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="val in ['flex-start', 'center', 'space-between', 'space-around']"
|
||||
:key="val"
|
||||
:class="{ active: justify === val }"
|
||||
@click="justify = val"
|
||||
>
|
||||
{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>交叉轴对齐 (align-items)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="val in ['stretch', 'center', 'flex-start', 'flex-end']"
|
||||
:key="val"
|
||||
:class="{ active: align === val }"
|
||||
@click="align = val"
|
||||
>
|
||||
{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>换行 (flex-wrap)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="val in ['nowrap', 'wrap']"
|
||||
:key="val"
|
||||
:class="{ active: wrap === val }"
|
||||
@click="wrap = val"
|
||||
>
|
||||
{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-area">
|
||||
<div class="container" :style="containerStyle">
|
||||
<div
|
||||
v-for="n in itemCount"
|
||||
:key="n"
|
||||
class="item"
|
||||
:style="[itemStyle, getItemColor(n)]"
|
||||
>
|
||||
{{ n }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-display">
|
||||
<div class="code-header">👆 点击代码行可以暂时禁用该属性</div>
|
||||
<pre>.container {
|
||||
display: flex;
|
||||
<div
|
||||
class="code-line"
|
||||
:class="{ disabled: !activeProps.direction }"
|
||||
@click="toggleProp('direction')"
|
||||
>flex-direction: <span class="val">{{ direction }}</span>;</div>
|
||||
<div
|
||||
class="code-line"
|
||||
:class="{ disabled: !activeProps.justify }"
|
||||
@click="toggleProp('justify')"
|
||||
>justify-content: <span class="val">{{ justify }}</span>;</div>
|
||||
<div
|
||||
class="code-line"
|
||||
:class="{ disabled: !activeProps.align }"
|
||||
@click="toggleProp('align')"
|
||||
>align-items: <span class="val">{{ align }}</span>;</div>
|
||||
<div
|
||||
class="code-line"
|
||||
:class="{ disabled: !activeProps.wrap }"
|
||||
@click="toggleProp('wrap')"
|
||||
>flex-wrap: <span class="val">{{ wrap }}</span>;</div>
|
||||
/* ...其他样式 */
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
|
||||
const direction = ref('row')
|
||||
const justify = ref('center')
|
||||
const align = ref('center')
|
||||
const wrap = ref('nowrap')
|
||||
|
||||
const activeProps = reactive({
|
||||
direction: true,
|
||||
justify: true,
|
||||
align: true,
|
||||
wrap: true
|
||||
})
|
||||
|
||||
const toggleProp = (prop) => {
|
||||
activeProps[prop] = !activeProps[prop]
|
||||
}
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
const style = { display: 'flex' }
|
||||
if (activeProps.direction) style.flexDirection = direction.value
|
||||
if (activeProps.justify) style.justifyContent = justify.value
|
||||
if (activeProps.align) style.alignItems = align.value
|
||||
if (activeProps.wrap) style.flexWrap = wrap.value
|
||||
return style
|
||||
})
|
||||
|
||||
const itemStyle = computed(() => {
|
||||
const style = {}
|
||||
// Default fixed size
|
||||
style.width = '60px'
|
||||
style.height = '60px'
|
||||
|
||||
// Adjust for stretch - use effective align/direction values
|
||||
const effectiveAlign = activeProps.align ? align.value : 'stretch'
|
||||
const effectiveDirection = activeProps.direction ? direction.value : 'row'
|
||||
|
||||
if (effectiveAlign === 'stretch') {
|
||||
if (effectiveDirection === 'row') {
|
||||
style.height = 'auto'
|
||||
} else {
|
||||
style.width = 'auto'
|
||||
}
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
const itemCount = computed(() => (wrap.value === 'wrap' ? 12 : 5))
|
||||
|
||||
const colors = [
|
||||
'#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981',
|
||||
'#6366f1', '#14b8a6', '#f97316', '#ef4444', '#84cc16',
|
||||
'#06b6d4', '#d946ef'
|
||||
]
|
||||
|
||||
const getItemColor = (n) => {
|
||||
return { background: colors[(n - 1) % colors.length] }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-group button {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-group button:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.btn-group button.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
height: 200px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
background-image: radial-gradient(var(--vp-c-divider) 1px, transparent 1px);
|
||||
background-size: 10px 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
/* Dimensions handled by inline style */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
background: #1e293b;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.code-line:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.code-line.disabled {
|
||||
opacity: 0.4;
|
||||
text-decoration: line-through;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.code-line.disabled .val {
|
||||
color: #94a3b8;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
pre { margin: 0; }
|
||||
.val { color: #f472b6; font-weight: bold; }
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="css-playground">
|
||||
<div class="demo-box">
|
||||
<div
|
||||
class="target-element"
|
||||
:style="{
|
||||
backgroundColor: bgColor,
|
||||
color: textColor,
|
||||
fontSize: fontSize + 'px',
|
||||
padding: padding + 'px',
|
||||
borderRadius: borderRadius + 'px',
|
||||
border: `${borderWidth}px solid ${borderColor}`
|
||||
}"
|
||||
>
|
||||
我是演示元素
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>背景颜色 (background-color)</label>
|
||||
<input type="color" v-model="bgColor" />
|
||||
<span class="value">{{ bgColor }}</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>文字颜色 (color)</label>
|
||||
<input type="color" v-model="textColor" />
|
||||
<span class="value">{{ textColor }}</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>字体大小 (font-size)</label>
|
||||
<input type="range" v-model="fontSize" min="12" max="48" />
|
||||
<span class="value">{{ fontSize }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>内边距 (padding)</label>
|
||||
<input type="range" v-model="padding" min="0" max="50" />
|
||||
<span class="value">{{ padding }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>圆角 (border-radius)</label>
|
||||
<input type="range" v-model="borderRadius" min="0" max="50" />
|
||||
<span class="value">{{ borderRadius }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>边框宽度 (border-width)</label>
|
||||
<input type="range" v-model="borderWidth" min="0" max="10" />
|
||||
<span class="value">{{ borderWidth }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>边框颜色 (border-color)</label>
|
||||
<input type="color" v-model="borderColor" />
|
||||
<span class="value">{{ borderColor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-preview">
|
||||
<div class="code-title">生成的 CSS 代码:</div>
|
||||
<pre><code>.element {
|
||||
background-color: <span class="highlight">{{ bgColor }}</span>;
|
||||
color: <span class="highlight">{{ textColor }}</span>;
|
||||
font-size: <span class="highlight">{{ fontSize }}px</span>;
|
||||
padding: <span class="highlight">{{ padding }}px</span>;
|
||||
border-radius: <span class="highlight">{{ borderRadius }}px</span>;
|
||||
border: <span class="highlight">{{ borderWidth }}px</span> solid <span class="highlight">{{ borderColor }}</span>;
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const bgColor = ref('#3b82f6')
|
||||
const textColor = ref('#ffffff')
|
||||
const fontSize = ref(16)
|
||||
const padding = ref(20)
|
||||
const borderRadius = ref(8)
|
||||
const borderWidth = ref(0)
|
||||
const borderColor = ref('#000000')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.css-playground {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.demo-box {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.target-element {
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
/* Ensure it doesn't overflow easily */
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-1);
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
color: #d4d4d4;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
color: #808080;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #9cdcfe;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="selectors-demo">
|
||||
<div class="hint">👇 鼠标悬停在左侧 CSS 代码上,看看右侧 HTML 谁会被选中</div>
|
||||
|
||||
<div class="comparison">
|
||||
<!-- Left: CSS Rules -->
|
||||
<div class="column css-col">
|
||||
<div class="col-title">CSS (样式表)</div>
|
||||
<div class="rules-list">
|
||||
<div
|
||||
class="rule-item"
|
||||
:class="{ active: activeType === 'tag' }"
|
||||
@mouseenter="activeType = 'tag'"
|
||||
@mouseleave="activeType = null"
|
||||
>
|
||||
<div class="selector">p</div>
|
||||
<div class="block">{ color: #333; }</div>
|
||||
<div class="explanation">
|
||||
<span class="badge tag">标签选择器</span>
|
||||
直接写标签名,选中所有 <code><p></code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rule-item"
|
||||
:class="{ active: activeType === 'class' }"
|
||||
@mouseenter="activeType = 'class'"
|
||||
@mouseleave="activeType = null"
|
||||
>
|
||||
<div class="selector">.card</div>
|
||||
<div class="block">{ background: white; }</div>
|
||||
<div class="explanation">
|
||||
<span class="badge class">类选择器</span>
|
||||
以 <code>.</code> 开头,选中所有 <code>class="card"</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rule-item"
|
||||
:class="{ active: activeType === 'id' }"
|
||||
@mouseenter="activeType = 'id'"
|
||||
@mouseleave="activeType = null"
|
||||
>
|
||||
<div class="selector">#submit-btn</div>
|
||||
<div class="block">{ font-weight: bold; }</div>
|
||||
<div class="explanation">
|
||||
<span class="badge id">ID 选择器</span>
|
||||
以 <code>#</code> 开头,选中唯一 <code>id="submit-btn"</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Connector -->
|
||||
<div class="connector">
|
||||
<div class="line-path" :class="activeType"></div>
|
||||
<div class="icon">🔗</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: HTML Structure -->
|
||||
<div class="column html-col">
|
||||
<div class="col-title">HTML (结构)</div>
|
||||
<div class="code-view">
|
||||
<div
|
||||
class="html-line"
|
||||
:class="{ highlight: activeType === 'tag' }"
|
||||
>
|
||||
<p>我是普通段落</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="html-line"
|
||||
:class="{ highlight: activeType === 'class' }"
|
||||
>
|
||||
<div <span class="attr">class="card"</span>>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="html-line indent"
|
||||
:class="{ highlight: activeType === 'tag' || activeType === 'class' }"
|
||||
>
|
||||
<p>我是卡片里的段落</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="html-line"
|
||||
:class="{ highlight: activeType === 'class' }"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="html-line"
|
||||
:class="{ highlight: activeType === 'id' }"
|
||||
>
|
||||
<button <span class="attr">id="submit-btn"</span>>提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeType = ref(null)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selectors-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 16px;
|
||||
font-family: var(--vp-font-family-base);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* CSS Column */
|
||||
.rule-item {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rule-item:hover, .rule-item.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.selector {
|
||||
color: #d73a49; /* Red-ish for selector */
|
||||
font-weight: bold;
|
||||
}
|
||||
.rule-item:nth-child(2) .selector { color: #6f42c1; } /* Purple for class */
|
||||
.rule-item:nth-child(3) .selector { color: #005cc5; } /* Blue for ID */
|
||||
|
||||
.explanation {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
.badge.tag { background: #d73a49; }
|
||||
.badge.class { background: #6f42c1; }
|
||||
.badge.id { background: #005cc5; }
|
||||
|
||||
/* HTML Column */
|
||||
.code-view {
|
||||
background: #1e1e1e;
|
||||
color: #abb2bf;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.html-line {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.html-line.indent {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.html-line.highlight {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
text-shadow: 0 0 5px rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.attr {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
/* Connector */
|
||||
.connector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
z-index: 2;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.comparison {
|
||||
flex-direction: column;
|
||||
}
|
||||
.rule-item:hover, .rule-item.active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
.connector {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,289 +1,167 @@
|
||||
<template>
|
||||
<div class="dns-lookup-demo">
|
||||
<div class="control-panel">
|
||||
<div class="input-group">
|
||||
<label>Domain</label>
|
||||
<input
|
||||
v-model="domain"
|
||||
placeholder="www.example.com"
|
||||
class="domain-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="useCache" />
|
||||
<span class="slider"></span>
|
||||
<span class="label">Simulate Cache Hit</span>
|
||||
</label>
|
||||
</div>
|
||||
<button @click="startLookup" :disabled="isLooking" class="lookup-btn">
|
||||
{{ isLooking ? 'Resolving...' : 'Lookup' }}
|
||||
</button>
|
||||
<div class="dns-lookup-demo simple-mode">
|
||||
<div class="concept-explanation">
|
||||
<p class="why-text">
|
||||
<strong>为什么需要 DNS?(查导航)</strong>
|
||||
</p>
|
||||
<p class="why-desc-zh">
|
||||
你知道店铺名字叫 "Shop.com",但快递员需要知道具体的经纬度坐标 (IP 地址) 才能送达。
|
||||
<br>
|
||||
DNS 就像是<strong>地图导航</strong>,输入店名,它告诉你具体的坐标。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="viz-area">
|
||||
<!-- Client -->
|
||||
<div class="node client">
|
||||
<span class="icon">💻</span>
|
||||
<span>Browser</span>
|
||||
<div class="demo-stage">
|
||||
<div class="input-area">
|
||||
<span class="label">店铺名称 (域名)</span>
|
||||
<div class="fake-input">shop.com</div>
|
||||
</div>
|
||||
|
||||
<!-- Servers -->
|
||||
<div class="servers-container">
|
||||
<div
|
||||
v-for="(server, index) in servers"
|
||||
:key="server.name"
|
||||
class="node server"
|
||||
:class="{
|
||||
active: currentServer === index,
|
||||
success: completed && currentServer === index
|
||||
}"
|
||||
>
|
||||
<div class="server-icon">{{ server.icon }}</div>
|
||||
<div class="server-name">{{ server.name }}</div>
|
||||
<div class="server-desc">{{ server.desc }}</div>
|
||||
<div class="process-animation">
|
||||
<div class="arrow-down">⬇️</div>
|
||||
<div class="dns-box">
|
||||
<div class="icon">🧭</div>
|
||||
<div class="title">DNS (地图导航)</div>
|
||||
<div class="desc">正在查找 shop.com 的位置...</div>
|
||||
</div>
|
||||
<div class="arrow-down">⬇️</div>
|
||||
</div>
|
||||
|
||||
<!-- Packet Animation -->
|
||||
<div v-if="packet.visible" class="packet" :style="packetStyle">
|
||||
{{ packet.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-panel" ref="logPanel">
|
||||
<div v-for="(log, i) in logs" :key="i" class="log-entry">
|
||||
<span class="time">{{ log.time }}ms</span>
|
||||
<span class="msg">{{ log.message }}</span>
|
||||
<div class="output-area">
|
||||
<span class="label">GPS 坐标 (IP 地址)</span>
|
||||
<div class="fake-output">93.184.216.34</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const domain = ref('www.google.com')
|
||||
const useCache = ref(false)
|
||||
const isLooking = ref(false)
|
||||
const currentServer = ref(-1)
|
||||
const completed = ref(false)
|
||||
const logs = ref([])
|
||||
const packet = ref({ visible: false, text: '?', x: 0, y: 0 })
|
||||
|
||||
const servers = [
|
||||
{ name: 'Root (.)', icon: '🌲', desc: 'Global Root' },
|
||||
{ name: 'TLD (.com)', icon: '🏢', desc: 'Top Level' },
|
||||
{ name: 'Authoritative', icon: '📝', desc: 'example.com' }
|
||||
]
|
||||
|
||||
const packetStyle = computed(() => ({
|
||||
transform: `translate(${packet.value.x}px, ${packet.value.y}px)`,
|
||||
opacity: packet.value.visible ? 1 : 0
|
||||
}))
|
||||
|
||||
const addLog = (message) => {
|
||||
logs.value.push({
|
||||
time: Math.floor(performance.now() % 10000),
|
||||
message
|
||||
})
|
||||
// Auto scroll
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('.log-panel')
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
const startLookup = async () => {
|
||||
if (isLooking.value) return
|
||||
isLooking.value = true
|
||||
currentServer.value = -1
|
||||
completed.value = false
|
||||
logs.value = []
|
||||
|
||||
addLog(`Starting DNS lookup for ${domain.value}`)
|
||||
|
||||
if (useCache.value) {
|
||||
await wait(500)
|
||||
addLog('✅ Cache HIT! IP found in browser cache.')
|
||||
completed.value = true
|
||||
isLooking.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Recursive Query Simulation
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const server = servers[i]
|
||||
addLog(`Querying ${server.name}...`)
|
||||
|
||||
// Simulate network delay
|
||||
currentServer.value = i
|
||||
await wait(800)
|
||||
|
||||
if (i < servers.length - 1) {
|
||||
addLog(`Received referral to ${servers[i + 1].name}`)
|
||||
} else {
|
||||
addLog(`✅ Resolved IP: 142.250.185.238`)
|
||||
}
|
||||
}
|
||||
|
||||
completed.value = true
|
||||
isLooking.value = false
|
||||
}
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
// Simplified: No need for complex i18n logic anymore as we display both.
|
||||
defineProps({
|
||||
lang: String // Accepted but ignored
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dns-lookup-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.domain-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.lookup-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
height: 40px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.lookup-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.viz-area {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
transition: all 0.3s;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.node.client {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.servers-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.node.server.active {
|
||||
border-color: #f59e0b;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 15px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.node.server.success {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.server-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.server-desc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
background: #1e1e1e;
|
||||
color: #10b981;
|
||||
.concept-explanation {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
height: 150px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.log-entry .time {
|
||||
color: #6b7280;
|
||||
margin-right: 0.5rem;
|
||||
.why-text {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle {
|
||||
.why-desc-en {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.why-desc-zh {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.demo-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input-area, .output-area {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.fake-input, .fake-output {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.8rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.fake-input {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.fake-output {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.process-animation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.toggle input {
|
||||
display: none;
|
||||
|
||||
.dns-box {
|
||||
background: #fffbeb;
|
||||
border: 2px solid #f59e0b;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
width: 240px; /* Slightly wider for bilingual text */
|
||||
}
|
||||
.toggle .slider {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background: #ccc;
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
transition: 0.3s;
|
||||
|
||||
.html.dark .dns-box {
|
||||
background: #451a03;
|
||||
}
|
||||
.toggle .slider:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: 0.3s;
|
||||
|
||||
.icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.toggle input:checked + .slider {
|
||||
background: var(--vp-c-brand);
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
color: #d97706;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.toggle input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
|
||||
.desc {
|
||||
font-size: 0.8rem;
|
||||
color: #b45309;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(5px); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
<template>
|
||||
<div class="frontend-evolution-demo">
|
||||
<!-- Modern Timeline -->
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-track"></div>
|
||||
<button
|
||||
v-for="(stage, index) in stages"
|
||||
:key="index"
|
||||
class="timeline-node"
|
||||
:class="{ active: currentStage === index, passed: currentStage > index }"
|
||||
@click="currentStage = index"
|
||||
>
|
||||
<div class="node-dot">
|
||||
<div class="inner-dot"></div>
|
||||
</div>
|
||||
<div class="node-content">
|
||||
<span class="year-badge">{{ stage.year }}</span>
|
||||
<span class="node-label">{{ stage.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div :key="currentStage" class="stage-content">
|
||||
<div class="header-section">
|
||||
<h3>
|
||||
<span class="stage-index">{{ indexToRoman(currentStage + 1) }}.</span>
|
||||
{{ stages[currentStage].title }}
|
||||
</h3>
|
||||
<p>{{ stages[currentStage].desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="visualization-grid">
|
||||
<!-- Code Editor -->
|
||||
<div class="mac-window code-window">
|
||||
<div class="window-bar">
|
||||
<div class="traffic-lights">
|
||||
<span class="light red"></span>
|
||||
<span class="light yellow"></span>
|
||||
<span class="light green"></span>
|
||||
</div>
|
||||
<div class="window-title">{{ stages[currentStage].codeTitle }}</div>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<pre><code>{{ stages[currentStage].code }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagram View -->
|
||||
<div class="mac-window diagram-window">
|
||||
<div class="window-bar">
|
||||
<div class="window-title">Architecture Pattern</div>
|
||||
</div>
|
||||
<div class="diagram-canvas">
|
||||
|
||||
<!-- Stage 0: Static -->
|
||||
<div v-if="currentStage === 0" class="diagram static">
|
||||
<div class="flow-stack">
|
||||
<div class="concept-box html">
|
||||
<span class="icon">📄</span> HTML (Content)
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div class="concept-box browser">
|
||||
<span class="icon">🌍</span> Browser (Display)
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-note">Server sends complete HTML</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 1: jQuery -->
|
||||
<div v-if="currentStage === 1" class="diagram jquery">
|
||||
<div class="concept-box dom">
|
||||
<span class="icon">🌳</span> DOM Tree
|
||||
</div>
|
||||
<div class="chaos-arrows">
|
||||
<svg viewBox="0 0 100 60" class="chaos-svg">
|
||||
<path d="M10,10 Q50,5 90,10" class="arrow-path" marker-end="url(#arrowhead)"/>
|
||||
<path d="M90,50 Q50,55 10,50" class="arrow-path" marker-end="url(#arrowhead)"/>
|
||||
<path d="M20,20 Q50,40 80,20" class="arrow-path dashed" marker-end="url(#arrowhead)"/>
|
||||
</svg>
|
||||
<span class="label-action">Direct Manipulation</span>
|
||||
<span class="label-event">Events</span>
|
||||
</div>
|
||||
<div class="concept-box js">
|
||||
<span class="icon">🍝</span> jQuery / JS
|
||||
</div>
|
||||
|
||||
<!-- SVG Marker Definition -->
|
||||
<svg style="position: absolute; width: 0; height: 0;">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#666" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Stage 2: MVC -->
|
||||
<div v-if="currentStage === 2" class="diagram mvc">
|
||||
<div class="mvc-triangle">
|
||||
<div class="concept-box model">Model</div>
|
||||
<div class="concept-box view">View</div>
|
||||
<div class="concept-box controller">Controller</div>
|
||||
|
||||
<!-- Connecting Lines -->
|
||||
<div class="line m-v"></div>
|
||||
<div class="line v-c"></div>
|
||||
<div class="line c-m"></div>
|
||||
</div>
|
||||
<div class="mvc-desc">Two-way Binding</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 3: Component -->
|
||||
<div v-if="currentStage === 3" class="diagram component">
|
||||
<div class="comp-structure">
|
||||
<div class="comp-box root">
|
||||
<span class="comp-label">App</span>
|
||||
<div class="comp-children">
|
||||
<div class="comp-box header">Header</div>
|
||||
<div class="comp-box list">
|
||||
ProductList
|
||||
<div class="comp-children row">
|
||||
<div class="comp-box item">Item</div>
|
||||
<div class="comp-box item">Item</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-pill">
|
||||
State ➔ UI = f(State)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentStage = ref(0)
|
||||
|
||||
const indexToRoman = (num) => {
|
||||
const map = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV' }
|
||||
return map[num] || num
|
||||
}
|
||||
|
||||
const stages = [
|
||||
{
|
||||
year: '1990s',
|
||||
label: 'Static Web',
|
||||
title: 'The Static Era',
|
||||
desc: 'Web pages were just digital documents. The server sent HTML, and the browser rendered it. Want new content? Refresh the whole page.',
|
||||
codeTitle: 'index.html',
|
||||
code: `<html>
|
||||
<body>
|
||||
<h1>Hello World</h1>
|
||||
<p>Static content served by server.</p>
|
||||
</body>
|
||||
</html>`
|
||||
},
|
||||
{
|
||||
year: '2005+',
|
||||
label: 'jQuery Era',
|
||||
title: 'Imperative DOM',
|
||||
desc: 'JS directly manipulated the DOM. "Find that button, add a click listener, change that div\'s color". Logic became tangled like "spaghetti".',
|
||||
codeTitle: 'script.js',
|
||||
code: `$('#btn').click(function() {
|
||||
// Find & Modify directly
|
||||
$('.box').show();
|
||||
$('.text').text('Loading...');
|
||||
|
||||
// Callback hell...
|
||||
$.ajax('/api', function(data) {
|
||||
$('.content').html(data);
|
||||
});
|
||||
});`
|
||||
},
|
||||
{
|
||||
year: '2010+',
|
||||
label: 'MVC/MVVM',
|
||||
title: 'Framework Era',
|
||||
desc: 'Separation of concerns. Data (Model) and View were separated. Two-way data binding (like in AngularJS) was magic but performance-heavy.',
|
||||
codeTitle: 'controller.js',
|
||||
code: `$scope.user = { name: 'Bob' };
|
||||
|
||||
// Magic: Data changes -> View updates
|
||||
$scope.updateName = function() {
|
||||
$scope.user.name = 'Alice';
|
||||
};`
|
||||
},
|
||||
{
|
||||
year: '2013+',
|
||||
label: 'Component',
|
||||
title: 'Component Era',
|
||||
desc: 'UI is broken into independent "Lego blocks" (Components). Declarative: You define "What it looks like given State X", framework handles the "How".',
|
||||
codeTitle: 'ProductCard.vue',
|
||||
code: `<template>
|
||||
<div class="card">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<button @click="buy">Buy</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// State driven
|
||||
export default {
|
||||
props: ['product'],
|
||||
methods: { buy() { ... } }
|
||||
}
|
||||
<\/script>`
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.frontend-evolution-demo {
|
||||
border-radius: 16px;
|
||||
background: var(--vp-c-bg);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
margin: 2rem 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* --- Timeline --- */
|
||||
.timeline-container {
|
||||
padding: 2rem 1rem 1rem;
|
||||
background: linear-gradient(to bottom, var(--vp-c-bg-soft), var(--vp-c-bg));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
position: absolute;
|
||||
top: 2.5rem; /* Center with dots */
|
||||
left: 3rem;
|
||||
right: 3rem;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 25%;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.timeline-node:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.timeline-node.active, .timeline-node.passed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.node-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-text-3);
|
||||
margin-bottom: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.inner-dot {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.timeline-node.active .node-dot {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 0 4px var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.timeline-node.active .inner-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.timeline-node.passed .node-dot {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.node-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* --- Content --- */
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.header-section h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(120deg, var(--vp-c-brand), #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stage-index {
|
||||
color: var(--vp-c-text-3);
|
||||
-webkit-text-fill-color: var(--vp-c-text-3);
|
||||
margin-right: 0.5rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.header-section p {
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* --- Visualization Grid --- */
|
||||
.visualization-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.visualization-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mac-window {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.mac-window:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.code-window {
|
||||
background: #1e1e2e; /* Dark theme */
|
||||
}
|
||||
|
||||
.diagram-window {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.window-bar {
|
||||
padding: 0.8rem 1rem;
|
||||
background: rgba(255,255,255,0.05); /* Transparent on dark */
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.diagram-window .window-bar {
|
||||
background: white;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.light.red { background: #ff5f56; }
|
||||
.light.yellow { background: #ffbd2e; }
|
||||
.light.green { background: #27c93f; }
|
||||
|
||||
.window-title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.diagram-window .window-title {
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-content pre {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-content code {
|
||||
font-family: 'Fira Code', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
.diagram-canvas {
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 250px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* --- Diagram Specifics --- */
|
||||
.concept-box {
|
||||
background: white;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
padding: 0.8rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon { font-size: 1.2rem; }
|
||||
|
||||
/* Static Diagram */
|
||||
.diagram.static .flow-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.side-note {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
background: rgba(0,0,0,0.05);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* jQuery Diagram */
|
||||
.diagram.jquery {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.chaos-arrows {
|
||||
position: relative;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chaos-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.arrow-path {
|
||||
fill: none;
|
||||
stroke: #9ca3af;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.arrow-path.dashed {
|
||||
stroke-dasharray: 4;
|
||||
}
|
||||
.label-action, .label-event {
|
||||
font-size: 0.75rem;
|
||||
background: white;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.label-action { transform: translate(-20px, -10px); }
|
||||
.label-event { transform: translate(20px, 10px); }
|
||||
|
||||
/* MVC Diagram */
|
||||
.diagram.mvc {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.mvc-triangle {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 160px;
|
||||
}
|
||||
.mvc-triangle .model { position: absolute; top: 0; left: 50%; transform: translateX(-50%); }
|
||||
.mvc-triangle .view { position: absolute; bottom: 0; left: 0; }
|
||||
.mvc-triangle .controller { position: absolute; bottom: 0; right: 0; }
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
background: #cbd5e1;
|
||||
z-index: 1;
|
||||
}
|
||||
.line.m-v {
|
||||
height: 2px;
|
||||
width: 110px;
|
||||
top: 45%;
|
||||
left: 20px;
|
||||
transform: rotate(60deg);
|
||||
}
|
||||
.line.v-c {
|
||||
height: 2px;
|
||||
width: 100px;
|
||||
bottom: 20px;
|
||||
left: 50px;
|
||||
}
|
||||
.line.c-m {
|
||||
height: 2px;
|
||||
width: 110px;
|
||||
top: 45%;
|
||||
right: 20px;
|
||||
transform: rotate(-60deg);
|
||||
}
|
||||
.mvc-desc {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Component Diagram */
|
||||
.diagram.component {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.comp-structure {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.comp-box {
|
||||
background: white;
|
||||
border: 2px solid #3b82f6;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 0 rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
.comp-box.root {
|
||||
background: #eff6ff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
.comp-label {
|
||||
font-weight: bold;
|
||||
color: #1e40af;
|
||||
}
|
||||
.comp-children {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.comp-children.row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.comp-box.header { background: #dbeafe; border-style: dashed; }
|
||||
.comp-box.list { background: #dbeafe; }
|
||||
.comp-box.item { background: #bfdbfe; font-size: 0.7rem; padding: 4px; }
|
||||
|
||||
.flow-pill {
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,82 +1,101 @@
|
||||
<template>
|
||||
<div class="http-exchange-demo">
|
||||
<div class="demo-container">
|
||||
<!-- Client Side -->
|
||||
<div class="panel client-panel">
|
||||
<div class="panel-header">
|
||||
<span class="icon">💻</span> Client (Browser)
|
||||
</div>
|
||||
<div class="browser-frame">
|
||||
<!-- Address Bar (Simplified) -->
|
||||
<div class="address-bar">
|
||||
<select v-model="method" class="method-select" :disabled="loading">
|
||||
<option>GET</option>
|
||||
<option>POST</option>
|
||||
<option>PUT</option>
|
||||
<option>DELETE</option>
|
||||
</select>
|
||||
<input v-model="path" class="url-input" :disabled="loading" />
|
||||
<button @click="sendRequest" :disabled="loading" class="send-btn">
|
||||
{{ loading ? '...' : t.send }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="request-builder">
|
||||
<div class="input-row">
|
||||
<select v-model="method" class="method-select">
|
||||
<option>GET</option>
|
||||
<option>POST</option>
|
||||
<option>PUT</option>
|
||||
<option>DELETE</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="path"
|
||||
class="path-input"
|
||||
placeholder="/index.html"
|
||||
/>
|
||||
<div class="split-view">
|
||||
<!-- Network Log (Left) -->
|
||||
<div class="network-log">
|
||||
<div class="log-header">
|
||||
<span>{{ t.cols.name }}</span>
|
||||
<span>{{ t.cols.status }}</span>
|
||||
<span>{{ t.cols.type }}</span>
|
||||
<span>{{ t.cols.time }}</span>
|
||||
</div>
|
||||
|
||||
<div class="headers-section">
|
||||
<div class="section-title">Headers</div>
|
||||
<div v-for="(value, key) in headers" :key="key" class="header-row">
|
||||
<span class="header-key">{{ key }}:</span>
|
||||
<span class="header-value">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="sendRequest"
|
||||
class="send-btn"
|
||||
:disabled="isProcessing"
|
||||
<div
|
||||
class="log-row"
|
||||
:class="{ active: requestSent, selected: true }"
|
||||
v-if="requestSent"
|
||||
>
|
||||
{{ isProcessing ? 'Sending...' : 'Send Request' }}
|
||||
</button>
|
||||
<span class="col-name">{{ path.split('/').pop() || 'index' }}</span>
|
||||
<span class="col-status" :class="statusClass">{{ responseStatus }}</span>
|
||||
<span class="col-type">document</span>
|
||||
<span class="col-time">{{ loading ? 'Pending' : '45ms' }}</span>
|
||||
</div>
|
||||
<div v-else class="empty-state">{{ t.noRequests }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Visualization -->
|
||||
<div class="network-space">
|
||||
<div class="connection-line"></div>
|
||||
<div
|
||||
v-if="currentPacket"
|
||||
class="packet"
|
||||
:class="currentPacket.type"
|
||||
:style="{ left: packetPosition + '%' }"
|
||||
>
|
||||
{{ currentPacket.label }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Details Panel (Right) -->
|
||||
<div class="details-panel" v-if="requestSent">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="tabKey in ['headers', 'response', 'preview']"
|
||||
:key="tabKey"
|
||||
:class="{ active: activeTab === tabKey }"
|
||||
@click="activeTab = tabKey"
|
||||
>
|
||||
{{ t.tabs[tabKey] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Server Side -->
|
||||
<div class="panel server-panel">
|
||||
<div class="panel-header"><span class="icon">🖥️</span> Server</div>
|
||||
|
||||
<div class="response-viewer" :class="{ empty: !response }">
|
||||
<div v-if="response">
|
||||
<div class="status-row" :class="statusClass">
|
||||
{{ response.status }} {{ response.statusText }}
|
||||
</div>
|
||||
<div class="headers-section">
|
||||
<div
|
||||
v-for="(value, key) in response.headers"
|
||||
:key="key"
|
||||
class="header-row"
|
||||
>
|
||||
<span class="header-key">{{ key }}:</span>
|
||||
<span class="header-value">{{ value }}</span>
|
||||
<div class="tab-content">
|
||||
<!-- Headers Tab -->
|
||||
<div v-if="activeTab === 'headers'" class="headers-view">
|
||||
<div class="section">
|
||||
<div class="section-title">{{ t.general }}</div>
|
||||
<div class="kv-row">
|
||||
<span class="key">{{ t.requestUrl }}:</span>
|
||||
<span class="value">https://api.example.com{{ path }}</span>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span class="key">{{ t.requestMethod }}:</span>
|
||||
<span class="value">{{ method }}</span>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span class="key">{{ t.statusCode }}:</span>
|
||||
<span class="value">
|
||||
<span class="status-dot" :class="statusClass"></span>
|
||||
{{ responseStatus || '...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">{{ t.responseHeaders }}</div>
|
||||
<div class="kv-row" v-for="(val, key) in responseHeaders" :key="key">
|
||||
<span class="key">{{ key }}:</span>
|
||||
<span class="value">{{ val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-preview">
|
||||
{{ response.body }}
|
||||
|
||||
<!-- Response Tab -->
|
||||
<div v-if="activeTab === 'response'" class="code-view">
|
||||
<pre>{{ responseBody }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Preview Tab -->
|
||||
<div v-if="activeTab === 'preview'" class="preview-view">
|
||||
<div v-if="method === 'GET'" class="html-preview" v-html="responseBody"></div>
|
||||
<div v-else class="json-preview">
|
||||
JSON Data: {{ responseBody }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="placeholder">Waiting for request...</div>
|
||||
</div>
|
||||
<div v-else class="details-placeholder">
|
||||
{{ t.placeholder }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,252 +105,260 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const method = ref('GET')
|
||||
const path = ref('/index.html')
|
||||
const isProcessing = ref(false)
|
||||
const packetPosition = ref(10)
|
||||
const currentPacket = ref(null)
|
||||
const response = ref(null)
|
||||
|
||||
const headers = ref({
|
||||
Host: 'www.example.com',
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Accept: 'text/html'
|
||||
const props = defineProps({
|
||||
lang: {
|
||||
type: String,
|
||||
default: 'zh'
|
||||
}
|
||||
})
|
||||
|
||||
const responses = {
|
||||
GET: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'text/html',
|
||||
Server: 'Nginx'
|
||||
},
|
||||
body: '<!DOCTYPE html>\n<html>\n <body>Hello World</body>\n</html>'
|
||||
const t = {
|
||||
send: '提交订单 (发送请求)',
|
||||
noRequests: '购物车是空的 (无请求)',
|
||||
placeholder: '点击 "提交订单" 向店员购买玩具',
|
||||
general: '订单详情 (General)',
|
||||
requestUrl: '商品地址 (URL)',
|
||||
requestMethod: '操作类型 (Method)',
|
||||
statusCode: '店员回复 (Status)',
|
||||
responseHeaders: '包裹标签 (Headers)',
|
||||
tabs: {
|
||||
headers: '订单信息',
|
||||
response: '包裹内容',
|
||||
preview: '玩具预览'
|
||||
},
|
||||
POST: {
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: '{"success": true, "id": 123}'
|
||||
cols: {
|
||||
name: '商品',
|
||||
status: '状态',
|
||||
type: '类型',
|
||||
time: '耗时'
|
||||
}
|
||||
}
|
||||
|
||||
const method = ref('GET')
|
||||
const path = ref('/toys/lego-castle')
|
||||
const loading = ref(false)
|
||||
const requestSent = ref(false)
|
||||
const activeTab = ref('headers')
|
||||
|
||||
const responseStatus = ref('')
|
||||
const responseBody = ref('')
|
||||
const responseHeaders = ref({})
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
requestSent.value = true
|
||||
responseStatus.value = '处理中...'
|
||||
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
|
||||
loading.value = false
|
||||
|
||||
if (method.value === 'GET') {
|
||||
responseStatus.value = '200 OK (有货)'
|
||||
responseHeaders.value = {
|
||||
'Content-Type': 'application/json (积木)',
|
||||
'Date': new Date().toLocaleString(),
|
||||
'Store': '乐高官方店'
|
||||
}
|
||||
responseBody.value = `{\n "id": 101,\n "name": "Lego Castle",\n "pieces": 500,\n "price": "$99"\n}`
|
||||
} else {
|
||||
responseStatus.value = '201 Created (下单成功)'
|
||||
responseHeaders.value = {
|
||||
'Content-Type': 'application/json',
|
||||
'Date': new Date().toLocaleString()
|
||||
}
|
||||
responseBody.value = `{\n "success": true,\n "message": "Order placed"\n}`
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (!response.value) return ''
|
||||
const code = response.value.status
|
||||
if (code >= 200 && code < 300) return 'success'
|
||||
if (code >= 400) return 'error'
|
||||
return ''
|
||||
if (loading.value) return 'pending'
|
||||
if (responseStatus.value.startsWith('2')) return 'success'
|
||||
return 'error'
|
||||
})
|
||||
|
||||
const sendRequest = () => {
|
||||
if (isProcessing.value) return
|
||||
isProcessing.value = true
|
||||
response.value = null
|
||||
|
||||
// Animate Request
|
||||
currentPacket.value = {
|
||||
type: 'request',
|
||||
label: `${method.value} ${path.value}`
|
||||
}
|
||||
animatePacket(10, 90, () => {
|
||||
// Server Processing
|
||||
setTimeout(() => {
|
||||
// Prepare Response
|
||||
const mockResponse = responses[method.value] || responses['GET']
|
||||
|
||||
// Animate Response
|
||||
currentPacket.value = {
|
||||
type: 'response',
|
||||
label: `${mockResponse.status} ${mockResponse.statusText}`
|
||||
}
|
||||
animatePacket(90, 10, () => {
|
||||
response.value = mockResponse
|
||||
currentPacket.value = null
|
||||
isProcessing.value = false
|
||||
})
|
||||
}, 800)
|
||||
})
|
||||
}
|
||||
|
||||
const animatePacket = (start, end, callback) => {
|
||||
let pos = start
|
||||
const step = (end - start) / 50
|
||||
const interval = setInterval(() => {
|
||||
pos += step
|
||||
packetPosition.value = pos
|
||||
|
||||
if ((step > 0 && pos >= end) || (step < 0 && pos <= end)) {
|
||||
clearInterval(interval)
|
||||
callback()
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.http-exchange-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-weight: bold;
|
||||
.address-bar {
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.network-space {
|
||||
width: 20%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connection-line {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
border-top: 1px dashed var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.packet {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.packet.request {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.packet.response {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.method-select {
|
||||
padding: 0.3rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
.url-input {
|
||||
flex: 1;
|
||||
padding: 0.3rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.headers-section {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
.split-view {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.response-viewer.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
.network-log {
|
||||
width: 40%;
|
||||
border-right: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.log-header span { flex: 1; }
|
||||
|
||||
.log-row {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.log-row.selected {
|
||||
background: #e0f2fe; /* Light blue */
|
||||
}
|
||||
|
||||
html.dark .log-row.selected {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.log-row span { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.col-status.success { color: #10b981; }
|
||||
.col-status.pending { color: #9ca3af; }
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
.details-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.details-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-bottom-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.status-row.success {
|
||||
color: #10b981;
|
||||
}
|
||||
.status-row.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.body-preview {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
.kv-row {
|
||||
display: flex;
|
||||
margin-bottom: 0.3rem;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.kv-row .key {
|
||||
width: 120px;
|
||||
color: var(--vp-c-text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kv-row .value {
|
||||
color: var(--vp-c-text-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.code-view pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.status-dot.success { background: #10b981; }
|
||||
.status-dot.pending { background: #9ca3af; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="imperative-declarative-demo">
|
||||
<div class="demo-grid">
|
||||
<!-- Imperative (jQuery Style) -->
|
||||
<div class="panel imperative">
|
||||
<div class="panel-header">
|
||||
<span class="badge yellow">Imperative (命令式)</span>
|
||||
<span class="sub-text">jQuery Style</span>
|
||||
</div>
|
||||
<div class="code-preview">
|
||||
<code>
|
||||
// 手动操作 DOM<br>
|
||||
$('#count').text(val);<br>
|
||||
if (val > 5) $('#msg').show();
|
||||
</code>
|
||||
</div>
|
||||
<div class="interactive-area">
|
||||
<div class="output-box">
|
||||
Count: <span id="imp-count-display">{{ impCount }}</span>
|
||||
<div v-show="impShowMsg" class="warning-msg">⚠️ Count is high!</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="impIncrement" class="btn">Step 1: Value++</button>
|
||||
<button @click="impUpdateText" class="btn" :disabled="!impChanged">Step 2: Update Text</button>
|
||||
<button @click="impCheckState" class="btn" :disabled="!impTextUpdated">Step 3: Check Logic</button>
|
||||
</div>
|
||||
<div class="status-log">{{ impStatus }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Declarative (Vue Style) -->
|
||||
<div class="panel declarative">
|
||||
<div class="panel-header">
|
||||
<span class="badge green">Declarative (声明式)</span>
|
||||
<span class="sub-text">Vue/React Style</span>
|
||||
</div>
|
||||
<div class="code-preview">
|
||||
<code>
|
||||
// 只需要绑定数据<br>
|
||||
{{ '{' + '{ count }' + '}' }}<br>
|
||||
<div v-if="count > 5">...</div>
|
||||
</code>
|
||||
</div>
|
||||
<div class="interactive-area">
|
||||
<div class="output-box">
|
||||
Count: <span>{{ decCount }}</span>
|
||||
<div v-if="decCount > 5" class="warning-msg">⚠️ Count is high!</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="decIncrement" class="btn primary">Value++ (Auto Render)</button>
|
||||
</div>
|
||||
<div class="status-log">Framework handles DOM updates automatically.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Imperative State
|
||||
const impCount = ref(0)
|
||||
const impShowMsg = ref(false)
|
||||
const impChanged = ref(false)
|
||||
const impTextUpdated = ref(false)
|
||||
const impStatus = ref('Ready.')
|
||||
|
||||
const impIncrement = () => {
|
||||
// Logic layer changes, but DOM doesn't
|
||||
impStatus.value = 'Variable `count` is now ' + (impCount.value + 1) + '. DOM is NOT updated.'
|
||||
impCount.value++
|
||||
impChanged.value = true
|
||||
impTextUpdated.value = false
|
||||
}
|
||||
|
||||
const impUpdateText = () => {
|
||||
// Manually update text
|
||||
impStatus.value = 'DOM text updated manually.'
|
||||
impChanged.value = false
|
||||
impTextUpdated.value = true
|
||||
}
|
||||
|
||||
const impCheckState = () => {
|
||||
// Manually check logic
|
||||
if (impCount.value > 5) {
|
||||
impShowMsg.value = true
|
||||
impStatus.value = 'Logic checked: > 5. Manually showing message.'
|
||||
} else {
|
||||
impShowMsg.value = false
|
||||
impStatus.value = 'Logic checked: <= 5. No message.'
|
||||
}
|
||||
}
|
||||
|
||||
// Declarative State
|
||||
const decCount = ref(0)
|
||||
const decIncrement = () => {
|
||||
decCount.value++
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.imperative-declarative-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.demo-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 0.8rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
.badge.yellow { background: #f59e0b; }
|
||||
.badge.green { background: #10b981; }
|
||||
|
||||
.sub-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
background: #1e1e2e;
|
||||
padding: 0.8rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #a6accd;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.interactive-area {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.output-box {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.warning-msg {
|
||||
color: #ef4444;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
animation: popIn 0.3s;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #f3f4f6; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn.primary { background: #3b82f6; color: white; border: none; }
|
||||
.btn.primary:hover { background: #2563eb; }
|
||||
|
||||
.status-log {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-style: italic;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
@keyframes popIn {
|
||||
0% { transform: scale(0.8); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +1,153 @@
|
||||
<template>
|
||||
<div class="tcp-handshake-demo">
|
||||
<div class="diagram">
|
||||
<!-- Client Column -->
|
||||
<div class="column client">
|
||||
<div class="actor-icon">💻 Client</div>
|
||||
<div class="state-label">{{ clientState }}</div>
|
||||
<div class="controls">
|
||||
<div class="status-indicator">
|
||||
{{ t.statusLabel }}: <span :class="connectionStatus.toLowerCase()">{{ statusText }}</span>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button v-if="step === 0" @click="startHandshake" class="action-btn">{{ t.connect }}</button>
|
||||
<button v-else @click="reset" class="reset-btn">{{ t.reset }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sequence-diagram">
|
||||
<!-- Client Timeline -->
|
||||
<div class="timeline client">
|
||||
<div class="actor">
|
||||
<span class="icon">💻</span>
|
||||
<span class="name">{{ t.client }}</span>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="state-marker" :class="{ active: step >= 1 }">SYN_SENT</div>
|
||||
<div class="state-marker" :class="{ active: step >= 3 }">ESTABLISHED</div>
|
||||
</div>
|
||||
|
||||
<!-- Interaction Area -->
|
||||
<div class="interaction-zone">
|
||||
<!-- Step 1: SYN -->
|
||||
<div class="packet-row" :class="{ active: step === 1, done: step > 1 }">
|
||||
<button
|
||||
@click="sendSyn"
|
||||
:disabled="step !== 0"
|
||||
class="packet-btn syn"
|
||||
>
|
||||
SYN (SEQ=x) →
|
||||
</button>
|
||||
<div class="interaction-space">
|
||||
<!-- SYN Packet -->
|
||||
<div class="packet-track">
|
||||
<transition name="slide-right">
|
||||
<div v-if="showSyn" class="packet syn">
|
||||
<div class="packet-body">SYN</div>
|
||||
<div class="packet-detail">SEQ=0</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: SYN-ACK -->
|
||||
<div
|
||||
class="packet-row reverse"
|
||||
:class="{ active: step === 2, done: step > 2 }"
|
||||
>
|
||||
<button
|
||||
@click="sendSynAck"
|
||||
:disabled="step !== 1"
|
||||
class="packet-btn syn-ack"
|
||||
>
|
||||
← SYN-ACK (ACK=x+1, SEQ=y)
|
||||
</button>
|
||||
<!-- SYN-ACK Packet -->
|
||||
<div class="packet-track reverse">
|
||||
<transition name="slide-left">
|
||||
<div v-if="showSynAck" class="packet syn-ack">
|
||||
<div class="packet-body">SYN-ACK</div>
|
||||
<div class="packet-detail">SEQ=0, ACK=1</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: ACK -->
|
||||
<div class="packet-row" :class="{ active: step === 3, done: step > 3 }">
|
||||
<button
|
||||
@click="sendAck"
|
||||
:disabled="step !== 2"
|
||||
class="packet-btn ack"
|
||||
>
|
||||
ACK (ACK=y+1) →
|
||||
</button>
|
||||
<!-- ACK Packet -->
|
||||
<div class="packet-track">
|
||||
<transition name="slide-right">
|
||||
<div v-if="showAck" class="packet ack">
|
||||
<div class="packet-body">ACK</div>
|
||||
<div class="packet-detail">SEQ=1, ACK=1</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Column -->
|
||||
<div class="column server">
|
||||
<div class="actor-icon">🖥️ Server</div>
|
||||
<div class="state-label">{{ serverState }}</div>
|
||||
<!-- Server Timeline -->
|
||||
<div class="timeline server">
|
||||
<div class="actor">
|
||||
<span class="icon">🖥️</span>
|
||||
<span class="name">{{ t.server }}</span>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="state-marker" :class="{ active: step >= 2 }">SYN_RCVD</div>
|
||||
<div class="state-marker" :class="{ active: step >= 3 }">ESTABLISHED</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-message">
|
||||
<p v-if="step === 0">点击 <strong>SYN</strong> 开始连接。</p>
|
||||
<p v-if="step === 1">
|
||||
服务器收到了请求,现在需要回复 <strong>SYN-ACK</strong>。
|
||||
</p>
|
||||
<p v-if="step === 2">
|
||||
客户端收到了确认,最后发送 <strong>ACK</strong> 完成握手。
|
||||
</p>
|
||||
<p v-if="step === 3" class="success">🎉 连接已建立 (ESTABLISHED)!</p>
|
||||
<div class="description-box">
|
||||
<p>{{ currentDescription }}</p>
|
||||
</div>
|
||||
|
||||
<button v-if="step === 3" @click="reset" class="reset-btn">Reset</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
lang: {
|
||||
type: String,
|
||||
default: 'zh'
|
||||
}
|
||||
})
|
||||
|
||||
// Bilingual text directly
|
||||
const t = {
|
||||
statusLabel: '通话状态',
|
||||
connect: '拨打电话',
|
||||
reset: '挂断重拨',
|
||||
client: '我 (顾客)',
|
||||
server: '玩具店',
|
||||
status: {
|
||||
closed: '未通话',
|
||||
handshaking: '正在拨号...',
|
||||
established: '通话中 (连接已建立)'
|
||||
},
|
||||
steps: {
|
||||
0: '点击 "拨打电话" 开始确认店铺是否营业。',
|
||||
1: '步骤 1: 我问 "喂?有人在吗?" (SYN)',
|
||||
2: '步骤 2: 店员答 "在的!请问有什么事?" (SYN-ACK)',
|
||||
3: '步骤 3: 我说 "太好了,我想买东西!" (ACK)'
|
||||
}
|
||||
}
|
||||
|
||||
const step = ref(0)
|
||||
const clientState = ref('CLOSED')
|
||||
const serverState = ref('LISTEN')
|
||||
const showSyn = ref(false)
|
||||
const showSynAck = ref(false)
|
||||
const showAck = ref(false)
|
||||
|
||||
const sendSyn = () => {
|
||||
const connectionStatus = computed(() => {
|
||||
if (step.value === 0) return 'closed'
|
||||
if (step.value < 3) return 'handshaking'
|
||||
return 'established'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
const s = connectionStatus.value
|
||||
return t.status[s] || s.toUpperCase()
|
||||
})
|
||||
|
||||
const currentDescription = computed(() => {
|
||||
return t.steps[step.value] || ''
|
||||
})
|
||||
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
const startHandshake = async () => {
|
||||
if (step.value > 0) return
|
||||
|
||||
// Step 1: SYN
|
||||
step.value = 1
|
||||
clientState.value = 'SYN_SENT'
|
||||
}
|
||||
|
||||
const sendSynAck = () => {
|
||||
showSyn.value = true
|
||||
await wait(1500)
|
||||
|
||||
// Step 2: SYN-ACK
|
||||
step.value = 2
|
||||
serverState.value = 'SYN_RCVD'
|
||||
}
|
||||
|
||||
const sendAck = () => {
|
||||
showSynAck.value = true
|
||||
await wait(1500)
|
||||
|
||||
// Step 3: ACK
|
||||
step.value = 3
|
||||
clientState.value = 'ESTABLISHED'
|
||||
serverState.value = 'ESTABLISHED'
|
||||
showAck.value = true
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
step.value = 0
|
||||
clientState.value = 'CLOSED'
|
||||
serverState.value = 'LISTEN'
|
||||
showSyn.value = false
|
||||
showSynAck.value = false
|
||||
showAck.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -102,127 +155,200 @@ const reset = () => {
|
||||
.tcp-handshake-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.diagram {
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.column {
|
||||
width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actor-icon {
|
||||
font-size: 1.2rem;
|
||||
.status-indicator {
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-indicator span.closed { color: var(--vp-c-text-3); }
|
||||
.status-indicator span.handshaking { color: #f59e0b; }
|
||||
.status-indicator span.established { color: #10b981; }
|
||||
|
||||
.state-label {
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.interaction-zone {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.packet-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
opacity: 0.3;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.packet-row.reverse {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.packet-row.active {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.packet-row.done {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.packet-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.packet-btn:not(:disabled):hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.packet-btn.syn {
|
||||
.action-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.packet-btn.syn-ack {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
.packet-btn.ack {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.packet-btn:disabled {
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: not-allowed;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
height: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.status-message .success {
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
.sequence-diagram {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
z-index: 2;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.timeline .line {
|
||||
width: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.state-marker {
|
||||
margin-top: 2rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.state-marker.active {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.interaction-space {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.packet-track {
|
||||
height: 40px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.packet-track.reverse {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.packet {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.packet.syn-ack { background: #f59e0b; }
|
||||
.packet.ack { background: #10b981; }
|
||||
|
||||
.packet-body { font-weight: bold; }
|
||||
.packet-detail { font-size: 0.7rem; opacity: 0.9; }
|
||||
|
||||
/* Animations */
|
||||
.slide-right-enter-active {
|
||||
animation: slide-right 1.5s linear;
|
||||
}
|
||||
.slide-left-enter-active {
|
||||
animation: slide-left 1.5s linear;
|
||||
}
|
||||
|
||||
@keyframes slide-right {
|
||||
0% { transform: translateX(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateX(100%); opacity: 1; } /* Not quite right, need to stick */
|
||||
}
|
||||
|
||||
/*
|
||||
Vue transitions are tricky for "moving across".
|
||||
Let's use a simpler approach: CSS transitions on left/right property or keyframes.
|
||||
Actually, for a "send" animation, we want it to move from A to B and then stay or disappear.
|
||||
Here I want it to appear and move.
|
||||
*/
|
||||
|
||||
.slide-right-enter-active,
|
||||
.slide-left-enter-active {
|
||||
transition: all 1.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
transform: translateX(-150px);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-right-enter-to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* This is getting complicated with Vue transitions for simple movement.
|
||||
Let's just use CSS keyframes on the element itself when it renders.
|
||||
*/
|
||||
|
||||
.packet {
|
||||
animation-duration: 1s;
|
||||
animation-fill-mode: forwards;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.packet-track .packet {
|
||||
animation-name: moveRight;
|
||||
}
|
||||
.packet-track.reverse .packet {
|
||||
animation-name: moveLeft;
|
||||
}
|
||||
|
||||
@keyframes moveRight {
|
||||
from { transform: translateX(-100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes moveLeft {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.description-box {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
min-height: 3rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,129 @@
|
||||
<template>
|
||||
<div class="url-parser-demo">
|
||||
<div class="control-panel">
|
||||
<div class="input-group">
|
||||
<label>输入 URL</label>
|
||||
<div class="browser-bar">
|
||||
<div class="nav-buttons">
|
||||
<span class="nav-btn">←</span>
|
||||
<span class="nav-btn">→</span>
|
||||
<span class="nav-btn">↻</span>
|
||||
</div>
|
||||
<div class="omnibox">
|
||||
<span class="lock-icon">🔒</span>
|
||||
<!-- Segmented URL Display -->
|
||||
<div class="segmented-url" v-if="parsedUrl">
|
||||
<span
|
||||
class="url-part protocol"
|
||||
:class="{ active: highlightedPart === 'protocol' }"
|
||||
@mouseover="highlightedPart = 'protocol'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.protocol }}:</span>
|
||||
<span class="divider">//</span>
|
||||
<span
|
||||
class="url-part host"
|
||||
:class="{ active: highlightedPart === 'host' }"
|
||||
@mouseover="highlightedPart = 'host'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.host }}</span>
|
||||
<span
|
||||
v-if="parts.port"
|
||||
class="url-part port"
|
||||
:class="{ active: highlightedPart === 'port' }"
|
||||
@mouseover="highlightedPart = 'port'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>:{{ parts.port }}</span>
|
||||
<span
|
||||
class="url-part pathname"
|
||||
:class="{ active: highlightedPart === 'pathname' }"
|
||||
@mouseover="highlightedPart = 'pathname'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.pathname }}</span>
|
||||
<span
|
||||
v-if="parts.search"
|
||||
class="url-part search"
|
||||
:class="{ active: highlightedPart === 'search' }"
|
||||
@mouseover="highlightedPart = 'search'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.search }}</span>
|
||||
<span
|
||||
v-if="parts.hash"
|
||||
class="url-part hash"
|
||||
:class="{ active: highlightedPart === 'hash' }"
|
||||
@mouseover="highlightedPart = 'hash'"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>{{ parts.hash }}</span>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
v-model="inputUrl"
|
||||
type="text"
|
||||
placeholder="https://www.example.com:8080/path?query=1#fragment"
|
||||
class="url-input"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="encoding-toggle">
|
||||
<button @click="encodeUrl" class="action-btn">Encode</button>
|
||||
<button @click="decodeUrl" class="action-btn">Decode</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div v-if="parsedUrl" class="url-parts">
|
||||
<div
|
||||
v-for="(part, key) in parts"
|
||||
<div v-if="parsedUrl" class="url-breakdown">
|
||||
<div
|
||||
v-for="(part, key) in parts"
|
||||
:key="key"
|
||||
class="url-part"
|
||||
:class="key"
|
||||
class="url-segment"
|
||||
:class="[key, { active: highlightedPart === key }]"
|
||||
@mouseover="highlightedPart = key"
|
||||
@mouseleave="highlightedPart = null"
|
||||
>
|
||||
<div class="part-label">{{ labels[key] }}</div>
|
||||
<div class="part-value">{{ part || '-' }}</div>
|
||||
<div class="part-desc" v-if="highlightedPart === key">
|
||||
{{ descriptions[key] }}
|
||||
<div class="segment-header">
|
||||
<span class="segment-icon">{{ icons[key] }}</span>
|
||||
<span class="segment-label">{{ labels[key] }}</span>
|
||||
</div>
|
||||
<div class="segment-value">{{ part || '-' }}</div>
|
||||
<div class="segment-desc">{{ descriptions[key] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="error-message">无效的 URL 格式</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>Note:</strong>
|
||||
URL (统一资源定位符)
|
||||
是互联网资源的地址。浏览器首先需要将它拆解成不同的部分,才能知道要去哪里(域名)、用什么方式(协议)、找什么东西(路径)。
|
||||
</p>
|
||||
<div v-else class="error-state">
|
||||
Invalid URL format / 无效的 URL 格式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const inputUrl = ref(
|
||||
'https://www.example.com:8080/search/results?q=vue&page=1#top'
|
||||
)
|
||||
const props = defineProps({
|
||||
lang: {
|
||||
type: String,
|
||||
default: 'zh'
|
||||
}
|
||||
})
|
||||
|
||||
const inputUrl = ref('https://shop.com/toys/lego-castle?color=red#summary')
|
||||
const highlightedPart = ref(null)
|
||||
|
||||
const icons = {
|
||||
protocol: '🚛',
|
||||
host: '🏢',
|
||||
port: '🚪',
|
||||
pathname: '🧸',
|
||||
search: '📝',
|
||||
hash: '📍'
|
||||
}
|
||||
|
||||
const labels = {
|
||||
protocol: '协议 (Protocol)',
|
||||
host: '域名 (Host)',
|
||||
port: '端口 (Port)',
|
||||
pathname: '路径 (Path)',
|
||||
search: '查询 (Query)',
|
||||
hash: '锚点 (Fragment)'
|
||||
protocol: '交通方式 (Protocol)',
|
||||
host: '店铺地址 (Host)',
|
||||
port: '大门号 (Port)',
|
||||
pathname: '商品位置 (Path)',
|
||||
search: '备注要求 (Query)',
|
||||
hash: '快速定位 (Hash)'
|
||||
}
|
||||
|
||||
const descriptions = {
|
||||
protocol: '告诉浏览器使用什么方式连接(如 https 安全连接)',
|
||||
host: '服务器的地址,需要通过 DNS 解析为 IP',
|
||||
port: '服务器的门牌号(http默认80,https默认443)',
|
||||
pathname: '资源在服务器上的具体位置',
|
||||
search: '传递给服务器的额外参数',
|
||||
hash: '页面内的定位标记,不会发送给服务器'
|
||||
protocol: '怎么去?(例如 https = 坐装甲车去,很安全)',
|
||||
host: '去哪家店?(域名,例如 shop.com)',
|
||||
port: '从哪个门进?(默认 443 号门)',
|
||||
pathname: '商品在哪个货架?(路径)',
|
||||
search: '给店员的备注 (例如 ?color=red)',
|
||||
hash: '直接翻到说明书第几页 (锚点)'
|
||||
}
|
||||
|
||||
const parsedUrl = computed(() => {
|
||||
@@ -86,162 +139,173 @@ const parts = computed(() => {
|
||||
return {
|
||||
protocol: parsedUrl.value.protocol.replace(':', ''),
|
||||
host: parsedUrl.value.hostname,
|
||||
port:
|
||||
parsedUrl.value.port ||
|
||||
(parsedUrl.value.protocol === 'https:' ? '443' : '80'),
|
||||
port: parsedUrl.value.port || (parsedUrl.value.protocol === 'https:' ? '443' : '80'),
|
||||
pathname: parsedUrl.value.pathname,
|
||||
search: parsedUrl.value.search,
|
||||
hash: parsedUrl.value.hash
|
||||
}
|
||||
})
|
||||
|
||||
const encodeUrl = () => {
|
||||
inputUrl.value = encodeURI(inputUrl.value)
|
||||
}
|
||||
|
||||
const decodeUrl = () => {
|
||||
inputUrl.value = decodeURI(inputUrl.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.url-parser-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
background-color: var(--vp-c-bg);
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 2rem;
|
||||
.browser-bar {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.8rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 1.2rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.omnibox {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 20px;
|
||||
padding: 0.4rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Segmented URL Styles */
|
||||
.segmented-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.url-part {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.url-part:hover, .url-part.active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.url-part.protocol { color: #ef4444; }
|
||||
.url-part.host { color: #3b82f6; }
|
||||
.url-part.port { color: #f59e0b; }
|
||||
.url-part.pathname { color: #10b981; }
|
||||
.url-part.search { color: #8b5cf6; }
|
||||
.url-part.hash { color: #ec4899; }
|
||||
|
||||
.url-part.active.protocol { background: #fef2f2; }
|
||||
.url-part.active.host { background: #eff6ff; }
|
||||
.url-part.active.port { background: #fffbeb; }
|
||||
.url-part.active.pathname { background: #ecfdf5; }
|
||||
.url-part.active.search { background: #f5f3ff; }
|
||||
.url-part.active.hash { background: #fdf2f8; }
|
||||
|
||||
.divider {
|
||||
color: var(--vp-c-text-3);
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.url-breakdown {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
.url-segment {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent; /* Prepare for border color */
|
||||
background: var(--vp-c-bg-alt);
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: monospace;
|
||||
width: 100%;
|
||||
.url-segment.active {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.encoding-toggle {
|
||||
/* Color Coding for Cards */
|
||||
.url-segment.protocol { border-color: #ef4444; }
|
||||
.url-segment.host { border-color: #3b82f6; }
|
||||
.url-segment.port { border-color: #f59e0b; }
|
||||
.url-segment.pathname { border-color: #10b981; }
|
||||
.url-segment.search { border-color: #8b5cf6; }
|
||||
.url-segment.hash { border-color: #ec4899; }
|
||||
|
||||
.url-segment.active.protocol { background: #fef2f2; }
|
||||
.url-segment.active.host { background: #eff6ff; }
|
||||
.url-segment.active.port { background: #fffbeb; }
|
||||
.url-segment.active.pathname { background: #ecfdf5; }
|
||||
.url-segment.active.search { background: #f5f3ff; }
|
||||
.url-segment.active.hash { background: #fdf2f8; }
|
||||
|
||||
.segment-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
.segment-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
margin-bottom: 1.5rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.url-parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.url-part {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.url-part:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.url-part.protocol {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
.url-part.host {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
.url-part.port {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
.url-part.pathname {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
.url-part.search {
|
||||
border-left: 4px solid #8b5cf6;
|
||||
}
|
||||
.url-part.hash {
|
||||
border-left: 4px solid #ec4899;
|
||||
}
|
||||
|
||||
.part-label {
|
||||
.segment-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.part-value {
|
||||
font-size: 1rem;
|
||||
.segment-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.part-desc {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--vp-c-text-inverse-1);
|
||||
color: var(--vp-c-text-inverse-2);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
.segment-desc {
|
||||
font-size: 0.8rem;
|
||||
z-index: 10;
|
||||
margin-top: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
.error-state {
|
||||
text-align: center;
|
||||
color: #ef4444;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
<template>
|
||||
<div class="url-to-browser-demo">
|
||||
<div class="stage-nav">
|
||||
<div class="stage-tracker">
|
||||
<button
|
||||
v-for="(stage, index) in stages"
|
||||
:key="index"
|
||||
:class="{ active: currentStage === index }"
|
||||
class="tracker-node"
|
||||
:class="{ active: currentStage === index, visited: currentStage > index }"
|
||||
@click="currentStage = index"
|
||||
>
|
||||
<span class="stage-num">{{ index + 1 }}</span>
|
||||
<span class="stage-name">{{ stage.name }}</span>
|
||||
<div class="node-circle">
|
||||
<span class="icon">{{ stage.icon }}</span>
|
||||
</div>
|
||||
<span class="node-label">{{ stage.name }}</span>
|
||||
</button>
|
||||
<div class="tracker-line">
|
||||
<div class="line-fill" :style="{ width: (currentStage / (stages.length - 1)) * 100 + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-content">
|
||||
<transition name="fade" mode="out-in">
|
||||
<div :key="currentStage" class="stage-viz">
|
||||
<div class="viz-icon">{{ stages[currentStage].icon }}</div>
|
||||
<div class="viz-desc">
|
||||
<h3>{{ stages[currentStage].title }}</h3>
|
||||
<p>{{ stages[currentStage].desc }}</p>
|
||||
</div>
|
||||
<div class="viz-action">
|
||||
<component
|
||||
:is="stages[currentStage].component"
|
||||
v-if="stages[currentStage].component"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="stage-display">
|
||||
<div class="header">
|
||||
<h2>{{ stages[currentStage].title }}</h2>
|
||||
<p>{{ stages[currentStage].desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="component-wrapper">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component
|
||||
:is="stages[currentStage].component"
|
||||
:key="currentStage"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="action-footer" v-if="currentStage < stages.length - 1">
|
||||
<button class="next-btn" @click="nextStage">
|
||||
下一步 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,117 +50,185 @@ const currentStage = ref(0)
|
||||
const stages = [
|
||||
{
|
||||
name: 'URL',
|
||||
title: 'Parsing the Address',
|
||||
desc: 'Browser breaks down the URL to understand protocol, domain, and path.',
|
||||
icon: '🔍',
|
||||
title: '1. 填写购物单 (URL)',
|
||||
desc: '你想买一个玩具。首先要在订单上写清楚:去哪家店 (域名)、买什么 (路径)、用什么快递 (协议)。',
|
||||
icon: '📝',
|
||||
component: 'UrlParserDemo'
|
||||
},
|
||||
{
|
||||
name: 'DNS',
|
||||
title: 'Finding the IP',
|
||||
desc: 'Browser asks DNS servers to translate the domain name into an IP address.',
|
||||
icon: '🌐',
|
||||
title: '2. 查找店铺地址 (DNS)',
|
||||
desc: '快递员不知道 "玩具店" 在哪。他需要查地图 (DNS),把店名翻译成具体的 GPS 坐标 (IP 地址)。',
|
||||
icon: '🧭',
|
||||
component: 'DnsLookupDemo'
|
||||
},
|
||||
{
|
||||
name: 'TCP',
|
||||
title: 'Establishing Connection',
|
||||
desc: 'Browser and Server perform a 3-way handshake to create a reliable connection.',
|
||||
icon: '🤝',
|
||||
title: '3. 建立通话 (TCP)',
|
||||
desc: '找到店了!进店前先敲门确认:"有人吗?" "有!" "那我进来了!"。确保连接通畅,不会白跑一趟。',
|
||||
icon: '📞',
|
||||
component: 'TcpHandshakeDemo'
|
||||
},
|
||||
{
|
||||
name: 'HTTP',
|
||||
title: 'Exchanging Data',
|
||||
desc: 'Browser sends a request, and the server sends back the website content.',
|
||||
icon: '📨',
|
||||
title: '4. 购买商品 (HTTP)',
|
||||
desc: '进店后,你递交订单:"我要这个玩具"。店员去仓库找货,最后把装有玩具的包裹 (HTML) 递给你。',
|
||||
icon: '📦',
|
||||
component: 'HttpExchangeDemo'
|
||||
},
|
||||
{
|
||||
name: 'Render',
|
||||
title: 'Painting the Page',
|
||||
desc: 'Browser parses HTML/CSS and paints pixels on your screen.',
|
||||
icon: '🎨',
|
||||
title: '5. 拆盒组装 (渲染)',
|
||||
desc: '回到家,拆开包裹。照着说明书 (HTML),把积木 (DOM) 搭起来,涂上颜色 (CSS),玩具就变好看了!',
|
||||
icon: '🧩',
|
||||
component: 'BrowserRenderingDemo'
|
||||
}
|
||||
]
|
||||
|
||||
const nextStage = () => {
|
||||
if (currentStage.value < stages.length - 1) {
|
||||
currentStage.value++
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.url-to-browser-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
overflow: hidden;
|
||||
margin: 2rem 0;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.stage-nav {
|
||||
.stage-tracker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
padding: 2rem 2rem 1rem;
|
||||
position: relative;
|
||||
background: var(--vp-c-bg);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.stage-nav::before {
|
||||
content: '';
|
||||
.tracker-line {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 3.2rem; /* Adjusted for padding */
|
||||
left: 3.5rem;
|
||||
right: 3.5rem;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.stage-nav button {
|
||||
.line-fill {
|
||||
height: 100%;
|
||||
background: var(--vp-c-brand);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.tracker-node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 20px;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.stage-nav button.active {
|
||||
.node-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tracker-node.visited .node-circle {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tracker-node.active .node-circle {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 4px var(--vp-c-brand-dimm);
|
||||
transform: scale(1.1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.stage-num {
|
||||
.node-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.stage-content {
|
||||
.tracker-node.active .node-label {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stage-display {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
border: none;
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.component-wrapper {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
/* padding: 1rem; */
|
||||
}
|
||||
|
||||
.viz-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
.action-footer {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.viz-desc h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-1);
|
||||
.next-btn {
|
||||
padding: 0.8rem 2rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.viz-desc p {
|
||||
color: var(--vp-c-text-2);
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
.next-btn:hover {
|
||||
background: var(--vp-c-brand-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
|
||||
@@ -23,6 +23,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🎨 交互式配置面板 -->
|
||||
<div class="config-panel" v-if="current === 'css'">
|
||||
<div class="config-header">
|
||||
<div class="config-title">🎨 教学演示参数调整 (仅 CSS 模式生效)</div>
|
||||
<button class="reset-btn" @click="resetColors">重置默认</button>
|
||||
</div>
|
||||
<div class="config-items">
|
||||
<div class="config-item">
|
||||
<label>Primary Color (主题色)</label>
|
||||
<div class="input-group">
|
||||
<input type="color" v-model="customColors.primary" />
|
||||
<input type="text" v-model="customColors.primary" class="hex-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Text Color (文字色)</label>
|
||||
<div class="input-group">
|
||||
<input type="color" v-model="customColors.text" />
|
||||
<input type="text" v-model="customColors.text" class="hex-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Button Text (按钮文字)</label>
|
||||
<div class="input-group">
|
||||
<input type="color" v-model="customColors.btnText" />
|
||||
<input type="text" v-model="customColors.btnText" class="hex-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview" :class="current">
|
||||
<div class="hint">点一下标题/段落/按钮,我会在下面的代码里高亮对应行。</div>
|
||||
<h1
|
||||
@@ -98,7 +129,48 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, reactive } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
primaryColor: {
|
||||
type: String,
|
||||
default: '#0ea5e9'
|
||||
},
|
||||
textColor: {
|
||||
type: String,
|
||||
default: '#111827'
|
||||
},
|
||||
btnTextColor: {
|
||||
type: String,
|
||||
default: '#fff'
|
||||
}
|
||||
})
|
||||
|
||||
// 🎨 用户自定义颜色状态
|
||||
const customColors = reactive({
|
||||
primary: props.primaryColor,
|
||||
text: props.textColor,
|
||||
btnText: props.btnTextColor
|
||||
})
|
||||
|
||||
// 重置为默认值
|
||||
const resetColors = () => {
|
||||
customColors.primary = props.primaryColor
|
||||
customColors.text = props.textColor
|
||||
customColors.btnText = props.btnTextColor
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 🔧 演示参数配置 (Demo Configuration)
|
||||
// =============================================================================
|
||||
// 优先使用用户自定义的颜色,如果有变化的话
|
||||
const DEMO_CONFIG = computed(() => ({
|
||||
colors: {
|
||||
primary: customColors.primary,
|
||||
text: customColors.text,
|
||||
btnText: customColors.btnText
|
||||
}
|
||||
}))
|
||||
|
||||
const modes = [
|
||||
{ id: 'html', label: '看骨架 (HTML)' },
|
||||
@@ -126,9 +198,9 @@ const codeLines = computed(() => {
|
||||
}
|
||||
if (current.value === 'css') {
|
||||
return [
|
||||
{ key: 'h1', text: '.hero { color: #0ea5e9; font-size: 24px; }' },
|
||||
{ key: 'p', text: '.desc { color: #111827; }' },
|
||||
{ key: 'btn', text: '.cta { background: #0ea5e9; color: #fff; border-radius: 10px; }' }
|
||||
{ key: 'h1', text: `.hero { color: ${DEMO_CONFIG.value.colors.primary}; font-size: 24px; }` },
|
||||
{ key: 'p', text: `.desc { color: ${DEMO_CONFIG.value.colors.text}; }` },
|
||||
{ key: 'btn', text: `.cta { background: ${DEMO_CONFIG.value.colors.primary}; color: ${DEMO_CONFIG.value.colors.btnText}; border-radius: 10px; }` }
|
||||
]
|
||||
}
|
||||
return [
|
||||
@@ -211,6 +283,91 @@ const increment = () => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* New Config Panel Styles */
|
||||
.config-panel {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.config-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hex-input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 13px;
|
||||
width: 65px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.reset-btn:hover {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.top { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.title { font-weight: 800; font-size: 16px; }
|
||||
.subtitle { color: var(--vp-c-text-2); font-size: 13px; margin-top: 4px; }
|
||||
@@ -282,18 +439,43 @@ const increment = () => {
|
||||
|
||||
.click-tip { margin-top: 6px; color: var(--vp-c-text-2); font-size: 13px; }
|
||||
|
||||
.preview.css .hero { color: #0ea5e9; }
|
||||
.preview.css .desc { color: var(--vp-c-text-1); }
|
||||
.preview.css .cta { background: #0ea5e9; color: #fff; border-color: #0ea5e9; box-shadow: 0 4px 12px rgba(14,165,233,0.25); }
|
||||
.preview.css .hero { color: v-bind('DEMO_CONFIG.colors.primary'); font-size: 24px; }
|
||||
.preview.css .desc { color: v-bind('DEMO_CONFIG.colors.text'); }
|
||||
.preview.css .cta {
|
||||
background: v-bind('DEMO_CONFIG.colors.primary');
|
||||
color: v-bind('DEMO_CONFIG.colors.btnText');
|
||||
border-color: v-bind('DEMO_CONFIG.colors.primary');
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.preview.js .cta { background: #22c55e; color: #fff; border-color: #22c55e; box-shadow: 0 4px 12px rgba(34,197,94,0.25); }
|
||||
.preview.js { border-color: rgba(34, 197, 94, 0.4); }
|
||||
|
||||
.code-block { background: var(--vp-c-bg-alt); border: 1px solid var(--vp-c-divider); border-radius: 10px; padding: 16px; }
|
||||
.code-title { font-weight: 700; margin-bottom: 8px; font-size: 13px; color: var(--vp-c-text-2); }
|
||||
pre { margin: 0; background: #0b1221; color: #e5e7eb; border-radius: 8px; padding: 16px; font-family: var(--vp-font-family-mono); font-size: 13px; overflow-x: auto; line-height: 1.6; }
|
||||
.code-content {
|
||||
background: #0b1221;
|
||||
color: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.line { min-height: 1.6em; }
|
||||
.hl { background: rgba(34, 197, 94, 0.2); border-radius: 4px; display: block; width: 100%; }
|
||||
.hl {
|
||||
background: var(--vp-c-brand-dimm);
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-left: 8px; /* Offset text to account for border */
|
||||
font-weight: bold; /* Make text bolder */
|
||||
/* color: white; Removed to fix visibility issue on light theme if brand-dimm is light */
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); /* Add subtle shadow */
|
||||
}
|
||||
|
||||
.explain { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
|
||||
.card { background: var(--vp-c-bg); border: 1px dashed var(--vp-c-divider); border-radius: 10px; padding: 10px; }
|
||||
|
||||
Reference in New Issue
Block a user