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:
@@ -433,7 +433,8 @@ export default defineConfig({
|
||||
{
|
||||
text: '附录',
|
||||
items: [
|
||||
{ text: 'AI 能力词典', link: '/zh-cn/appendix/ai-capability-dictionary' }
|
||||
{ text: 'AI 能力词典', link: '/zh-cn/appendix/ai-capability-dictionary' },
|
||||
{ text: '终端入门', link: '/zh-cn/appendix/terminal-intro' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
@@ -10,6 +10,16 @@ import './style.css'
|
||||
import Layout from './Layout.vue'
|
||||
import StepBar from './components/StepBar.vue'
|
||||
import ChapterIntroduction from './components/ChapterIntroduction.vue'
|
||||
import WebTerminal from './components/appendix/terminal-intro/WebTerminal.vue'
|
||||
import TerminalGrid from './components/appendix/terminal-intro/TerminalGrid.vue'
|
||||
import CellInspector from './components/appendix/terminal-intro/CellInspector.vue'
|
||||
import EscapeSequences from './components/appendix/terminal-intro/EscapeSequences.vue'
|
||||
import InputVisualizer from './components/appendix/terminal-intro/InputVisualizer.vue'
|
||||
import SignalsDemo from './components/appendix/terminal-intro/SignalsDemo.vue'
|
||||
import FlowDiagram from './components/appendix/terminal-intro/FlowDiagram.vue'
|
||||
import AdvancedTUIDemo from './components/appendix/terminal-intro/AdvancedTUIDemo.vue'
|
||||
import ArchitectureDemo from './components/appendix/terminal-intro/ArchitectureDemo.vue'
|
||||
import TerminalDefinition from './components/appendix/terminal-intro/TerminalDefinition.vue'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
@@ -18,6 +28,16 @@ export default {
|
||||
app.use(ElementPlus)
|
||||
app.component('StepBar', StepBar)
|
||||
app.component('ChapterIntroduction', ChapterIntroduction)
|
||||
app.component('WebTerminal', WebTerminal)
|
||||
app.component('TerminalGrid', TerminalGrid)
|
||||
app.component('CellInspector', CellInspector)
|
||||
app.component('EscapeSequences', EscapeSequences)
|
||||
app.component('InputVisualizer', InputVisualizer)
|
||||
app.component('SignalsDemo', SignalsDemo)
|
||||
app.component('FlowDiagram', FlowDiagram)
|
||||
app.component('AdvancedTUIDemo', AdvancedTUIDemo)
|
||||
app.component('ArchitectureDemo', ArchitectureDemo)
|
||||
app.component('TerminalDefinition', TerminalDefinition)
|
||||
},
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
|
||||
Reference in New Issue
Block a user