feat(docs): add terminal introduction appendix with interactive components

Add a comprehensive terminal introduction guide with interactive Vue components demonstrating terminal concepts. Includes:
- Terminal definition and architecture visualization
- Character grid and cell inspector
- ANSI escape sequences demo
- Input visualization and signal mechanisms
- Flow diagrams and TUI examples

The components are registered in the VitePress theme and linked from the appendix section. Each component includes detailed documentation and interactive elements to help users understand terminal principles.
This commit is contained in:
sanbuphy
2026-01-14 19:04:09 +08:00
parent a9f4071308
commit c238f07e0d
14 changed files with 4248 additions and 1 deletions
@@ -0,0 +1,239 @@
<!--
AdvancedTUIDemo.vue
高级 TUI 布局演示组件
用途
展示复杂的终端用户界面Text User Interface是如何构建的
说明如何利用备用缓冲区Alternate Buffer和全屏绘制技术来实现类似 vim/htop 的界面
交互功能
- 布局展示模拟一个包含侧边栏主内容区和状态栏的 TUI 应用
- 动态更新演示界面元素如何响应窗口大小变化或用户操作
-->
<template>
<div class="advanced-tui">
<div class="tui-window">
<div class="tui-header">
<div class="tabs">
<div class="tab active">Continuous</div>
<div class="tab">Integration</div>
<div class="tab">Logging</div>
</div>
</div>
<div class="tui-body">
<div class="sidebar" :style="{ width: sidebarWidth + '%' }">
<div class="list-item success">
<span class="icon"></span> ci-fe-be-rules
</div>
<div class="list-item warning">
<span class="icon"></span> ci-api-test
</div>
<div class="list-item success active">
<span class="icon"></span> ci-email-service
</div>
<div class="list-item error">
<span class="icon"></span> ci-auth-core
</div>
<div class="list-item pending">
<span class="icon"></span> ci-db-migration
</div>
</div>
<div class="main-content">
<div class="content-header">
<h3>ci-email-service</h3>
</div>
<div class="content-body">
<div class="status-row">Status: <span class="text-success">success</span></div>
<div class="info-row">Updated: 2024-01-15 14:32:00 UTC</div>
</div>
</div>
</div>
</div>
<div class="tui-controls">
<div class="control-group">
<button @click="toggleCoordinates" :class="{ active: showCoordinates }">
Show Coordinates
</button>
</div>
<div class="control-group">
<button @click="simulateResize">Simulate Resize</button>
<span class="size-label">Size: {{ sizeDisplay }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const showCoordinates = ref(false)
const sidebarWidth = ref(30)
const sizeDisplay = ref('60x20')
const toggleCoordinates = () => {
showCoordinates.value = !showCoordinates.value
}
const simulateResize = () => {
// Simple animation to show resizing concept
const originalWidth = sidebarWidth.value
sidebarWidth.value = 20
sizeDisplay.value = '40x20'
setTimeout(() => {
sidebarWidth.value = 40
sizeDisplay.value = '80x20'
}, 500)
setTimeout(() => {
sidebarWidth.value = originalWidth
sizeDisplay.value = '60x20'
}, 1000)
}
</script>
<style scoped>
.advanced-tui {
background: #0a0a0a;
padding: 20px;
border-radius: 8px;
border: 1px solid #333;
font-family: 'Menlo', monospace;
}
.tui-window {
border: 1px solid #333;
border-radius: 4px;
background: #000;
overflow: hidden;
height: 300px;
display: flex;
flex-direction: column;
}
.tui-header {
border-bottom: 1px solid #333;
background: #1a1a1a;
}
.tabs {
display: flex;
}
.tab {
padding: 8px 16px;
color: #666;
cursor: pointer;
border-right: 1px solid #333;
font-size: 13px;
}
.tab.active {
background: #22c55e;
color: #000;
font-weight: bold;
}
.tui-body {
display: flex;
flex: 1;
}
.sidebar {
border-right: 1px solid #333;
padding: 10px 0;
transition: width 0.3s;
}
.list-item {
padding: 5px 15px;
color: #ccc;
font-size: 13px;
cursor: pointer;
}
.list-item:hover {
background: #222;
}
.list-item.active {
background: #222;
border-left: 2px solid #22c55e;
}
.icon {
display: inline-block;
width: 16px;
}
.success .icon { color: #22c55e; }
.warning .icon { color: #eab308; }
.error .icon { color: #ef4444; }
.pending .icon { color: #666; }
.main-content {
flex: 1;
padding: 20px;
}
.content-header h3 {
margin: 0 0 15px 0;
color: #fff;
font-size: 16px;
}
.status-row {
margin-bottom: 5px;
color: #aaa;
}
.text-success { color: #22c55e; }
.info-row {
color: #666;
font-size: 12px;
}
.tui-controls {
margin-top: 20px;
display: flex;
gap: 20px;
padding-top: 20px;
border-top: 1px solid #222;
}
.control-group {
display: flex;
align-items: center;
gap: 10px;
}
button {
background: #111;
border: 1px solid #333;
color: #ccc;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
}
button:hover {
background: #222;
}
button.active {
background: #222;
border-color: #666;
color: #fff;
}
.size-label {
color: #666;
font-size: 13px;
}
</style>
@@ -0,0 +1,654 @@
<!--
ArchitectureDemo.vue
终端架构演示组件
用途
可视化展示 Terminal终端Shell Kernel内核之间的交互流程
通过模拟 "ls" 命令的执行过程帮助用户理解输入传输解析系统调用数据返回和渲染显示的完整链路
交互功能
- 逐步演示 (Step-by-Step)用户点击按钮一步步观察数据包流转
- 中英双语说明适应不同语言背景的读者
- 状态反馈实时显示各组件终端/Shell/内核的当前状态空闲/忙碌
-->
<template>
<div class="arch-demo">
<div class="analogy-header">
<div class="analogy-item">
<div class="icon">🍽</div>
<div class="text">
<div class="role">Terminal = Table (餐桌)</div>
<div class="desc">UI & Input/Output<br>交互界面与输入输出</div>
</div>
</div>
<div class="analogy-item">
<div class="icon">💁</div>
<div class="text">
<div class="role">Shell = Waiter (服务员)</div>
<div class="desc">Interpreter & Logic<br>解释器与逻辑处理</div>
</div>
</div>
<div class="analogy-item">
<div class="icon">👨🍳</div>
<div class="text">
<div class="role">Kernel = Kitchen (后厨)</div>
<div class="desc">System Execution<br>系统执行与硬件调度</div>
</div>
</div>
</div>
<div class="diagram-container">
<!-- Terminal Node -->
<div class="node terminal" :class="{ active: activeNode === 'terminal' }">
<div class="node-title">TERMINAL (终端)</div>
<div class="screen">
<div v-for="(line, i) in terminalLines" :key="i" class="line">{{ line }}</div>
<div class="line input-line">
<span class="prompt">$</span>
<span class="typing">{{ currentInput }}</span>
<span class="cursor" v-if="activeNode === 'terminal'"></span>
</div>
</div>
<div class="node-label">/dev/tty</div>
</div>
<!-- Connections -->
<div class="connection t-s" :class="{ active: packetState === 't-to-s' || packetState === 's-to-t' }">
<div class="line-path"></div>
<div class="data-label" v-if="packetState === 't-to-s'">
<span class="icon"></span> Bytes
</div>
<div class="data-label" v-if="packetState === 's-to-t'">
<span class="icon"></span> Text
</div>
<div class="conn-label">stdin / stdout</div>
</div>
<!-- Shell Node -->
<div class="node shell" :class="{ active: activeNode === 'shell' }">
<div class="node-title">SHELL ()</div>
<div class="process-box">
<div class="status-icon">{{ shellIcon }}</div>
<div class="status">{{ shellStatus }}</div>
</div>
<div class="node-label">/bin/zsh</div>
</div>
<!-- Connections -->
<div class="connection s-k" :class="{ active: packetState === 's-to-k' || packetState === 'k-to-s' }">
<div class="line-path"></div>
<div class="data-label" v-if="packetState === 's-to-k'">
<span class="icon"></span> Syscall
</div>
<div class="data-label" v-if="packetState === 'k-to-s'">
<span class="icon"></span> Raw Data
</div>
<div class="conn-label">System Calls</div>
</div>
<!-- Kernel Node -->
<div class="node kernel" :class="{ active: activeNode === 'kernel' }">
<div class="node-title">KERNEL (内核)</div>
<div class="process-box">
<div class="status-icon">{{ kernelIcon }}</div>
<div class="status">{{ kernelStatus }}</div>
</div>
<div class="node-label">macOS / Linux</div>
</div>
</div>
<div class="controls">
<div class="btn-group">
<button class="btn primary" @click="nextStep" :disabled="currentStep >= totalSteps">
<span v-if="currentStep === 0"> Start Simulation / 开始演示</span>
<span v-else-if="currentStep < totalSteps">Next Step / 下一步 ({{ currentStep }}/{{ totalSteps }}) </span>
<span v-else> Done / 完成 (Reset)</span>
</button>
<button class="btn secondary" @click="reset" v-if="currentStep > 0">
Reset / 重置
</button>
</div>
<div class="step-info" v-if="currentStep > 0">
<div class="step-title">
{{ steps[currentStep - 1].titleEn }}
<span class="divider">|</span>
{{ steps[currentStep - 1].titleZh }}
</div>
<div class="step-desc">
<div class="en">{{ steps[currentStep - 1].descEn }}</div>
<div class="zh">{{ steps[currentStep - 1].descZh }}</div>
</div>
<div class="step-tech">
<span class="tech-label">Technical / 技术原理:</span>
<div class="tech-content">
<div class="en">{{ steps[currentStep - 1].techEn }}</div>
<div class="zh">{{ steps[currentStep - 1].techZh }}</div>
</div>
</div>
</div>
<div class="step-info placeholder" v-else>
<div class="step-desc">
<div class="en">Click "Start Simulation" to see how the command 'ls' travels through the system.</div>
<div class="zh">点击开始演示查看 'ls' 命令如何在系统中流转</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const currentStep = ref(0)
const activeNode = ref('terminal')
const packetState = ref(null)
const terminalLines = ref([])
const currentInput = ref('')
const shellStatus = ref('Idle')
const shellIcon = ref('💤')
const kernelStatus = ref('Idle')
const kernelIcon = ref('💤')
const steps = [
{
titleEn: "1. User Input",
titleZh: "1. 用户输入",
descEn: "You type 'ls' in the terminal window. The terminal captures your keystrokes.",
descZh: "你在终端窗口输入 'ls'。终端会捕获你的按键操作。",
techEn: "Terminal buffers input in 'Cooked Mode' until you press Enter.",
techZh: "终端在“加工模式 (Cooked Mode)”下缓冲输入,直到你按下回车键。",
action: async () => {
activeNode.value = 'terminal'
currentInput.value = 'l'
await wait(200)
currentInput.value = 'ls'
}
},
{
titleEn: "2. Transmission",
titleZh: "2. 传输",
descEn: "The Terminal sends the characters 'l', 's', and 'Enter' to the Shell.",
descZh: "终端将字符 'l'、's' 和 '回车' 发送给 Shell。",
techEn: "Data travels via standard input (stdin) as a byte stream.",
techZh: "数据通过标准输入 (stdin) 以字节流的形式传输。",
action: async () => {
packetState.value = 't-to-s'
await wait(1000)
packetState.value = null
}
},
{
titleEn: "3. Shell Parsing",
titleZh: "3. Shell 解析",
descEn: "The Shell receives the characters and figures out what you want.",
descZh: "Shell 接收到字符,并解析你的意图。",
techEn: "Shell tokenizes input, finds the 'ls' executable in $PATH.",
techZh: "Shell 对输入进行分词,并在 $PATH 环境变量中查找 'ls' 可执行文件。",
action: async () => {
activeNode.value = 'shell'
shellIcon.value = '🧠'
shellStatus.value = 'Parsing "ls"...'
}
},
{
titleEn: "4. System Call",
titleZh: "4. 系统调用",
descEn: "The Shell asks the Kernel to read the file list from the disk.",
descZh: "Shell 请求内核从磁盘读取文件列表。",
techEn: "Shell invokes `execve()` and `getdents()` system calls.",
techZh: "Shell 调用 `execve()` 和 `getdents()` 等系统调用。",
action: async () => {
packetState.value = 's-to-k'
await wait(1000)
packetState.value = null
}
},
{
titleEn: "5. Kernel Execution",
titleZh: "5. 内核执行",
descEn: "The Kernel (the boss) talks to the hardware to get the actual data.",
descZh: "内核(大管家)与硬件通信以获取实际数据。",
techEn: "Kernel driver accesses the file system (APFS/ext4).",
techZh: "内核驱动程序访问文件系统 (APFS/ext4)。",
action: async () => {
activeNode.value = 'kernel'
kernelIcon.value = '💾'
kernelStatus.value = 'Reading Disk...'
await wait(800)
kernelStatus.value = 'Data Found'
}
},
{
titleEn: "6. Returning Data",
titleZh: "6. 返回数据",
descEn: "The Kernel gives the raw file list back to the Shell.",
descZh: "内核将原始文件列表数据返回给 Shell。",
techEn: "System call returns with file descriptors/structs.",
techZh: "系统调用返回文件描述符或结构体数据。",
action: async () => {
kernelStatus.value = 'Idle'
kernelIcon.value = '💤'
packetState.value = 'k-to-s'
await wait(1000)
packetState.value = null
}
},
{
titleEn: "7. Formatting",
titleZh: "7. 格式化",
descEn: "The Shell formats the raw list into text, adding colors if needed.",
descZh: "Shell 将原始列表格式化为文本,并根据需要添加颜色。",
techEn: "Shell formats output buffer, adding ANSI color codes.",
techZh: "Shell 格式化输出缓冲区,并添加 ANSI 颜色代码。",
action: async () => {
activeNode.value = 'shell'
shellIcon.value = '🎨'
shellStatus.value = 'Formatting...'
await wait(500)
}
},
{
titleEn: "8. Display Output",
titleZh: "8. 显示输出",
descEn: "The Shell sends the final text back to the Terminal to show you.",
descZh: "Shell 将最终文本发送回终端以供显示。",
techEn: "Data travels via standard output (stdout) to the TTY.",
techZh: "数据通过标准输出 (stdout) 传输到 TTY。",
action: async () => {
shellStatus.value = 'Idle'
shellIcon.value = '💤'
packetState.value = 's-to-t'
await wait(1000)
packetState.value = null
}
},
{
titleEn: "9. Render",
titleZh: "9. 渲染",
descEn: "The Terminal draws the text on the screen grid.",
descZh: "终端在屏幕网格上绘制文本。",
techEn: "Terminal emulator renders glyphs into the frame buffer.",
techZh: "终端模拟器将字形渲染到帧缓冲区中。",
action: async () => {
activeNode.value = 'terminal'
terminalLines.value = ['file1.txt photo.jpg', 'notes.md']
currentInput.value = ''
}
}
]
const totalSteps = steps.length
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
const nextStep = async () => {
if (currentStep.value >= totalSteps) {
reset()
return
}
const step = steps[currentStep.value]
currentStep.value++
await step.action()
}
const reset = () => {
currentStep.value = 0
activeNode.value = 'terminal'
packetState.value = null
terminalLines.value = []
currentInput.value = ''
shellStatus.value = 'Idle'
shellIcon.value = '💤'
kernelStatus.value = 'Idle'
kernelIcon.value = '💤'
}
</script>
<style scoped>
.arch-demo {
background: #09090b;
padding: 30px;
border-radius: 12px;
border: 1px solid #27272a;
font-family: 'JetBrains Mono', 'Menlo', monospace;
color: #e4e4e7;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 30px;
}
.analogy-header {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
border-bottom: 1px solid #27272a;
padding-bottom: 20px;
}
.analogy-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
}
.analogy-item .icon {
font-size: 24px;
background: #18181b;
padding: 8px;
border-radius: 50%;
border: 1px solid #27272a;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.analogy-item .role {
font-weight: bold;
color: #22d3ee;
font-size: 13px;
}
.analogy-item .desc {
font-size: 11px;
color: #a1a1aa;
}
.diagram-container {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
padding: 0 10px;
}
.node {
background: #18181b;
border: 2px solid #27272a;
border-radius: 8px;
width: 140px;
height: 130px;
display: flex;
flex-direction: column;
transition: all 0.3s;
z-index: 2;
position: relative;
}
.node.active {
border-color: #22c55e;
box-shadow: 0 0 15px rgba(34, 197, 94, 0.2);
transform: translateY(-2px);
}
.node-title {
background: #27272a;
color: #a1a1aa;
font-size: 10px;
padding: 6px 0;
text-align: center;
font-weight: bold;
letter-spacing: 1px;
border-radius: 6px 6px 0 0;
}
.node-label {
position: absolute;
bottom: -20px;
left: 0;
right: 0;
text-align: center;
font-size: 10px;
color: #71717a;
}
.screen, .process-box {
flex: 1;
padding: 10px;
display: flex;
flex-direction: column;
font-size: 12px;
}
.process-box {
align-items: center;
justify-content: center;
gap: 8px;
}
.status-icon {
font-size: 24px;
}
.screen {
background: #000;
justify-content: flex-start;
font-family: monospace;
overflow: hidden;
}
.line {
height: 16px;
white-space: nowrap;
overflow: hidden;
}
.input-line {
display: flex;
align-items: center;
}
.prompt {
color: #22c55e;
margin-right: 4px;
}
.cursor {
width: 6px;
height: 12px;
background: #e4e4e7;
animation: blink 1s step-end infinite;
}
.status {
text-align: center;
color: #facc15;
font-size: 11px;
}
.connection {
flex: 1;
height: 2px;
background: #27272a;
position: relative;
margin: 0 15px;
transition: all 0.3s;
}
.connection.active {
background: #22c55e;
box-shadow: 0 0 10px rgba(34, 197, 94, 0.4);
}
.conn-label {
position: absolute;
top: 10px;
left: 0;
right: 0;
text-align: center;
font-size: 10px;
color: #52525b;
}
.data-label {
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
background: #22c55e;
color: #000;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
z-index: 10;
animation: pop-in 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes pop-in {
from { opacity: 0; transform: translate(-50%, 5px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes blink {
50% { opacity: 0; }
}
.controls {
display: flex;
flex-direction: column;
gap: 20px;
background: #18181b;
padding: 20px;
border-radius: 8px;
border: 1px solid #27272a;
}
.btn-group {
display: flex;
gap: 10px;
justify-content: center;
}
.btn {
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: all 0.2s;
border: 1px solid transparent;
}
.btn.primary {
background: #22c55e;
color: #000;
}
.btn.primary:hover:not(:disabled) {
background: #16a34a;
}
.btn.primary:disabled {
background: #27272a;
color: #71717a;
cursor: not-allowed;
}
.btn.secondary {
background: transparent;
border-color: #3f3f46;
color: #a1a1aa;
}
.btn.secondary:hover {
border-color: #71717a;
color: #e4e4e7;
}
.step-info {
display: flex;
flex-direction: column;
gap: 8px;
text-align: center;
animation: fade-in 0.3s ease;
}
.step-title {
font-size: 16px;
font-weight: bold;
color: #22d3ee;
}
.step-desc {
font-size: 14px;
color: #e4e4e7;
}
.step-tech {
font-size: 12px;
color: #71717a;
background: #09090b;
padding: 8px;
border-radius: 4px;
display: inline-block;
margin: 0 auto;
}
.tech-label {
color: #facc15;
font-weight: bold;
margin-right: 4px;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 640px) {
.analogy-header {
grid-template-columns: 1fr;
}
.diagram-container {
flex-direction: column;
gap: 50px;
padding: 20px 0;
}
.connection {
width: 2px;
height: 50px;
margin: 0;
}
.conn-label {
top: 50%;
left: 10px;
right: auto;
transform: translateY(-50%);
text-align: left;
white-space: nowrap;
}
.packet {
top: 0;
left: 10px;
animation: travel-vertical 1s linear forwards;
}
.packet.reverse {
animation: travel-vertical-back 1s linear forwards;
}
@keyframes travel-vertical {
0% { top: 0; transform: translateY(0); }
100% { top: 100%; transform: translateY(-100%); }
}
@keyframes travel-vertical-back {
0% { top: 100%; transform: translateY(-100%); }
100% { top: 0; transform: translateY(0); }
}
}
</style>
@@ -0,0 +1,239 @@
<!--
CellInspector.vue
单元格检查器组件
用途
深入展示单个终端单元格Cell的内部结构
说明一个单元格不仅仅包含字符还包含前景色背景色加粗下划线等属性
交互功能
- 属性切换用户可以修改字符颜色和样式
- 实时预览左侧大图实时反映右侧属性的修改结果
-->
<template>
<div class="cell-inspector">
<div class="preview-area">
<div class="large-cell" :style="cellStyle">
{{ char }}
</div>
</div>
<div class="controls-area">
<div class="control-group">
<label>CHARACTER</label>
<div class="char-buttons">
<button
v-for="c in chars"
:key="c"
:class="{ active: char === c }"
@click="char = c"
>{{ c }}</button>
</div>
</div>
<div class="control-group">
<label>FOREGROUND</label>
<div class="color-palette">
<div
v-for="color in colors"
:key="color"
class="color-swatch"
:style="{ backgroundColor: color }"
:class="{ active: fgColor === color }"
@click="fgColor = color"
></div>
</div>
</div>
<div class="control-group">
<label>BACKGROUND</label>
<div class="color-palette">
<div
class="color-swatch"
:class="{ active: bgColor === 'transparent' }"
style="background: linear-gradient(45deg, #222 25%, transparent 25%), linear-gradient(-45deg, #222 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #222 75%), linear-gradient(-45deg, transparent 75%, #222 75%); background-size: 10px 10px; background-color: #111;"
@click="bgColor = 'transparent'"
></div>
<div
v-for="color in bgColors"
:key="color"
class="color-swatch"
:style="{ backgroundColor: color }"
:class="{ active: bgColor === color }"
@click="bgColor = color"
></div>
</div>
</div>
<div class="control-group">
<label>ATTRIBUTES</label>
<div class="toggles">
<label class="toggle">
<input type="checkbox" v-model="isBold">
<span>Bold</span>
</label>
<label class="toggle">
<input type="checkbox" v-model="isUnderline">
<span>Underline</span>
</label>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const chars = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P']
const colors = [
'#ef4444', '#22c55e', '#eab308', '#3b82f6', '#a855f7', '#06b6d4', '#f3f4f6', '#6b7280',
'#f87171', '#4ade80', '#facc15', '#60a5fa', '#c084fc', '#22d3ee', '#ffffff'
]
const bgColors = [
'#000000', '#1f2937', '#111827', '#374151', '#1e3a8a', '#3f2c08', '#310b0b'
]
const char = ref('A')
const fgColor = ref('#22c55e')
const bgColor = ref('transparent')
const isBold = ref(false)
const isUnderline = ref(false)
const cellStyle = computed(() => ({
color: fgColor.value,
backgroundColor: bgColor.value,
fontWeight: isBold.value ? 'bold' : 'normal',
textDecoration: isUnderline.value ? 'underline' : 'none'
}))
</script>
<style scoped>
.cell-inspector {
display: grid;
grid-template-columns: 200px 1fr;
gap: 40px;
background: #09090b;
padding: 30px;
border-radius: 12px;
border: 1px solid #27272a;
font-family: 'JetBrains Mono', 'Menlo', monospace;
}
.preview-area {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #27272a;
border-radius: 8px;
background: #000;
aspect-ratio: 3/4;
}
.large-cell {
font-size: 120px;
line-height: 1;
width: 140px;
height: 180px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.controls-area {
display: flex;
flex-direction: column;
gap: 24px;
}
.control-group label {
display: block;
color: #a1a1aa; /* Zinc 400 */
font-size: 12px;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.char-buttons {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
}
.char-buttons button {
background: #18181b;
border: 1px solid #27272a;
color: #a1a1aa;
padding: 8px 0;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.char-buttons button:hover {
border-color: #52525b;
color: #fff;
background: #27272a;
}
.char-buttons button.active {
background: #fff;
color: #000;
border-color: #fff;
font-weight: bold;
}
.color-palette {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.color-swatch {
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
border: 2px solid transparent;
transition: transform 0.1s;
}
.color-swatch:hover {
transform: scale(1.1);
}
.color-swatch.active {
border-color: #fff;
transform: scale(1.1);
box-shadow: 0 0 10px rgba(0,0,0,0.5);
}
.toggles {
display: flex;
gap: 20px;
}
.toggle {
display: flex;
align-items: center;
gap: 8px;
color: #e4e4e7;
cursor: pointer;
user-select: none;
}
.toggle input {
width: 16px;
height: 16px;
accent-color: #22c55e;
}
@media (max-width: 640px) {
.cell-inspector {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,418 @@
<!--
EscapeSequences.vue
转义序列演示组件
用途
解释终端如何通过不可见字符来控制颜色光标位置和清屏操作
揭示 ANSI 转义序列 `\033[31m`的工作原理
交互功能
- 颜色/样式按钮点击后发送对应的转义序列
- 序列显示实时显示当前发送的原始序列代码 `^[[31m`
- 终端反馈下方模拟终端根据接收到的序列改变文字颜色或清除内容
-->
<template>
<div class="escape-demo">
<div class="controls">
<div class="panel-section">
<div class="section-title">
<span class="en">16-COLOR PALETTE</span>
<span class="divider">|</span>
<span class="zh">16 色调色板</span>
</div>
<div class="palette-grid">
<div
v-for="(color, index) in palette"
:key="index"
class="swatch"
:style="{ backgroundColor: color }"
@click="applyColor(index)"
:title="`^[[38;5;${index}m`"
></div>
</div>
<div class="hint-text" v-if="activeColor">
Sequence: <span class="code">^[[38;5;{{ palette.indexOf(activeColor) }}m</span>
</div>
</div>
<div class="panel-section">
<div class="section-title">
<span class="en">STYLE SEQUENCES</span>
<span class="divider">|</span>
<span class="zh">样式序列</span>
</div>
<div class="btn-group">
<button @click="applyStyle('1')" :class="{ active: isBold }">
<span class="btn-code">^[[1m</span>
<span class="btn-label">Bold / 加粗</span>
</button>
<button @click="applyStyle('4')" :class="{ active: isUnderline }">
<span class="btn-code">^[[4m</span>
<span class="btn-label">Underline / 下划线</span>
</button>
</div>
<div class="btn-group" style="margin-top: 8px">
<button @click="resetStyle" class="reset-btn">
<span class="btn-code">^[[0m</span>
<span class="btn-label">Reset / 重置所有样式</span>
</button>
</div>
</div>
<div class="panel-section">
<div class="section-title">
<span class="en">CURSOR SEQUENCES</span>
<span class="divider">|</span>
<span class="zh">光标控制序列</span>
</div>
<div class="btn-stack">
<button @click="clearScreen">
<span class="code">^[[2J</span>
<span class="desc">Clear Screen / 清屏</span>
</button>
<button @click="moveHome">
<span class="code">^[[H</span>
<span class="desc">Move Home / 回到原点 (0,0)</span>
</button>
<button @click="moveTo">
<span class="code">^[[5;10H</span>
<span class="desc">Move to 5,10 / 移动到 (5,10)</span>
</button>
</div>
</div>
</div>
<div class="preview">
<div class="terminal-window">
<div class="window-header">
<div class="dots">
<span></span><span></span><span></span>
</div>
<div class="window-title">Terminal Preview</div>
</div>
<div class="window-content">
<div class="sequence-display-area">
<span class="label">Last Sequence:</span>
<span v-if="lastSequence" class="sequence-code">{{ lastSequence }}</span>
<span v-else class="placeholder">Waiting for input...</span>
</div>
<div class="main-display" :style="currentStyle" v-if="isContentVisible">
Hello World
</div>
<div class="cursor-line">
<span class="prompt">$</span>
<span class="cursor-placeholder" v-if="cursorMode === 'absolute'"></span>
<span class="cursor-block" :style="cursorStyle"></span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const palette = [
'#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5',
'#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#ffffff'
]
const activeColor = ref(null)
const isBold = ref(false)
const isUnderline = ref(false)
const lastSequence = ref('')
const isContentVisible = ref(true)
const cursorMode = ref('static') // 'static' | 'absolute'
const cursorPosition = ref({ top: 0, left: 0 })
const currentStyle = computed(() => ({
color: activeColor.value || '#ccc',
fontWeight: isBold.value ? 'bold' : 'normal',
textDecoration: isUnderline.value ? 'underline' : 'none'
}))
const cursorStyle = computed(() => {
if (cursorMode.value === 'static') {
return {}
}
return {
position: 'absolute',
top: `${cursorPosition.value.top}px`,
left: `${cursorPosition.value.left}px`
}
})
const applyColor = (index) => {
activeColor.value = palette[index]
lastSequence.value = `^[[38;5;${index}m`
}
const applyStyle = (code) => {
if (code === '1') isBold.value = !isBold.value
if (code === '4') isUnderline.value = !isUnderline.value
lastSequence.value = `^[[${code}m`
}
const resetStyle = () => {
activeColor.value = null
isBold.value = false
isUnderline.value = false
lastSequence.value = '^[[0m'
isContentVisible.value = true
cursorMode.value = 'static'
}
const clearScreen = () => {
lastSequence.value = '^[[2J'
isContentVisible.value = false
}
const moveHome = () => {
lastSequence.value = '^[[H'
cursorMode.value = 'absolute'
cursorPosition.value = { top: 20, left: 20 }
}
const moveTo = () => {
lastSequence.value = '^[[5;10H'
cursorMode.value = 'absolute'
// Approximate position for 5,10 (5th line, 10th char)
// Assuming line height ~24px, char width ~9px
// Base padding 20px
cursorPosition.value = { top: 20 + 4 * 24, left: 20 + 10 * 9 }
}
</script>
<style scoped>
.escape-demo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
background: #09090b;
padding: 30px;
border-radius: 12px;
font-family: 'JetBrains Mono', 'Menlo', monospace;
border: 1px solid #27272a;
}
.panel-section {
margin-bottom: 24px;
}
.section-title {
color: #a1a1aa;
font-size: 12px;
margin-bottom: 12px;
font-weight: 600;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title .divider {
color: #3f3f46;
font-weight: normal;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
margin-bottom: 8px;
}
.swatch {
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
border: 1px solid #27272a;
transition: transform 0.1s;
}
.swatch:hover {
transform: scale(1.1);
border-color: #fff;
z-index: 1;
}
.hint-text {
font-size: 11px;
color: #71717a;
margin-top: 8px;
}
button {
background: #18181b;
border: 1px solid #27272a;
color: #e4e4e7;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
text-align: left;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
button:hover {
background: #27272a;
border-color: #52525b;
}
button.active {
background: #27272a;
border-color: #22c55e;
color: #22c55e;
}
.btn-code {
color: #facc15;
font-weight: bold;
min-width: 50px;
}
.btn-label {
color: #a1a1aa;
}
.btn-group {
display: flex;
gap: 10px;
}
.reset-btn {
width: 100%;
}
.btn-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.btn-stack button {
display: flex;
justify-content: space-between;
}
.code { color: #facc15; font-weight: bold; }
.desc { color: #a1a1aa; font-size: 12px; }
.terminal-window {
background: #000;
border: 1px solid #27272a;
border-radius: 8px;
height: 320px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.window-header {
padding: 10px 15px;
border-bottom: 1px solid #27272a;
background: #18181b;
display: flex;
align-items: center;
justify-content: space-between;
}
.dots span {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: #3f3f46;
margin-right: 6px;
}
.window-title {
color: #71717a;
font-size: 11px;
}
.window-content {
padding: 20px;
flex: 1;
display: flex;
flex-direction: column;
color: #e4e4e7;
}
.sequence-display-area {
margin-bottom: 40px;
font-size: 13px;
display: flex;
gap: 8px;
align-items: center;
}
.sequence-display-area .label {
color: #71717a;
}
.sequence-code {
color: #22d3ee;
font-family: monospace;
background: #18181b;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #27272a;
}
.placeholder {
color: #3f3f46;
font-style: italic;
}
.main-display {
font-size: 32px;
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.cursor-line {
display: flex;
align-items: center;
gap: 8px;
margin-top: auto;
border: 1px solid #27272a;
padding: 10px;
background: #09090b;
border-radius: 4px;
}
.prompt {
color: #22c55e;
}
.cursor-block {
width: 8px;
height: 16px;
background: #e4e4e7;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
@media (max-width: 768px) {
.escape-demo {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,313 @@
<!--
FlowDiagram.vue
输入输出流程图组件
用途
可视化展示一次按键从物理键盘到屏幕显示的完整往返旅程 (Round Trip)
将复杂的系统流程键盘 -> 操作系统 -> 终端 -> 程序 -> 终端 -> 屏幕抽象为清晰的图表
交互功能
- 静态展示清晰的 SVG CSS 流程图
- 节点说明鼠标悬停可查看每个环节的具体解释
-->
<template>
<div class="flow-diagram">
<div class="stack-col">
<div class="stack-label">TERMINAL STACK</div>
<div class="stack-box kbd" :class="{ active: activeStage === 'kbd' }">
<div class="box-header">
<span class="box-icon">[kbd]</span>
<span class="box-title">You (Keyboard)</span>
</div>
<div class="box-desc">Physical keystrokes</div>
</div>
<div class="arrow"> / </div>
<div class="stack-box tty" :class="{ active: activeStage === 'tty' }">
<div class="box-header">
<span class="box-icon">[tty]</span>
<span class="box-title">Terminal Emulator</span>
</div>
<div class="box-desc">Encodes input, renders output</div>
</div>
<div class="arrow"> / </div>
<div class="stack-box pty" :class="{ active: activeStage === 'pty' }">
<div class="box-header">
<span class="box-icon">[pty]</span>
<span class="box-title">PTY (Pseudo-Terminal)</span>
</div>
<div class="box-desc">Bidirectional pipe</div>
</div>
<div class="arrow"> / </div>
<div class="stack-box sh" :class="{ active: activeStage === 'sh' }">
<div class="box-header">
<span class="box-icon">[sh]</span>
<span class="box-title">Shell / Program</span>
</div>
<div class="box-desc">bash, zsh, or any CLI program</div>
</div>
</div>
<div class="output-col">
<div class="output-label">OUTPUT</div>
<div class="terminal-preview">
<div class="term-header">
<span></span><span></span><span></span>
</div>
<div class="term-body">
<span class="prompt">$ </span>
<span class="typed-text">{{ displayText }}</span>
<span class="cursor" :class="{ blinking: !isAnimating }"></span>
</div>
</div>
<div class="status-box">
<div class="status-title" :class="statusColor">{{ statusTitle }}</div>
<div class="status-desc">{{ statusDesc }}</div>
</div>
<div class="controls">
<button class="play-btn" @click="startAnimation" :disabled="isAnimating">
{{ isAnimating ? 'Simulating...' : 'Simulate Keystroke' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const activeStage = ref(null)
const isAnimating = ref(false)
const displayText = ref('')
const statusTitle = ref('Ready')
const statusDesc = ref('The terminal is waiting. The cursor blinks.')
const statusColor = computed(() => {
if (statusTitle.value === 'Ready') return 'text-red'
return 'text-green'
})
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
const startAnimation = async () => {
if (isAnimating.value) return
isAnimating.value = true
displayText.value = ''
// Stage 1: Keyboard
activeStage.value = 'kbd'
statusTitle.value = 'Input'
statusDesc.value = 'Key "l" pressed. Physical event generated.'
await sleep(800)
// Stage 2: Terminal Emulator
activeStage.value = 'tty'
statusDesc.value = 'Terminal encodes key to byte 0x6C.'
await sleep(800)
// Stage 3: PTY
activeStage.value = 'pty'
statusDesc.value = 'Bytes travel through the pseudo-terminal pipe.'
await sleep(800)
// Stage 4: Shell
activeStage.value = 'sh'
statusTitle.value = 'Processing'
statusDesc.value = 'Shell receives 0x6C, decides to echo it back.'
await sleep(800)
// Return Trip
// Stage 3: PTY
activeStage.value = 'pty'
statusTitle.value = 'Output'
statusDesc.value = 'Shell sends 0x6C back through PTY.'
await sleep(600)
// Stage 2: Terminal Emulator
activeStage.value = 'tty'
statusDesc.value = 'Terminal receives 0x6C, renders "l" character.'
displayText.value = 'l'
await sleep(600)
// Finish
activeStage.value = null
statusTitle.value = 'Ready'
statusDesc.value = 'The terminal is waiting. The cursor blinks.'
isAnimating.value = false
}
</script>
<style scoped>
.flow-diagram {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
background: #0a0a0a;
padding: 30px;
border-radius: 8px;
border: 1px solid #333;
font-family: 'Menlo', monospace;
color: #ccc;
}
.stack-col, .output-col {
display: flex;
flex-direction: column;
}
.stack-label, .output-label {
color: #eab308;
font-size: 12px;
margin-bottom: 20px;
text-transform: uppercase;
}
.stack-box {
background: #111;
border: 1px solid #333;
padding: 15px;
border-radius: 4px;
transition: all 0.3s;
opacity: 0.5;
}
.stack-box.active {
opacity: 1;
border-color: #22c55e;
background: #1a1a1a;
box-shadow: 0 0 10px rgba(34, 197, 94, 0.2);
}
.box-header {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.box-icon {
color: #666;
margin-right: 10px;
font-size: 12px;
}
.box-title {
font-weight: bold;
color: #fff;
}
.box-desc {
color: #666;
font-size: 12px;
margin-left: 40px;
}
.arrow {
text-align: center;
color: #444;
margin: 10px 0;
font-size: 12px;
}
.terminal-preview {
background: #000;
border: 1px solid #333;
border-radius: 6px;
height: 200px;
margin-bottom: 20px;
}
.term-header {
padding: 8px;
border-bottom: 1px solid #222;
}
.term-header span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #333;
margin-right: 5px;
}
.term-body {
padding: 15px;
font-size: 16px;
color: #fff;
}
.prompt { color: #888; }
.typed-text { color: #22c55e; }
.cursor {
display: inline-block;
width: 8px;
height: 16px;
background: #22c55e;
vertical-align: middle;
margin-left: 2px;
}
.cursor.blinking {
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.status-box {
background: #111;
padding: 15px;
border-radius: 4px;
border: 1px solid #333;
margin-bottom: 20px;
}
.status-title {
font-size: 16px;
margin-bottom: 5px;
font-weight: bold;
}
.status-desc {
color: #888;
font-size: 13px;
line-height: 1.5;
}
.text-red { color: #ef4444; }
.text-green { color: #22c55e; }
.play-btn {
width: 100%;
padding: 12px;
background: #22c55e;
border: none;
border-radius: 4px;
color: #000;
font-weight: bold;
cursor: pointer;
transition: opacity 0.2s;
}
.play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.flow-diagram {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,292 @@
<!--
InputVisualizer.vue
输入可视化组件
用途
展示键盘输入在底层是如何被转换为字节流发送给终端的
纠正按键直接上屏的误区强调按键 -> 编码 -> 发送的过程
交互功能
- 键盘监听捕获用户的真实按键
- 数据展示同时显示按键名16进制字节码和转义序列如方向键
- 历史记录记录最近几次按键的编码流
-->
<template>
<div class="input-visualizer" tabindex="0" @keydown="handleKeydown" @blur="handleBlur">
<div class="focus-overlay" v-if="!isFocused" @click="focus">
<div class="focus-btn">
<span class="icon"></span>
<span>Click to Type</span>
</div>
</div>
<div class="main-display" :class="{ 'blur-content': !isFocused }">
<div class="key-name">{{ currentKey.name || 'Press any key' }}</div>
<div class="info-grid">
<div class="info-box">
<div class="label">BYTES (HEX)</div>
<div class="value highlight">{{ currentKey.bytes || '-' }}</div>
</div>
<div class="info-box">
<div class="label">SEQUENCE</div>
<div class="value code">{{ currentKey.sequence || '-' }}</div>
</div>
</div>
<div class="char-display">
Character: <span class="char-val">{{ currentKey.charDisplay || '-' }}</span>
</div>
</div>
<div class="history-strip">
<div v-for="(item, i) in history" :key="i" class="history-item">
<span class="h-name">{{ item.name }}</span>
<span class="arrow"></span>
<span class="h-bytes">{{ item.bytes }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isFocused = ref(false)
const currentKey = ref({ name: '', bytes: '', sequence: '', charDisplay: '' })
const history = ref([])
const focus = (e) => {
// Find the parent .input-visualizer and focus it
const container = e.currentTarget.closest('.input-visualizer')
if (container) {
container.focus()
isFocused.value = true
}
}
const handleBlur = () => {
isFocused.value = false
}
const handleKeydown = (e) => {
e.preventDefault()
let name = e.key
let bytes = ''
let sequence = ''
let charDisplay = e.key
// Map special keys
const keyMap = {
' ': { name: 'Space', bytes: '20', char: ' ' },
'Enter': { name: 'Enter', bytes: '0a', char: '\\n' },
'Tab': { name: 'Tab', bytes: '09', char: '\\t' },
'Escape': { name: 'Esc', bytes: '1b', char: '\\e' },
'Backspace': { name: 'Backspace', bytes: '7f', char: '\\b' },
'Delete': { name: 'Del', bytes: '1b 5b 33 7e', sequence: '^[[3~' },
'ArrowUp': { name: 'Arrow Up', bytes: '1b 5b 41', sequence: '^[[A' },
'ArrowDown': { name: 'Arrow Down', bytes: '1b 5b 42', sequence: '^[[B' },
'ArrowRight': { name: 'Arrow Right', bytes: '1b 5b 43', sequence: '^[[C' },
'ArrowLeft': { name: 'Arrow Left', bytes: '1b 5b 44', sequence: '^[[D' },
}
if (keyMap[e.key]) {
const map = keyMap[e.key]
name = map.name
bytes = map.bytes
sequence = map.sequence || ''
charDisplay = map.char || map.name
} else if (e.key.length === 1) {
// Printable characters
const code = e.key.charCodeAt(0)
bytes = code.toString(16).toLowerCase().padStart(2, '0')
if (e.ctrlKey) {
// Ctrl + Letter
name = `Ctrl+${e.key.toUpperCase()}`
const ctrlCode = code >= 97 && code <= 122 ? code - 96 : code
bytes = ctrlCode.toString(16).toLowerCase().padStart(2, '0')
sequence = '^' + e.key.toUpperCase()
charDisplay = sequence
}
} else {
// Other special keys
name = e.key
charDisplay = e.key
}
const keyData = { name, bytes, sequence, charDisplay }
currentKey.value = keyData
history.value.unshift(keyData)
if (history.value.length > 5) history.value.pop()
}
</script>
<style scoped>
.input-visualizer {
position: relative;
background: #09090b; /* Slightly lighter than pure black */
border: 1px solid #27272a;
border-radius: 12px;
padding: 30px 20px;
text-align: center;
font-family: 'JetBrains Mono', 'Menlo', monospace;
outline: none;
min-height: 320px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-visualizer:focus {
border-color: #10b981; /* Emerald 500 */
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.focus-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 0.2s;
}
.focus-overlay:hover {
background: rgba(0, 0, 0, 0.3);
}
.focus-btn {
background: #10b981;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
transition: transform 0.1s;
}
.focus-btn:hover {
transform: translateY(-1px);
}
.focus-btn:active {
transform: translateY(1px);
}
.main-display {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: opacity 0.2s, filter 0.2s;
}
.blur-content {
opacity: 0.5;
filter: blur(1px);
}
.key-name {
font-size: 36px;
font-weight: 700;
color: #e4e4e7; /* Zinc 200 */
margin-bottom: 30px;
height: 50px;
line-height: 50px;
}
.info-grid {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 30px;
width: 100%;
}
.info-box {
background: #18181b; /* Zinc 900 */
padding: 16px 20px;
border-radius: 8px;
min-width: 140px;
border: 1px solid #27272a;
}
.label {
color: #71717a; /* Zinc 500 */
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.value {
font-size: 24px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.highlight { color: #facc15; /* Yellow 400 */ }
.code { color: #22d3ee; /* Cyan 400 */ }
.char-display {
color: #a1a1aa; /* Zinc 400 */
font-size: 14px;
}
.char-val {
color: #fff;
font-weight: bold;
background: #27272a;
padding: 2px 6px;
border-radius: 4px;
margin-left: 5px;
}
.history-strip {
display: flex;
gap: 12px;
justify-content: center;
border-top: 1px solid #27272a;
padding-top: 20px;
margin-top: 20px;
flex-wrap: wrap;
}
.history-item {
display: flex;
align-items: center;
background: #18181b;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
color: #a1a1aa;
border: 1px solid #27272a;
}
.arrow {
color: #71717a; /* Lighter grey for better visibility */
margin: 0 8px;
}
.h-name {
font-weight: 500;
color: #e4e4e7;
}
.h-bytes {
color: #facc15;
font-family: monospace;
}
</style>
@@ -0,0 +1,42 @@
# Terminal Intro Components
此目录包含 `docs/zh-cn/appendix/terminal-intro.md`(终端原理附录)页面使用的所有交互式 Vue 组件。
这些组件旨在通过可视化和互动的方式,帮助读者理解终端的工作原理、ANSI 转义序列、Shell 交互等概念。
## 组件列表
| 组件名 | 描述 | 对应文档章节 |
| :--- | :--- | :--- |
| **TerminalDefinition.vue** | 可视化终端作为“字符流输入/输出环境”的定义。展示键盘输入 -> 字符流 -> 屏幕输出的过程。 | 1. 概念界定 |
| **ArchitectureDemo.vue** | 演示终端(前端)与 Shell(后端)的分离架构。模拟点餐流程类比。 | 2. 核心架构 |
| **TerminalGrid.vue** | 展示终端的字符网格系统,演示行、列和单元格的概念。 | 3. 视觉模型 |
| **CellInspector.vue** | 单元格检查器,展示每个字符单元格背后的属性(字符、前景色、背景色等)。 | 3.2 样式检查 |
| **EscapeSequences.vue** | 演示 ANSI 转义序列如何控制颜色、样式、光标移动和清屏。 | 4. 通信协议 |
| **InputVisualizer.vue** | 可视化键盘按键如何转换为字节序列发送给 Shell。 | 5. 输入机制 |
| **WebTerminal.vue** | 一个功能较完整的模拟终端,支持 `ls`, `cd`, `cat`, `apt` 等命令,包含虚拟文件系统。 | 附录/综合演示 |
| **SignalsDemo.vue** | 演示终端信号(如 Ctrl+C SIGINT)的工作机制。 | (文档中可能引用) |
| **FlowDiagram.vue** | 展示标准输入/输出/错误流 (stdin/stdout/stderr) 的流向图。 | (文档中可能引用) |
| **AdvancedTUIDemo.vue** | 展示基于文本的用户界面 (TUI) 的高级布局能力(如面板、进度条)。 | (文档中可能引用) |
## 开发指南
### 技术栈
- **Vue 3**: 使用 `<script setup>` 语法。
- **Styling**: Scoped CSS,主要使用 Flexbox 和 Grid 布局。
- **Theme**: 统一使用黑色系背景 (`#09090b`, `#18181b`) 和 JetBrains Mono 字体,保持类似终端的视觉风格。
### 维护注意事项
1. **双语支持**: 组件内部文本尽量支持中英双语,或通过 Props 传入文本。目前部分组件已硬编码双语标签。
2. **自包含**: 组件应尽量自包含,不依赖外部复杂的 Store 或 Context,以便于在 Markdown 中直接使用。
3. **响应式**: 考虑移动端适配,通常使用 `@media (max-width: 768px)` 进行布局调整。
### 常用颜色变量 (参考)
- 背景: `#09090b` (Main), `#18181b` (Panel)
- 边框: `#27272a`
- 文本: `#e4e4e7` (Primary), `#a1a1aa` (Secondary)
- 强调色: `#22c55e` (Green/Success), `#facc15` (Yellow/Warning), `#22d3ee` (Cyan/Info)
## 目录结构
所有组件均位于 `docs/.vitepress/theme/components/appendix/terminal-intro/` 下。
注册逻辑位于 `docs/.vitepress/theme/index.js`
@@ -0,0 +1,366 @@
<!--
SignalsDemo.vue
信号机制演示组件
用途
演示进程控制信号Signals如何工作特别是 `Ctrl+C` `Ctrl+Z`
说明这些组合键不是发送字符而是触发操作系统级别的中断信号
交互功能
- 模拟运行点击按钮启动一个模拟进程 `sleep 100`
- 发送信号点击按钮或快捷键发送 SIGINT/SIGTSTP
- 状态反馈展示进程状态的变化运行中 -> 被杀死/被挂起
-->
<template>
<div class="signals-demo">
<div class="left-panel">
<div class="signal-list">
<div
class="signal-item"
:class="{ active: activeSignal === 'SIGINT' }"
@click="sendSignal('SIGINT')"
>
<div class="key-combo">
<span class="key">Ctrl</span>+<span class="key">C</span>
<span class="action">Interrupt</span>
</div>
<div class="signal-name">SIGINT</div>
</div>
<div
class="signal-item"
:class="{ active: activeSignal === 'SIGTSTP' }"
@click="sendSignal('SIGTSTP')"
>
<div class="key-combo">
<span class="key">Ctrl</span>+<span class="key">Z</span>
<span class="action">Suspend</span>
</div>
<div class="signal-name">SIGTSTP</div>
</div>
</div>
<div class="info-box">
<div v-if="activeSignal === 'SIGINT'">
<div class="info-header">
<span class="highlight">Ctrl+C</span> <span class="signal-green">SIGINT</span>
</div>
<div class="info-desc">Stop the running program</div>
<p>Sends SIGINT (signal interrupt) to the foreground process. Most programs respond by stopping immediately. It's how you cancel a long-running command or exit a program that's stuck.</p>
<div class="example-box">
Example: Running `sleep 100` and pressing Ctrl+C stops it immediately.
</div>
</div>
<div v-else-if="activeSignal === 'SIGTSTP'">
<div class="info-header">
<span class="highlight">Ctrl+Z</span> <span class="signal-blue">SIGTSTP</span>
</div>
<div class="info-desc">Suspend the running program</div>
<p>Sends SIGTSTP (signal terminal stop). The process is paused and put in the background. You can resume it later with `fg` command.</p>
<div class="example-box">
Example: Pressing Ctrl+Z pauses a running editor like vim, returning you to the shell.
</div>
</div>
<div v-else>
<div class="info-header">Select a signal</div>
<p>Click on a signal type above to see how it works.</p>
</div>
</div>
</div>
<div class="right-panel">
<div class="terminal-window">
<div class="window-header">
<div class="dots"><span></span><span></span><span></span></div>
</div>
<div class="window-content">
<div v-for="(line, i) in lines" :key="i" class="term-line" :class="line.type">
{{ line.text }}
</div>
<div v-if="isRunning" class="term-line output">sleeping...</div>
<div v-if="inputBuffer" class="term-line input">
<span class="prompt">$</span> {{ inputBuffer }}<span class="cursor"></span>
</div>
<div v-else class="term-line input">
<span class="prompt">$</span> <span class="cursor"></span>
</div>
</div>
</div>
<div class="controls">
<button class="btn" @click="runCommand" :disabled="isRunning">Run Command</button>
<button class="btn" @click="sendSignal('SIGINT')">Ctrl+C</button>
<button class="btn" @click="sendSignal('SIGTSTP')">Ctrl+Z</button>
<button class="btn secondary" @click="reset">Reset</button>
</div>
<div class="state-display">
State: <span :class="stateClass">{{ processState }}</span>
</div>
<p class="instruction">
Click "Run Command" to start a simulated process, then try sending different signals.
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const activeSignal = ref('SIGINT')
const isRunning = ref(false)
const lines = ref([
{ type: 'input', text: '$ sleep 100' }
])
const processState = ref('Running')
const inputBuffer = ref('')
const stateClass = computed(() => {
if (processState.value.includes('Running')) return 'state-green'
if (processState.value.includes('interrupted')) return 'state-red'
if (processState.value.includes('suspended')) return 'state-blue'
return ''
})
const runCommand = () => {
reset()
isRunning.value = true
processState.value = 'Running (PID 1234)'
}
const sendSignal = (sig) => {
activeSignal.value = sig
if (!isRunning.value && sig === 'SIGINT') return
if (sig === 'SIGINT') {
lines.value.push({ type: 'output', text: 'sleeping...' })
lines.value.push({ type: 'control', text: '^C' })
isRunning.value = false
processState.value = 'Process interrupted (killed)'
} else if (sig === 'SIGTSTP') {
lines.value.push({ type: 'output', text: 'sleeping...' })
lines.value.push({ type: 'control', text: '^Z' })
lines.value.push({ type: 'output', text: '[1]+ Stopped sleep 100' })
isRunning.value = false
processState.value = 'Process suspended (stopped)'
}
}
const reset = () => {
lines.value = [{ type: 'input', text: '$ sleep 100' }]
isRunning.value = true
processState.value = 'Running (PID 1234)'
}
// Initial state
reset()
</script>
<style scoped>
.signals-demo {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); /* 自动适应宽度,不够时换行 */
gap: 30px;
background: #09090b;
padding: 30px;
border-radius: 12px;
border: 1px solid #27272a;
font-family: 'JetBrains Mono', 'Menlo', monospace;
color: #e4e4e7;
overflow: hidden; /* 防止溢出 */
}
.left-panel {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0; /* 防止 flex 子项溢出 */
}
.signal-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #18181b;
border: 1px solid #27272a;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.signal-item:hover {
border-color: #52525b;
}
.signal-item.active {
background: #27272a;
border-left: 3px solid #facc15;
}
.key-combo {
display: flex;
align-items: center;
gap: 5px;
}
.key {
color: #facc15;
font-weight: bold;
}
.action {
color: #a1a1aa;
margin-left: 10px;
font-size: 13px;
}
.signal-name {
color: #22d3ee;
font-weight: bold;
}
.info-box {
background: #18181b;
padding: 20px;
border-radius: 6px;
border: 1px solid #27272a;
font-size: 14px;
line-height: 1.6;
}
.info-header {
font-size: 18px;
margin-bottom: 10px;
font-weight: bold;
}
.highlight { color: #facc15; }
.signal-green { color: #22c55e; }
.signal-blue { color: #3b82f6; }
.info-desc {
color: #a1a1aa;
margin-bottom: 15px;
}
.example-box {
background: #000;
padding: 10px;
border-radius: 4px;
font-size: 13px;
color: #d4d4d8;
margin-top: 15px;
border: 1px solid #27272a;
}
.right-panel {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0; /* 防止 flex 子项溢出 */
}
.terminal-window {
background: #000;
border: 1px solid #27272a;
border-radius: 8px;
height: 200px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.window-header {
padding: 10px 15px;
border-bottom: 1px solid #27272a;
background: #18181b;
}
.dots span {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: #3f3f46;
margin-right: 6px;
}
.window-content {
padding: 15px;
flex: 1;
font-size: 14px;
overflow-y: auto;
}
.term-line {
margin-bottom: 5px;
}
.control { color: #ef4444; }
.output { color: #d4d4d8; }
.input { color: #fff; }
.prompt { color: #71717a; margin-right: 8px; }
.cursor {
display: inline-block;
width: 8px;
height: 14px;
background: #a1a1aa;
vertical-align: middle;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap; /* 允许按钮换行 */
}
.btn {
background: #18181b;
border: 1px solid #27272a;
color: #e4e4e7;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
flex: 1;
white-space: nowrap; /* 防止文字换行 */
min-width: 80px; /* 最小宽度 */
transition: all 0.2s;
font-size: 13px;
}
.btn:hover:not(:disabled) {
background: #27272a;
border-color: #52525b;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.state-display {
font-size: 16px;
margin-top: 10px;
}
.state-green { color: #22c55e; }
.state-red { color: #ef4444; }
.state-blue { color: #3b82f6; }
.instruction {
color: #a1a1aa;
font-size: 13px;
}
@media (max-width: 640px) {
.signals-demo {
padding: 20px;
gap: 20px;
}
}
</style>
@@ -0,0 +1,576 @@
<template>
<div class="terminal-definition">
<div class="mode-switch">
<button
:class="{ active: mode === 'cli' }"
@click="mode = 'cli'"
>
🖥 CLI (命令行界面)
</button>
<button
:class="{ active: mode === 'gui' }"
@click="mode = 'gui'"
>
🖱 GUI (图形用户界面)
</button>
</div>
<!-- CLI Visualization -->
<div v-if="mode === 'cli'" class="visualization-container">
<div class="flow-container">
<!-- Input Side -->
<div class="stage input-stage">
<div class="icon-box">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2" ry="2"></rect><path d="M6 8h.001"></path><path d="M10 8h.001"></path><path d="M14 8h.001"></path><path d="M18 8h.001"></path><path d="M6 12h.001"></path><path d="M10 12h.001"></path><path d="M14 12h.001"></path><path d="M18 12h.001"></path><path d="M7 16h10"></path></svg>
</div>
<div class="label">Input (Keyboard)</div>
<div class="sub-label">发送指令 (字符信号)</div>
</div>
<!-- Stream Animation -->
<div class="stream-path">
<div class="stream-line"></div>
<div class="stream-label">Character Stream / 字符流</div>
<div
v-for="char in activeChars"
:key="char.id"
class="stream-char"
:style="{ left: char.progress + '%' }"
>
{{ char.val }}
</div>
</div>
<!-- Output Side -->
<div class="stage output-stage">
<div class="terminal-screen">
<div class="screen-content">
<span class="prompt">$</span> {{ typedContent }}<span class="cursor">_</span>
</div>
</div>
<div class="label">Output (Text Grid)</div>
<div class="sub-label">文本网格反馈</div>
</div>
</div>
<div class="desc-box">
<p><strong>CLI (Command Line Interface)</strong>: 这种模式下计算机只认识字符你的每一次按键都会被转换成编码发送给系统系统处理后返回文字结果它不关心你在哪里点击只关心你输入了什么</p>
</div>
<div class="control-bar">
<button @click="startSimulation" :disabled="isAnimating">
<span v-if="!isAnimating"> Play Simulation / 演示输入流</span>
<span v-else>Simulating... / 演示中...</span>
</button>
</div>
</div>
<!-- GUI Visualization -->
<div v-else class="visualization-container">
<div class="flow-container">
<!-- Input Side -->
<div class="stage input-stage">
<div class="icon-box gui-input" :class="{ clicking: isGuiClicking }">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"></path><path d="M13 13l6 6"></path></svg>
</div>
<div class="label">Input (Mouse)</div>
<div class="sub-label">发送事件 (坐标/点击)</div>
</div>
<!-- Event Animation -->
<div class="stream-path">
<div class="stream-line dashed"></div>
<div class="stream-label">Event Loop / 事件循环</div>
<div
v-if="guiEvent"
class="gui-event-packet"
:style="{ left: guiEvent.progress + '%' }"
>
{{ guiEvent.type }}
</div>
</div>
<!-- Output Side -->
<div class="stage output-stage">
<div class="gui-screen">
<div class="window-frame">
<div class="win-header"></div>
<div class="win-body">
<div class="icon-grid">
<div class="desktop-icon" :class="{ selected: iconSelected }">📁</div>
<div class="desktop-icon">📄</div>
</div>
<div class="gui-cursor" :style="cursorStyle">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white" stroke="black" stroke-width="2"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"></path></svg>
</div>
</div>
</div>
</div>
<div class="label">Output (Graphics)</div>
<div class="sub-label">像素图形渲染</div>
</div>
</div>
<div class="desc-box">
<p><strong>GUI (Graphical User Interface)</strong>: 这种模式下计算机实时追踪鼠标坐标和点击事件并每秒刷新 60 次屏幕像素它更直观但需要消耗大量资源来处理图形渲染</p>
</div>
<div class="control-bar">
<button @click="startGuiSimulation" :disabled="isGuiAnimating">
<span v-if="!isGuiAnimating"> Play Interaction / 演示交互</span>
<span v-else>Simulating... / 演示中...</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const mode = ref('cli') // 'cli' | 'gui'
// CLI Logic
const isAnimating = ref(false)
const activeChars = ref([])
const typedContent = ref('')
const demoText = 'ls -la'
const startSimulation = () => {
if (isAnimating.value) return
isAnimating.value = true
typedContent.value = ''
activeChars.value = []
let index = 0
const processNextChar = () => {
if (index >= demoText.length) {
setTimeout(() => {
isAnimating.value = false
}, 1000)
return
}
const char = demoText[index]
const charId = Date.now() + index
// Create new flying char
const newChar = {
id: charId,
val: char,
progress: 10 // start position
}
activeChars.value.push(newChar)
// Animate this char
let progress = 10
const interval = setInterval(() => {
progress += 2
const charObj = activeChars.value.find(c => c.id === charId)
if (charObj) charObj.progress = progress
if (progress >= 90) {
clearInterval(interval)
// Remove from stream and add to screen
activeChars.value = activeChars.value.filter(c => c.id !== charId)
typedContent.value += char
// Next char
index++
setTimeout(processNextChar, 300)
}
}, 20)
}
processNextChar()
}
// GUI Logic
const isGuiAnimating = ref(false)
const isGuiClicking = ref(false)
const guiEvent = ref(null)
const iconSelected = ref(false)
const cursorPosition = ref({ x: 50, y: 50 })
const cursorStyle = computed(() => ({
transform: `translate(${cursorPosition.value.x}px, ${cursorPosition.value.y}px)`
}))
const startGuiSimulation = () => {
if (isGuiAnimating.value) return
isGuiAnimating.value = true
iconSelected.value = false
cursorPosition.value = { x: 80, y: 60 } // Reset pos
// 1. Move Cursor
let step = 0
const moveInterval = setInterval(() => {
step++
cursorPosition.value = {
x: 80 - step * 2,
y: 60 - step * 1.5
}
if (step >= 20) {
clearInterval(moveInterval)
// 2. Click
performClick()
}
}, 20)
}
const performClick = () => {
setTimeout(() => {
isGuiClicking.value = true
// Send Event Packet
guiEvent.value = { type: 'Click(40,30)', progress: 10 }
let progress = 10
const packetInterval = setInterval(() => {
progress += 2
if (guiEvent.value) guiEvent.value.progress = progress
if (progress >= 90) {
clearInterval(packetInterval)
guiEvent.value = null
isGuiClicking.value = false
iconSelected.value = true // Effect
setTimeout(() => {
isGuiAnimating.value = false
}, 1000)
}
}, 10)
}, 300)
}
</script>
<style scoped>
.terminal-definition {
background: #09090b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 20px;
font-family: 'JetBrains Mono', monospace;
margin: 20px 0;
}
.mode-switch {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #27272a;
padding-bottom: 15px;
}
.mode-switch button {
background: transparent;
border: 1px solid transparent;
color: #71717a;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.mode-switch button.active {
background: #27272a;
color: #e4e4e7;
border-color: #3f3f46;
}
.mode-switch button:hover {
color: #e4e4e7;
}
.flow-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
height: 120px;
}
.stage {
display: flex;
flex-direction: column;
align-items: center;
z-index: 2;
position: relative;
width: 100px;
}
.input-stage {
flex: 0 0 auto;
}
.output-stage {
flex: 0 0 auto;
}
.icon-box {
width: 60px;
height: 60px;
background: #18181b;
border: 1px solid #3f3f46;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #a1a1aa;
margin-bottom: 10px;
transition: all 0.2s;
}
.icon-box.clicking {
transform: scale(0.9);
border-color: #22d3ee;
color: #22d3ee;
}
.terminal-screen {
width: 140px;
height: 80px;
background: #000;
border: 1px solid #3f3f46;
border-radius: 8px;
padding: 10px;
color: #22c55e;
font-size: 12px;
display: flex;
align-items: flex-start;
margin-bottom: 10px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.2);
overflow: hidden;
}
.gui-screen {
width: 140px;
height: 80px;
background: #27272a;
border: 1px solid #52525b;
border-radius: 4px;
margin-bottom: 10px;
overflow: hidden;
position: relative;
}
.window-frame {
background: #3f3f46;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.win-header {
height: 12px;
background: #52525b;
border-bottom: 1px solid #27272a;
}
.win-body {
flex: 1;
background: #18181b; /* Wallpaper */
position: relative;
padding: 10px;
}
.icon-grid {
display: flex;
gap: 10px;
}
.desktop-icon {
font-size: 16px;
padding: 2px;
border-radius: 4px;
border: 1px solid transparent;
}
.desktop-icon.selected {
background: rgba(34, 211, 238, 0.2);
border-color: #22d3ee;
}
.gui-cursor {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
transition: transform 0.05s linear;
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.5));
}
.screen-content {
word-break: break-all;
}
.label {
font-size: 13px;
font-weight: 600;
color: #e4e4e7;
text-align: center;
}
.sub-label {
font-size: 10px;
color: #71717a;
margin-top: 2px;
text-align: center;
}
.stream-path {
flex: 1;
height: 60px;
position: relative;
margin: 0 10px;
display: flex;
align-items: center;
justify-content: center;
}
.stream-line {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 2px;
background: #27272a;
transform: translateY(-50%);
}
.stream-line.dashed {
background: repeating-linear-gradient(90deg, #27272a 0, #27272a 6px, transparent 6px, transparent 10px);
height: 1px;
}
.stream-line::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
border-left: 6px solid #27272a;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
.stream-label {
position: absolute;
top: 10px;
font-size: 10px;
color: #52525b;
background: #09090b;
padding: 0 8px;
}
.stream-char {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
background: #22d3ee;
color: #000;
font-weight: bold;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
box-shadow: 0 0 10px rgba(34, 211, 238, 0.3);
z-index: 10;
}
.gui-event-packet {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
background: #facc15;
color: #000;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
box-shadow: 0 0 5px rgba(250, 204, 21, 0.3);
white-space: nowrap;
z-index: 10;
}
.cursor {
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.desc-box {
background: #18181b;
padding: 12px;
border-radius: 6px;
margin-bottom: 15px;
font-size: 13px;
color: #a1a1aa;
line-height: 1.5;
}
.desc-box strong {
color: #e4e4e7;
}
.control-bar {
display: flex;
justify-content: center;
}
button {
background: #18181b;
color: #e4e4e7;
border: 1px solid #27272a;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
button:hover:not(:disabled) {
background: #27272a;
border-color: #52525b;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 600px) {
.flow-container {
flex-direction: column;
height: auto;
gap: 20px;
}
.stream-path {
width: 100%;
height: 40px;
margin: 10px 0;
}
.stream-line {
transform: rotate(90deg);
width: 40px;
left: 50%;
margin-left: -20px;
}
}
</style>
@@ -0,0 +1,246 @@
<!--
TerminalGrid.vue
终端网格模型演示组件
用途
展示终端屏幕本质上是由字符网格构成的
帮助用户理解终端不是像素画板而是由一个个固定大小的单元格Cell组成的矩阵
交互功能
- 点击/拖拽可以在网格上出字符
- 键盘输入可以直接在网格中打字观察光标移动和字符填充
- 响应式布局支持横向滚动适应不同屏幕宽度
-->
<template>
<div class="grid-demo">
<div class="terminal-screen">
<div class="grid-row" v-for="(row, rIndex) in rows" :key="rIndex">
<div
class="grid-cell"
v-for="(cell, cIndex) in row"
:key="cIndex"
:class="{
'active-cursor': cursor.r === rIndex && cursor.c === cIndex,
'drawn': cell.drawn
}"
@mousedown.prevent="handleCellMouseDown(rIndex, cIndex)"
@mouseover="handleCellHover(rIndex, cIndex)"
>
{{ cell.char || ' ' }}
</div>
</div>
</div>
<div class="controls">
<input
ref="inputRef"
type="text"
v-model="inputText"
placeholder="Type here..."
class="text-input"
@keydown="handleKeydown"
/>
<button class="btn" @click="clearGrid">Clear</button>
<span class="hint">Click/Drag cells to draw, Type to insert text</span>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
const ROW_COUNT = 10
const COL_COUNT = 40
const createGrid = () => Array.from({ length: ROW_COUNT }, () =>
Array.from({ length: COL_COUNT }, () => ({ char: '', drawn: false }))
)
const rows = reactive(createGrid())
const cursor = reactive({ r: 0, c: 0 })
const inputText = ref('')
const isDrawing = ref(false)
const inputRef = ref(null)
const drawingListener = () => {
isDrawing.value = false
}
const handleKeydown = (e) => {
if (e.key === 'Backspace') {
if (cursor.c > 0) {
cursor.c--
} else if (cursor.r > 0) {
cursor.r--
cursor.c = COL_COUNT - 1
}
rows[cursor.r][cursor.c].char = ''
return
}
if (e.key.length === 1) {
rows[cursor.r][cursor.c].char = e.key
advanceCursor()
}
if (e.key === 'Enter') {
cursor.r = Math.min(cursor.r + 1, ROW_COUNT - 1)
cursor.c = 0
}
}
const advanceCursor = () => {
cursor.c++
if (cursor.c >= COL_COUNT) {
cursor.c = 0
cursor.r++
if (cursor.r >= ROW_COUNT) {
cursor.r = ROW_COUNT - 1 // Stop at bottom
}
}
}
const handleCellMouseDown = (r, c) => {
isDrawing.value = true
rows[r][c].drawn = !rows[r][c].drawn
cursor.r = r
cursor.c = c
if (inputRef.value) {
inputRef.value.focus()
}
}
const handleCellHover = (r, c) => {
if (isDrawing.value) {
rows[r][c].drawn = true
}
}
const clearGrid = () => {
for(let r=0; r<ROW_COUNT; r++) {
for(let c=0; c<COL_COUNT; c++) {
rows[r][c].char = ''
rows[r][c].drawn = false
}
}
cursor.r = 0
cursor.c = 0
inputText.value = ''
if (inputRef.value) {
inputRef.value.focus()
}
}
onMounted(() => {
window.addEventListener('mouseup', drawingListener)
})
onBeforeUnmount(() => {
window.removeEventListener('mouseup', drawingListener)
})
</script>
<style scoped>
.grid-demo {
background: #09090b;
padding: 20px;
border-radius: 12px;
border: 1px solid #27272a;
font-family: 'JetBrains Mono', 'Menlo', 'Monaco', monospace;
overflow: hidden; /* 防止内容溢出圆角 */
}
.terminal-screen {
border: 1px solid #27272a;
background: #000;
cursor: text;
display: block;
overflow-x: auto; /* 允许横向滚动 */
max-width: 100%;
border-radius: 6px;
scrollbar-width: thin; /* Firefox */
scrollbar-color: #3f3f46 #18181b;
}
/* Webkit scrollbar styles */
.terminal-screen::-webkit-scrollbar {
height: 8px;
}
.terminal-screen::-webkit-scrollbar-track {
background: #18181b;
}
.terminal-screen::-webkit-scrollbar-thumb {
background-color: #3f3f46;
border-radius: 4px;
}
.grid-row {
display: flex;
width: max-content; /* 确保内容撑开宽度 */
}
.grid-cell {
width: 16px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid #27272a;
border-bottom: 1px solid #27272a;
color: #e4e4e7;
font-size: 14px;
user-select: none;
}
.grid-cell.drawn {
background-color: #3f3f46;
}
.grid-cell.active-cursor {
background-color: #e4e4e7;
color: #000;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0.7; }
}
.controls {
margin-top: 15px;
display: flex;
gap: 10px;
align-items: center;
}
.text-input {
background: #18181b;
border: 1px solid #3f3f46;
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-family: inherit;
}
.btn {
background: #27272a;
border: 1px solid #3f3f46;
color: #e4e4e7;
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn:hover {
background: #3f3f46;
border-color: #52525b;
}
.hint {
color: #a1a1aa; /* Zinc 400 */
font-size: 12px;
margin-left: auto;
}
</style>
@@ -0,0 +1,660 @@
<!--
WebTerminal.vue
Web 模拟终端组件
用途
提供一个在浏览器中可交互的简易终端环境作为本章节的综合练习场
让用户在学完所有理论后能够在一个受控环境中实际体验输入输出命令执行等操作
交互功能
- 命令执行支持简单的模拟命令 help, clear, echo
- 历史记录支持上下键翻阅命令历史
- 真实反馈模拟真实的终端响应延迟和输出格式
-->
<template>
<div class="web-terminal-wrapper">
<div class="terminal-container">
<div class="terminal-header">
<div class="terminal-buttons">
<span class="btn red"></span>
<span class="btn yellow"></span>
<span class="btn green"></span>
</div>
<div class="terminal-title">Terminal - zsh</div>
</div>
<div class="terminal-body" ref="terminalBody" @click="focusInput">
<div v-for="(line, index) in history" :key="index" class="terminal-line">
<span class="prompt" v-if="line.type === 'input'">
<span class="path">{{ currentPath }}</span>
<span class="arrow">$ </span>
</span>
<span :class="line.type">{{ line.content }}</span>
</div>
<div class="input-line">
<span class="prompt">
<span class="path">{{ currentPath }}</span>
<span class="arrow">$ </span>
</span>
<input
type="text"
v-model="currentInput"
@keyup.enter="executeCommand"
@keydown.up.prevent="navigateHistory(-1)"
@keydown.down.prevent="navigateHistory(1)"
@keydown.tab.prevent="handleTabCompletion"
ref="inputField"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
</div>
<div class="cheat-sheet">
<div class="sheet-title">
<span class="icon">📖</span>
<span class="en">Command Cheat Sheet</span>
<span class="divider">|</span>
<span class="zh">命令速查表</span>
</div>
<div class="sheet-content">
<div class="cmd-group" v-for="(group, gIndex) in cheatSheet" :key="gIndex">
<div class="group-title">{{ group.category }}</div>
<div class="cmd-item" v-for="(cmd, cIndex) in group.commands" :key="cIndex">
<div class="cmd-name" @click="fillCommand(cmd.name)">{{ cmd.name }}</div>
<div class="cmd-desc">
<div class="en">{{ cmd.descEn }}</div>
<div class="zh">{{ cmd.descZh }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
const history = ref([
{ type: 'output', content: 'Welcome to the interactive terminal simulator! / 欢迎使用交互式终端模拟器!' },
{ type: 'output', content: 'Type "help" to see available commands. / 输入 "help" 查看可用命令。' }
])
const currentInput = ref('')
const inputField = ref(null)
const terminalBody = ref(null)
const commandHistory = ref([])
const historyIndex = ref(-1)
// 模拟文件系统
const fileSystem = {
name: '/',
type: 'dir',
children: {
'home': {
name: 'home',
type: 'dir',
children: {
'user': {
name: 'user',
type: 'dir',
children: {
'hello.txt': { name: 'hello.txt', type: 'file', content: 'Hello World! This is a mock file.\n你好!这是一个模拟文件。' },
'notes.md': { name: 'notes.md', type: 'file', content: '# My Notes\n- Learn Terminal\n- Learn Shell\n- Learn Kernel' },
'projects': {
name: 'projects',
type: 'dir',
children: {
'app.js': { name: 'app.js', type: 'file', content: 'console.log("Hello");' }
}
},
'Downloads': { name: 'Downloads', type: 'dir', children: {} }
}
}
}
},
'etc': {
name: 'etc',
type: 'dir',
children: {
'passwd': { name: 'passwd', type: 'file', content: 'root:x:0:0:root:/root:/bin/bash\nuser:x:1000:1000:user:/home/user:/bin/zsh' }
}
},
'bin': {
name: 'bin',
type: 'dir',
children: {
'ls': { name: 'ls', type: 'file', content: 'Binary file' },
'cat': { name: 'cat', type: 'file', content: 'Binary file' }
}
}
}
}
let currentPath = '~'
let currentDirObj = fileSystem.children['home'].children['user']
const resolvePath = (path) => {
if (path === '~' || path === '') return fileSystem.children['home'].children['user']
if (path === '/') return fileSystem
let parts = path.split('/').filter(p => p)
let current = path.startsWith('/') ? fileSystem : currentDirObj
for (const part of parts) {
if (part === '.') continue
if (part === '..') {
// Find parent (simplification: we don't store parent refs, so we re-traverse or just mock it)
// For this simple mock, '..' support is limited or we implement path string manipulation
return null // path manipulation logic needs to be separate
}
if (current.type === 'dir' && current.children && current.children[part]) {
current = current.children[part]
} else {
return null
}
}
return current
}
const getParentPath = (path) => {
if (path === '/' || path === '~') return path // Simplified
const parts = path.split('/')
parts.pop()
return parts.join('/') || '/'
}
// Better path resolution logic
const navigateTo = (target) => {
let newPath = currentPath
let newDir = currentDirObj
if (target === '/') {
newPath = '/'
newDir = fileSystem
} else if (target === '~') {
newPath = '~'
newDir = fileSystem.children['home'].children['user']
} else if (target === '..') {
if (currentPath === '/') return { path: '/', dir: fileSystem }
if (currentPath === '~') { // ~ is /home/user
newPath = '/home'
newDir = fileSystem.children['home']
} else {
// Simple string manipulation for path
const parts = currentPath.split('/')
parts.pop()
newPath = parts.join('/') || '/'
// Re-resolve dir from root for safety
if (newPath === '/') newDir = fileSystem
else if (newPath === '/home') newDir = fileSystem.children['home']
else if (newPath === '/home/user') newDir = fileSystem.children['home'].children['user']
else {
// Fallback for deeper paths if we supported them
// For now, let's keep it simple
}
}
} else {
// Relative path
if (currentDirObj.children && currentDirObj.children[target]) {
const targetObj = currentDirObj.children[target]
if (targetObj.type === 'dir') {
newDir = targetObj
newPath = currentPath === '/' ? `/${target}` : `${currentPath}/${target}`
} else {
return { error: `cd: not a directory: ${target}` }
}
} else {
return { error: `cd: no such file or directory: ${target}` }
}
}
return { path: newPath, dir: newDir }
}
const cheatSheet = [
{
category: 'Navigation / 导航',
commands: [
{ name: 'ls', descEn: 'List directory contents', descZh: '列出当前目录下的文件和文件夹' },
{ name: 'cd <dir>', descEn: 'Change directory', descZh: '进入指定目录 (例如: cd projects)' },
{ name: 'pwd', descEn: 'Print working directory', descZh: '显示当前所在的完整路径' }
]
},
{
category: 'File Operations / 文件操作',
commands: [
{ name: 'cat <file>', descEn: 'Show file contents', descZh: '查看文件内容 (例如: cat hello.txt)' },
{ name: 'touch <file>', descEn: 'Create empty file', descZh: '创建一个新文件' },
{ name: 'mkdir <dir>', descEn: 'Make directory', descZh: '创建一个新文件夹' },
{ name: 'rm <file>', descEn: 'Remove file', descZh: '删除文件' }
]
},
{
category: 'System / 系统',
commands: [
{ name: 'echo <text>', descEn: 'Print text', descZh: '在屏幕上打印一段文字' },
{ name: 'whoami', descEn: 'Current user', descZh: '显示当前用户名' },
{ name: 'date', descEn: 'Show date/time', descZh: '显示当前日期和时间' },
{ name: 'clear', descEn: 'Clear screen', descZh: '清空屏幕内容' }
]
},
{
category: 'Package Manager / 软件包 (Mock)',
commands: [
{ name: 'apt update', descEn: 'Update package list', descZh: '更新软件包列表' },
{ name: 'apt install <pkg>', descEn: 'Install package', descZh: '安装软件 (例如: apt install git)' }
]
}
]
const commands = {
help: () => {
return `Available commands:
ls, cd, pwd, cat, touch, mkdir, rm, echo, whoami, date, clear, apt`
},
ls: (args) => {
if (!currentDirObj.children) return ''
const items = Object.values(currentDirObj.children)
if (items.length === 0) return ''
// Simple column formatting
const names = items.map(item => {
return item.type === 'dir' ? `\x1b[1;34m${item.name}/\x1b[0m` : item.name
})
return names.join(' ')
},
pwd: () => {
// Expand ~ to /home/user for display if needed, but keeping ~ is also standard zsh
return currentPath === '~' ? '/home/user' : currentPath
},
cd: (args) => {
const target = args[0] || '~'
const result = navigateTo(target)
if (result.error) return result.error
currentPath = result.path
currentDirObj = result.dir
return null
},
clear: () => {
history.value = []
return null
},
echo: (args) => {
return args.join(' ')
},
cat: (args) => {
const file = args[0]
if (!file) return 'usage: cat <file>'
if (currentDirObj.children && currentDirObj.children[file]) {
const target = currentDirObj.children[file]
if (target.type === 'dir') return `cat: ${file}: Is a directory`
return target.content
}
return `cat: ${file}: No such file or directory`
},
touch: (args) => {
const name = args[0]
if (!name) return 'usage: touch <file>'
if (currentDirObj.children[name]) return null // Already exists, update time (mock: do nothing)
currentDirObj.children[name] = { name, type: 'file', content: '' }
return null
},
mkdir: (args) => {
const name = args[0]
if (!name) return 'usage: mkdir <dir>'
if (currentDirObj.children[name]) return `mkdir: cannot create directory '${name}': File exists`
currentDirObj.children[name] = { name, type: 'dir', children: {} }
return null
},
rm: (args) => {
const name = args[0]
if (!name) return 'usage: rm <file>'
// Mock: -r not supported for simplicity
if (currentDirObj.children[name]) {
if (currentDirObj.children[name].type === 'dir') return `rm: cannot remove '${name}': Is a directory`
delete currentDirObj.children[name]
return null
}
return `rm: cannot remove '${name}': No such file or directory`
},
whoami: () => 'user',
date: () => new Date().toString(),
apt: (args) => {
const cmd = args[0]
if (cmd === 'update') {
return `Hit:1 http://archive.ubuntu.com/ubuntu jammy InRelease
Get:2 http://security.ubuntu.com/ubuntu jammy-security InRelease [110 kB]
Fetched 110 kB in 1s (135 kB/s)
Reading package lists... Done`
}
if (cmd === 'install') {
const pkg = args[1]
if (!pkg) return 'apt install: missing package name'
return `Reading package lists... Done
Building dependency tree... Done
The following NEW packages will be installed:
${pkg}
0 upgraded, 1 newly installed, 0 to remove.
Need to get 1,234 kB of archives.
After this operation, 5,678 kB of additional disk space will be used.
Selecting previously unselected package ${pkg}.
(Reading database ... 25432 files and directories currently installed.)
Preparing to unpack .../${pkg}_1.0.0_amd64.deb ...
Unpacking ${pkg} (1.0.0) ...
Setting up ${pkg} (1.0.0) ...`
}
return 'apt: usage: apt update | apt install <package>'
}
}
const executeCommand = () => {
const input = currentInput.value.trim()
if (!input) {
history.value.push({ type: 'input', content: '' })
currentInput.value = ''
scrollToBottom()
return
}
// Add to command history
commandHistory.value.push(input)
historyIndex.value = commandHistory.value.length
history.value.push({ type: 'input', content: input })
const [cmd, ...args] = input.split(/\s+/)
if (commands[cmd]) {
try {
const output = commands[cmd](args)
if (output !== null) {
// Handle colored output simply by not escaping HTML if we trust it (Vue escapes by default)
// For simple color simulation, we can strip codes or use a span.
// Here we just keep simple text, but `ls` returns ANSI codes which we might want to handle or strip.
// For this demo, let's strip ANSI codes for safety/simplicity in Vue or parse them.
// Let's simple string replace for blue color
let safeOutput = output
const lines = safeOutput.split('\n')
lines.forEach(line => {
// Basic ANSI parser for ls colors
const isDir = line.includes('\x1b[1;34m')
const cleanContent = line.replace(/\x1b\[[0-9;]*m/g, '')
history.value.push({ type: isDir ? 'output-dir' : 'output', content: cleanContent })
})
}
} catch (e) {
history.value.push({ type: 'error', content: `Error executing command: ${e.message}` })
}
} else {
history.value.push({ type: 'error', content: `zsh: command not found: ${cmd}` })
}
currentInput.value = ''
scrollToBottom()
}
const navigateHistory = (direction) => {
if (commandHistory.value.length === 0) return
historyIndex.value += direction
if (historyIndex.value < 0) historyIndex.value = 0
if (historyIndex.value > commandHistory.value.length) historyIndex.value = commandHistory.value.length
if (historyIndex.value === commandHistory.value.length) {
currentInput.value = ''
} else {
currentInput.value = commandHistory.value[historyIndex.value]
}
}
const handleTabCompletion = () => {
// Simple tab completion for current directory
const input = currentInput.value
const [cmd, ...args] = input.split(/\s+/)
const partial = args[args.length - 1] || ''
if (cmd && currentDirObj.children) {
const matches = Object.keys(currentDirObj.children).filter(name => name.startsWith(partial))
if (matches.length === 1) {
const completed = matches[0]
// Replace last arg with completed
args[args.length - 1] = completed
currentInput.value = `${cmd} ${args.join(' ')}`
}
}
}
const focusInput = () => {
inputField.value?.focus()
}
const scrollToBottom = () => {
nextTick(() => {
if (terminalBody.value) {
terminalBody.value.scrollTop = terminalBody.value.scrollHeight
}
})
}
const fillCommand = (cmdName) => {
// Extract command from example (e.g., "cd <dir>" -> "cd")
const cmd = cmdName.split(' ')[0]
currentInput.value = cmd + ' '
focusInput()
}
onMounted(() => {
focusInput()
})
</script>
<style scoped>
.web-terminal-wrapper {
display: grid;
grid-template-columns: 1fr 280px;
gap: 20px;
margin: 20px 0;
font-family: 'JetBrains Mono', 'Menlo', monospace;
}
.terminal-container {
background-color: #0a0a0a;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
overflow: hidden;
border: 1px solid #27272a;
display: flex;
flex-direction: column;
height: 400px;
}
.terminal-header {
background-color: #18181b;
padding: 10px 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #27272a;
}
.terminal-buttons {
display: flex;
gap: 8px;
}
.btn {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.red { background-color: #ef4444; }
.yellow { background-color: #facc15; }
.green { background-color: #22c55e; }
.terminal-title {
flex: 1;
text-align: center;
color: #71717a;
font-size: 12px;
margin-left: -50px;
}
.terminal-body {
padding: 15px;
flex: 1;
overflow-y: auto;
color: #e4e4e7;
font-size: 14px;
line-height: 1.6;
cursor: text;
}
.terminal-line {
margin-bottom: 2px;
white-space: pre-wrap;
word-break: break-all;
}
.input-line {
display: flex;
align-items: center;
}
.prompt {
color: #22c55e;
margin-right: 8px;
user-select: none;
white-space: nowrap;
}
.prompt .path {
color: #3b82f6;
margin-right: 4px;
}
.error {
color: #ef4444;
}
.output-dir {
color: #3b82f6;
font-weight: bold;
}
input {
background: transparent;
border: none;
color: #e4e4e7;
outline: none;
flex: 1;
font-family: inherit;
font-size: inherit;
padding: 0;
margin: 0;
}
/* Cheat Sheet Styles */
.cheat-sheet {
background: #18181b;
border: 1px solid #27272a;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
height: 400px;
}
.sheet-title {
padding: 12px 15px;
background: #27272a;
color: #e4e4e7;
font-weight: bold;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.sheet-title .divider {
color: #52525b;
font-weight: normal;
}
.sheet-content {
padding: 15px;
overflow-y: auto;
flex: 1;
}
.cmd-group {
margin-bottom: 20px;
}
.group-title {
color: #facc15;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
font-weight: 600;
border-bottom: 1px solid #27272a;
padding-bottom: 4px;
}
.cmd-item {
margin-bottom: 10px;
cursor: pointer;
transition: transform 0.1s;
}
.cmd-item:hover {
transform: translateX(4px);
}
.cmd-name {
color: #22d3ee;
font-weight: bold;
font-size: 12px;
margin-bottom: 2px;
}
.cmd-desc {
font-size: 11px;
color: #a1a1aa;
}
.cmd-desc .zh {
color: #71717a;
margin-top: 1px;
}
@media (max-width: 768px) {
.web-terminal-wrapper {
grid-template-columns: 1fr;
height: auto;
}
.terminal-container, .cheat-sheet {
height: 350px;
}
}
</style>