feat: comprehensive documentation and demo updates

- Update READMEs and docs across multiple languages
- Enhance interactive demos for Agent, LLM, VLM, Audio, Image Gen, Terminal, and Web Basics
- Add new appendix sections for Database and IDE intros
- Update VitePress config, theme, and utility scripts
- Clean up unused assets and components
This commit is contained in:
sanbuphy
2026-01-16 19:10:21 +08:00
parent c8567ce23f
commit 73f4788d7e
150 changed files with 19530 additions and 13401 deletions
@@ -20,7 +20,7 @@
<div class="tab">Logging</div>
</div>
</div>
<div class="tui-body">
<div class="sidebar" :style="{ width: sidebarWidth + '%' }">
<div class="list-item success">
@@ -39,13 +39,15 @@
<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="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>
@@ -82,12 +84,12 @@ const simulateResize = () => {
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'
@@ -169,10 +171,18 @@ const simulateResize = () => {
width: 16px;
}
.success .icon { color: #22c55e; }
.warning .icon { color: #eab308; }
.error .icon { color: #ef4444; }
.pending .icon { color: #666; }
.success .icon {
color: #22c55e;
}
.warning .icon {
color: #eab308;
}
.error .icon {
color: #ef4444;
}
.pending .icon {
color: #666;
}
.main-content {
flex: 1;
@@ -190,7 +200,9 @@ const simulateResize = () => {
color: #aaa;
}
.text-success { color: #22c55e; }
.text-success {
color: #22c55e;
}
.info-row {
color: #666;
@@ -37,7 +37,11 @@
</div>
</div>
<div class="diagram-container" @click="nextStep" :class="{ 'clickable': currentStep < totalSteps }">
<div
class="diagram-container"
@click="nextStep"
:class="{ clickable: currentStep < totalSteps }"
>
<!-- Click Overlay Hint -->
<div class="click-overlay" v-if="currentStep === 0">
<div class="click-hint">
@@ -53,7 +57,7 @@
<span class="text">演示结束点击重置 / Finished (Reset)</span>
</div>
</div>
<!-- Spaces Background -->
<div class="spaces-bg">
<div class="space user-space">
@@ -71,7 +75,9 @@
<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 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>
@@ -82,7 +88,12 @@
</div>
<!-- Connections -->
<div class="connection t-s" :class="{ active: packetState === 't-to-s' || packetState === 's-to-t' }">
<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
@@ -104,7 +115,12 @@
</div>
<!-- Connections -->
<div class="connection s-k" :class="{ active: packetState === 's-to-k' || packetState === 'k-to-s' }">
<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
@@ -128,20 +144,26 @@
<div class="controls">
<div class="btn-group">
<button class="btn primary" @click="nextStep" :disabled="currentStep >= totalSteps">
<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-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].titleEn }}
<span class="divider">|</span>
{{ steps[currentStep - 1].titleZh }}
</div>
<div class="step-desc">
@@ -158,7 +180,10 @@
</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="en">
Click "Start Simulation" to see how the command 'ls' travels through
the system.
</div>
<div class="zh">点击开始演示查看 'ls' 命令如何在系统中流转</div>
</div>
</div>
@@ -170,8 +195,8 @@
import { ref, computed } from 'vue'
const currentStep = ref(0)
const activeNode = ref('terminal')
const packetState = ref(null)
const activeNode = ref('terminal')
const packetState = ref(null)
const terminalLines = ref([])
const currentInput = ref('')
const shellStatus = ref('Idle')
@@ -181,12 +206,13 @@ const kernelIcon = ref('💤')
const steps = [
{
titleEn: "1. User Input",
titleZh: "1. 用户输入",
descEn: "You type 'ls' in the terminal window. The terminal captures your keystrokes.",
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)”下缓冲输入,直到你按下回车键。",
techZh: '终端在“加工模式 (Cooked Mode)”下缓冲输入,直到你按下回车键。',
action: async () => {
activeNode.value = 'terminal'
currentInput.value = 'l'
@@ -195,12 +221,13 @@ const steps = [
}
},
{
titleEn: "2. Transmission",
titleZh: "2. 传输",
descEn: "The Terminal sends the characters 'l', 's', and 'Enter' to the Shell.",
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) 以字节流的形式传输。",
techEn: 'Data travels via standard input (stdin) as a byte stream.',
techZh: '数据通过标准输入 (stdin) 以字节流的形式传输。',
action: async () => {
packetState.value = 't-to-s'
await wait(1000)
@@ -208,10 +235,10 @@ const steps = [
}
},
{
titleEn: "3. Shell Parsing",
titleZh: "3. Shell 解析",
descEn: "The Shell (Waiter) translates your command for the Kernel.",
descZh: "Shell(服务员)接收指令,并将其翻译成内核能听懂的请求。",
titleEn: '3. Shell Parsing',
titleZh: '3. Shell 解析',
descEn: 'The Shell (Waiter) translates your command for the Kernel.',
descZh: 'Shell(服务员)接收指令,并将其翻译成内核能听懂的请求。',
techEn: "Shell tokenizes input, finds the 'ls' executable in $PATH.",
techZh: "Shell 对输入进行分词,并在 $PATH 环境变量中查找 'ls' 可执行文件。",
action: async () => {
@@ -221,12 +248,12 @@ const steps = [
}
},
{
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()` 等系统调用。",
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)
@@ -234,12 +261,12 @@ const steps = [
}
},
{
titleEn: "5. Kernel Execution",
titleZh: "5. 内核执行",
descEn: "The Kernel (Kitchen) executes the request by accessing hardware.",
descZh: "内核(后厨)直接操作硬件(如磁盘)来执行实际任务。",
techEn: "Kernel driver accesses the file system (APFS/ext4).",
techZh: "内核驱动程序访问文件系统 (APFS/ext4)。",
titleEn: '5. Kernel Execution',
titleZh: '5. 内核执行',
descEn: 'The Kernel (Kitchen) executes the request by accessing hardware.',
descZh: '内核(后厨)直接操作硬件(如磁盘)来执行实际任务。',
techEn: 'Kernel driver accesses the file system (APFS/ext4).',
techZh: '内核驱动程序访问文件系统 (APFS/ext4)。',
action: async () => {
activeNode.value = 'kernel'
kernelIcon.value = '💾'
@@ -249,12 +276,12 @@ const steps = [
}
},
{
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: "系统调用返回文件描述符或结构体数据。",
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 = '💤'
@@ -264,12 +291,13 @@ const steps = [
}
},
{
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 颜色代码。",
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 = '🎨'
@@ -278,12 +306,12 @@ const steps = [
}
},
{
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。",
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 = '💤'
@@ -293,12 +321,12 @@ const steps = [
}
},
{
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: "终端模拟器将字形渲染到帧缓冲区中。",
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']
@@ -307,17 +335,16 @@ const steps = [
}
]
const totalSteps = steps.length
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
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()
@@ -446,9 +473,15 @@ const reset = () => {
}
@keyframes pulse-bg {
0% { background: rgba(0, 0, 0, 0.4); }
50% { background: rgba(0, 0, 0, 0.2); }
100% { background: rgba(0, 0, 0, 0.4); }
0% {
background: rgba(0, 0, 0, 0.4);
}
50% {
background: rgba(0, 0, 0, 0.2);
}
100% {
background: rgba(0, 0, 0, 0.4);
}
}
.completed-overlay {
@@ -512,16 +545,18 @@ const reset = () => {
}
.user-space {
flex: 2;
flex: 2;
background: rgba(34, 211, 238, 0.03);
border-right: 1px dashed #3f3f46;
border-radius: 8px 0 0 8px;
align-items: flex-start;
/* Ensure User Space (containing Shell) is below the Barrier Label */
z-index: 0;
z-index: 0;
}
.user-space .space-header { color: #22d3ee; }
.user-space .space-header {
color: #22d3ee;
}
.kernel-space {
flex: 1;
@@ -531,7 +566,9 @@ const reset = () => {
z-index: 0;
}
.kernel-space .space-header { color: #ef4444; }
.kernel-space .space-header {
color: #ef4444;
}
.barrier {
width: 2px;
@@ -556,8 +593,6 @@ const reset = () => {
opacity: 0.3;
}
.node {
background: #18181b;
border: 2px solid #27272a;
@@ -573,7 +608,7 @@ const reset = () => {
/* Specific z-index for Shell to prevent it from covering barrier label */
.node.shell {
z-index: 1;
z-index: 1;
}
.node.active {
@@ -603,7 +638,8 @@ const reset = () => {
color: #71717a;
}
.screen, .process-box {
.screen,
.process-box {
flex: 1;
padding: 10px;
display: flex;
@@ -693,18 +729,26 @@ const reset = () => {
font-size: 11px;
font-weight: bold;
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
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); }
from {
opacity: 0;
transform: translate(-50%, 5px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes blink {
50% { opacity: 0; }
50% {
opacity: 0;
}
}
.controls {
@@ -795,27 +839,33 @@ const reset = () => {
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
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;
@@ -824,25 +874,37 @@ const reset = () => {
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%); }
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); }
0% {
top: 100%;
transform: translateY(-100%);
}
100% {
top: 0;
transform: translateY(0);
}
}
}
</style>
</style>
@@ -7,18 +7,31 @@
<span class="dot green"></span>
<span class="title">Terminal - Buffer Switching Demo</span>
</div>
<div class="screen-container">
<!-- Main Buffer (Layer 0) -->
<div class="buffer main-buffer">
<div class="line"><span class="prompt"></span> <span class="cmd">ls -la</span></div>
<div class="line">
<span class="prompt"></span> <span class="cmd">ls -la</span>
</div>
<div class="line output">total 16</div>
<div class="line output">drwxr-xr-x 2 user staff 64 Jan 15 10:00 .</div>
<div class="line output">drwxr-xr-x 4 user staff 128 Jan 15 09:55 ..</div>
<div class="line output">-rw-r--r-- 1 user staff 1024 Jan 15 10:00 notes.txt</div>
<div class="line"><span class="prompt"></span> <span class="cmd">echo "Hello World"</span></div>
<div class="line output">
drwxr-xr-x 2 user staff 64 Jan 15 10:00 .
</div>
<div class="line output">
drwxr-xr-x 4 user staff 128 Jan 15 09:55 ..
</div>
<div class="line output">
-rw-r--r-- 1 user staff 1024 Jan 15 10:00 notes.txt
</div>
<div class="line">
<span class="prompt"></span>
<span class="cmd">echo "Hello World"</span>
</div>
<div class="line output">Hello World</div>
<div class="line"><span class="prompt"></span> <span class="cmd">vim notes.txt</span></div>
<div class="line">
<span class="prompt"></span> <span class="cmd">vim notes.txt</span>
</div>
<!-- The cursor would be here if not in vim -->
</div>
@@ -30,12 +43,21 @@
<span class="modified">[+]</span>
</div>
<div class="vim-body">
<div class="line-num">1</div><div class="code">This is a text file opened in Vim.</div>
<div class="line-num">2</div><div class="code"></div>
<div class="line-num">3</div><div class="code">Notice how this interface takes up</div>
<div class="line-num">4</div><div class="code">the entire screen?</div>
<div class="line-num">5</div><div class="code"></div>
<div class="line-num">6</div><div class="code">It is running in the <span class="highlight">Alternate Buffer</span>.</div>
<div class="line-num">1</div>
<div class="code">This is a text file opened in Vim.</div>
<div class="line-num">2</div>
<div class="code"></div>
<div class="line-num">3</div>
<div class="code">Notice how this interface takes up</div>
<div class="line-num">4</div>
<div class="code">the entire screen?</div>
<div class="line-num">5</div>
<div class="code"></div>
<div class="line-num">6</div>
<div class="code">
It is running in the
<span class="highlight">Alternate Buffer</span>.
</div>
<div class="line-num">~</div>
<div class="line-num">~</div>
</div>
@@ -55,14 +77,20 @@
<div class="description">
<div v-if="!isAltBufferActive">
<p><strong>Current: Primary Buffer (主缓冲区)</strong></p>
<p>This is the standard scrolling log. Commands are executed line by line.</p>
<p>
This is the standard scrolling log. Commands are executed line by
line.
</p>
<button class="action-btn" @click="openVim">
Execute `vim notes.txt`
</button>
</div>
<div v-else>
<p><strong>Current: Alternate Buffer (备用缓冲区)</strong></p>
<p>A separate "canvas" for full-screen apps. It hides the history but doesn't delete it.</p>
<p>
A separate "canvas" for full-screen apps. It hides the history but
doesn't delete it.
</p>
<button class="action-btn red" @click="quitVim">
Execute `:q` (Quit)
</button>
@@ -101,7 +129,7 @@ const quitVim = () => {
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
border: 1px solid #333;
}
@@ -118,9 +146,15 @@ const quitVim = () => {
height: 10px;
border-radius: 50%;
}
.red { background: #ff5f56; }
.yellow { background: #ffbd2e; }
.green { background: #27c93f; }
.red {
background: #ff5f56;
}
.yellow {
background: #ffbd2e;
}
.green {
background: #27c93f;
}
.title {
margin-left: 10px;
@@ -281,4 +315,4 @@ p {
font-size: 14px;
color: var(--vp-c-text-1);
}
</style>
</style>
@@ -17,26 +17,28 @@
{{ 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"
<button
v-for="c in chars"
:key="c"
:class="{ active: char === c }"
@click="char = c"
>{{ c }}</button>
>
{{ c }}
</button>
</div>
</div>
<div class="control-group">
<label>FOREGROUND</label>
<div class="color-palette">
<div
v-for="color in colors"
:key="color"
<div
v-for="color in colors"
:key="color"
class="color-swatch"
:style="{ backgroundColor: color }"
:class="{ active: fgColor === color }"
@@ -48,15 +50,23 @@
<div class="control-group">
<label>BACKGROUND</label>
<div class="color-palette">
<div
<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;"
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"
<div
v-for="color in bgColors"
:key="color"
class="color-swatch"
:style="{ backgroundColor: color }"
:class="{ active: bgColor === color }"
@@ -69,11 +79,11 @@
<label>ATTRIBUTES</label>
<div class="toggles">
<label class="toggle">
<input type="checkbox" v-model="isBold">
<input type="checkbox" v-model="isBold" />
<span>Bold</span>
</label>
<label class="toggle">
<input type="checkbox" v-model="isUnderline">
<input type="checkbox" v-model="isUnderline" />
<span>Underline</span>
</label>
</div>
@@ -85,13 +95,49 @@
<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 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'
'#ef4444',
'#22c55e',
'#eab308',
'#3b82f6',
'#a855f7',
'#06b6d4',
'#f3f4f6',
'#6b7280',
'#f87171',
'#4ade80',
'#facc15',
'#60a5fa',
'#c084fc',
'#22d3ee',
'#ffffff'
]
const bgColors = [
'#000000', '#1f2937', '#111827', '#374151', '#1e3a8a', '#3f2c08', '#310b0b'
'#000000',
'#1f2937',
'#111827',
'#374151',
'#1e3a8a',
'#3f2c08',
'#310b0b'
]
const char = ref('A')
@@ -208,7 +254,7 @@ const cellStyle = computed(() => ({
.color-swatch.active {
border-color: #fff;
transform: scale(1.1);
box-shadow: 0 0 10px rgba(0,0,0,0.5);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
.toggles {
@@ -1,25 +1,19 @@
<template>
<div class="cooked-raw-demo">
<div class="mode-switch">
<button
:class="{ active: mode === 'cooked' }"
@click="setMode('cooked')"
>
<button :class="{ active: mode === 'cooked' }" @click="setMode('cooked')">
🥘 Cooked Mode (Standard)
</button>
<button
:class="{ active: mode === 'raw' }"
@click="setMode('raw')"
>
<button :class="{ active: mode === 'raw' }" @click="setMode('raw')">
🥩 Raw Mode (Vim/Games)
</button>
</div>
<div class="demo-container" @click="focusInput">
<!-- Hidden Input for capturing keystrokes -->
<input
<input
ref="inputRef"
type="text"
type="text"
class="hidden-input"
@keydown="handleKey"
@blur="isFocused = false"
@@ -28,12 +22,13 @@
<!-- Visualization -->
<div class="flow-diagram">
<!-- 1. User Input -->
<div class="stage user-input" :class="{ focused: isFocused }">
<div class="stage-title">1. Keyboard Input</div>
<div class="key-visual">
<span v-if="lastPressedKey" class="key-cap">{{ lastPressedKey }}</span>
<span v-if="lastPressedKey" class="key-cap">{{
lastPressedKey
}}</span>
<span v-else class="placeholder">Type here...</span>
</div>
<div class="status-text" v-if="!isFocused">Click to focus</div>
@@ -42,7 +37,10 @@
<div class="arrow"></div>
<!-- 2. OS Buffer (Only for Cooked) -->
<div class="stage buffer" :class="{ disabled: mode === 'raw', active: mode === 'cooked' }">
<div
class="stage buffer"
:class="{ disabled: mode === 'raw', active: mode === 'cooked' }"
>
<div class="stage-title">
2. Line Buffer (Kernel)
<span class="badge" v-if="mode === 'cooked'">Active</span>
@@ -50,7 +48,9 @@
</div>
<div class="buffer-content">
<template v-if="mode === 'cooked'">
<span v-for="(char, i) in buffer" :key="i" class="char">{{ char }}</span>
<span v-for="(char, i) in buffer" :key="i" class="char">{{
char
}}</span>
<span class="cursor">_</span>
</template>
<template v-else>
@@ -58,7 +58,9 @@
</template>
</div>
<div class="explanation">
<span v-if="mode === 'cooked'">Waiting for Enter... (Backspace works)</span>
<span v-if="mode === 'cooked'"
>Waiting for Enter... (Backspace works)</span
>
<span v-else>No buffering. Every key is sent immediately.</span>
</div>
</div>
@@ -77,7 +79,6 @@
</div>
</div>
</div>
</div>
</div>
</div>
@@ -110,9 +111,9 @@ const focusInput = () => {
const handleKey = (e) => {
e.preventDefault()
const key = e.key
// Visual feedback
if (key === ' ') lastPressedKey.value = 'Space'
else if (key === 'Enter') lastPressedKey.value = 'Enter'
@@ -122,7 +123,16 @@ const handleKey = (e) => {
// Clear visual feedback after delay
setTimeout(() => {
if (lastPressedKey.value === (key === ' ' ? 'Space' : (key === 'Enter' ? 'Enter' : (key === 'Backspace' ? '⌫' : key)))) {
if (
lastPressedKey.value ===
(key === ' '
? 'Space'
: key === 'Enter'
? 'Enter'
: key === 'Backspace'
? '⌫'
: key)
) {
// lastPressedKey.value = '' // Optional: keep last key visible
}
}, 500)
@@ -345,7 +355,8 @@ const handleRawMode = (e) => {
color: #ccc;
}
.cursor, .app-cursor {
.cursor,
.app-cursor {
display: inline-block;
width: 8px;
background: currentColor;
@@ -353,10 +364,20 @@ const handleRawMode = (e) => {
}
@keyframes pop {
0% { transform: scale(0.9); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
0% {
transform: scale(0.9);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes blink { 50% { opacity: 0; } }
</style>
@keyframes blink {
50% {
opacity: 0;
}
}
</style>
@@ -5,7 +5,9 @@
<div class="controls">
<button @click="reset" :disabled="isPlaying">Reset</button>
<button @click="togglePlay" class="play-btn">
{{ isPlaying ? '⏸ Pause' : (isFinished ? '↺ Replay' : '▶ Play Animation') }}
{{
isPlaying ? '⏸ Pause' : isFinished ? '↺ Replay' : '▶ Play Animation'
}}
</button>
</div>
</div>
@@ -15,16 +17,19 @@
<div class="label">Input Byte Stream / 输入字节流</div>
<div class="stream-track">
<div class="stream-window-mask">
<div class="stream-content" :style="{ transform: `translateX(-${currentIndex * 40}px)` }">
<div
v-for="(char, index) in charStream"
<div
class="stream-content"
:style="{ transform: `translateX(-${currentIndex * 40}px)` }"
>
<div
v-for="(char, index) in charStream"
:key="index"
class="char-box"
:class="{
'active': index === currentIndex,
'processed': index < currentIndex,
'special': char.isSpecial,
'arg': char.isArg
:class="{
active: index === currentIndex,
processed: index < currentIndex,
special: char.isSpecial,
arg: char.isArg
}"
>
<span class="char-val">{{ char.display }}</span>
@@ -47,11 +52,14 @@
<div class="state-desc">Print Characters</div>
</div>
<div class="arrow-right"></div>
<div class="state-box warning" :class="{ active: parserState === 'ESCAPE' }">
<div
class="state-box warning"
:class="{ active: parserState === 'ESCAPE' }"
>
<div class="state-name">ESCAPE MODE</div>
<div class="state-desc">Buffer Command...</div>
</div>
<!-- 指令说明框 -->
<div class="action-log" v-if="lastAction">
<span class="action-icon"></span>
@@ -63,18 +71,22 @@
<div class="terminal-screen">
<div class="label">Terminal Screen / 屏幕显示</div>
<div class="screen-content">
<span
v-for="(char, index) in outputBuffer"
<span
v-for="(char, index) in outputBuffer"
:key="index"
:style="char.style"
>{{ char.val }}</span><span class="cursor">_</span>
>{{ char.val }}</span
><span class="cursor">_</span>
</div>
</div>
<div class="explanation">
<p>
<span class="badge normal">Normal</span> 模式下字符直接上屏
<span class="badge escape">Escape</span> 模式下遇到 <code>ESC</code> 终端<strong>停止输出</strong>开始收集字符作为指令直到指令结束 <code>m</code>并执行
<span class="badge escape">Escape</span> 模式下遇到
<code>ESC</code>
终端<strong>停止输出</strong>开始收集字符作为指令直到指令结束
<code>m</code>并执行
</p>
</div>
</div>
@@ -135,7 +147,7 @@ const togglePlay = () => {
const play = async () => {
if (isPlaying.value) return
isPlaying.value = true
// If finished, reset first
if (isFinished.value) {
reset()
@@ -146,7 +158,7 @@ const play = async () => {
if (!isPlaying.value) break
const char = charStream.value[currentIndex.value]
// Processing Logic
if (parserState.value === 'NORMAL') {
if (char.val === '\x1B') {
@@ -171,23 +183,23 @@ const play = async () => {
currentStyle.value = {}
lastAction.value = 'Execute: Reset Style'
}
// Small delay to show "Executing" state
await new Promise(r => setTimeout(r, 200))
await new Promise((r) => setTimeout(r, 200))
parserState.value = 'NORMAL'
} else {
lastAction.value = 'Buffering...'
}
}
await new Promise(r => setTimeout(r, 600)) // Animation speed
await new Promise((r) => setTimeout(r, 600)) // Animation speed
// Check playing again after wait
if (!isPlaying.value) break
currentIndex.value++
}
if (currentIndex.value >= charStream.value.length) {
isPlaying.value = false
isFinished.value = true
@@ -272,8 +284,20 @@ const play = async () => {
position: relative;
height: 100%;
/* Mask gradient to fade edges */
mask-image: linear-gradient(to right, transparent, black 40%, black 60%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, black 40%, black 60%, transparent);
mask-image: linear-gradient(
to right,
transparent,
black 40%,
black 60%,
transparent
);
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 40%,
black 60%,
transparent
);
}
.stream-content {
@@ -290,7 +314,7 @@ const play = async () => {
- Item[0] center is at: 18px (half width)
- So we need to shift left by 18px initially.
*/
margin-left: -18px;
margin-left: -18px;
transition: transform 0.5s cubic-bezier(0.25, 1, 0.5, 1);
}
@@ -312,7 +336,7 @@ const play = async () => {
background: #fff;
color: #000;
transform: scale(1.1);
box-shadow: 0 0 10px rgba(255,255,255,0.5);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
z-index: 10;
border-color: #fff;
}
@@ -339,8 +363,15 @@ const play = async () => {
color: #fff;
}
.char-val { font-size: 14px; font-weight: bold; }
.char-code { font-size: 9px; opacity: 0.7; margin-top: 2px; }
.char-val {
font-size: 14px;
font-weight: bold;
}
.char-code {
font-size: 9px;
opacity: 0.7;
margin-top: 2px;
}
.pointer {
position: absolute;
@@ -350,9 +381,14 @@ const play = async () => {
text-align: center;
color: #0dbc79;
}
.arrow { font-size: 20px; line-height: 1; }
.pointer-label { font-size: 10px; white-space: nowrap; }
.arrow {
font-size: 20px;
line-height: 1;
}
.pointer-label {
font-size: 10px;
white-space: nowrap;
}
/* State Machine */
.parser-state-machine {
@@ -389,10 +425,19 @@ const play = async () => {
color: #000;
}
.state-name { font-weight: bold; font-size: 12px; }
.state-desc { font-size: 10px; opacity: 0.8; }
.state-name {
font-weight: bold;
font-size: 12px;
}
.state-desc {
font-size: 10px;
opacity: 0.8;
}
.arrow-right { color: #555; font-size: 18px; }
.arrow-right {
color: #555;
font-size: 18px;
}
.action-log {
margin-left: 20px;
@@ -440,10 +485,24 @@ const play = async () => {
font-size: 11px;
color: #000;
}
.badge.normal { background: #0dbc79; }
.badge.escape { background: #e5e510; }
@keyframes blink { 50% { opacity: 0; } }
@keyframes flash { 0% { background: #333; } 100% { background: #000; } }
.badge.normal {
background: #0dbc79;
}
.badge.escape {
background: #e5e510;
}
@keyframes blink {
50% {
opacity: 0;
}
}
@keyframes flash {
0% {
background: #333;
}
100% {
background: #000;
}
}
</style>
@@ -21,8 +21,8 @@
<span class="zh">16 色调色板</span>
</div>
<div class="palette-grid">
<div
v-for="(color, index) in palette"
<div
v-for="(color, index) in palette"
:key="index"
class="swatch"
:style="{ backgroundColor: color }"
@@ -31,7 +31,8 @@
></div>
</div>
<div class="hint-text" v-if="activeColor">
Sequence: <span class="code">^[[38;5;{{ palette.indexOf(activeColor) }}m</span>
Sequence:
<span class="code">^[[38;5;{{ palette.indexOf(activeColor) }}m</span>
</div>
</div>
@@ -85,25 +86,32 @@
<div class="preview">
<div class="terminal-window">
<div class="window-header">
<div class="dots">
<span></span><span></span><span></span>
</div>
<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-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">
<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-placeholder"
v-if="cursorMode === 'absolute'"
></span>
<span class="cursor-block" :style="cursorStyle"></span>
</div>
</div>
@@ -116,8 +124,22 @@
import { ref, computed } from 'vue'
const palette = [
'#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5',
'#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#ffffff'
'#000000',
'#cd3131',
'#0dbc79',
'#e5e510',
'#2472c8',
'#bc3fbc',
'#11a8cd',
'#e5e5e5',
'#666666',
'#f14c4c',
'#23d18b',
'#f5f543',
'#3b8eea',
'#d670d6',
'#29b8db',
'#ffffff'
]
const activeColor = ref(null)
@@ -303,8 +325,14 @@ button.active {
justify-content: space-between;
}
.code { color: #facc15; font-weight: bold; }
.desc { color: #a1a1aa; font-size: 12px; }
.code {
color: #facc15;
font-weight: bold;
}
.desc {
color: #a1a1aa;
font-size: 12px;
}
.terminal-window {
background: #000;
@@ -407,7 +435,9 @@ button.active {
}
@keyframes blink {
50% { opacity: 0; }
50% {
opacity: 0;
}
}
@media (max-width: 768px) {
@@ -14,7 +14,7 @@
<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>
@@ -56,11 +56,9 @@
<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-header"><span></span><span></span><span></span></div>
<div class="term-body">
<span class="prompt">$ </span>
<span class="typed-text">{{ displayText }}</span>
@@ -74,7 +72,11 @@
</div>
<div class="controls">
<button class="play-btn" @click="startAnimation" :disabled="isAnimating">
<button
class="play-btn"
@click="startAnimation"
:disabled="isAnimating"
>
{{ isAnimating ? 'Simulating...' : 'Simulate Keystroke' }}
</button>
</div>
@@ -96,13 +98,13 @@ const statusColor = computed(() => {
return 'text-green'
})
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
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'
@@ -159,12 +161,14 @@ const startAnimation = async () => {
color: #ccc;
}
.stack-col, .output-col {
.stack-col,
.output-col {
display: flex;
flex-direction: column;
}
.stack-label, .output-label {
.stack-label,
.output-label {
color: #eab308;
font-size: 12px;
margin-bottom: 20px;
@@ -245,8 +249,12 @@ const startAnimation = async () => {
color: #fff;
}
.prompt { color: #888; }
.typed-text { color: #22c55e; }
.prompt {
color: #888;
}
.typed-text {
color: #22c55e;
}
.cursor {
display: inline-block;
@@ -262,7 +270,9 @@ const startAnimation = async () => {
}
@keyframes blink {
50% { opacity: 0; }
50% {
opacity: 0;
}
}
.status-box {
@@ -285,8 +295,12 @@ const startAnimation = async () => {
line-height: 1.5;
}
.text-red { color: #ef4444; }
.text-green { color: #22c55e; }
.text-red {
color: #ef4444;
}
.text-green {
color: #22c55e;
}
.play-btn {
width: 100%;
@@ -12,7 +12,12 @@
- 历史记录记录最近几次按键的编码流
-->
<template>
<div class="input-visualizer" tabindex="0" @keydown="handleKeydown" @blur="handleBlur">
<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>
@@ -22,7 +27,7 @@
<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>
@@ -33,9 +38,10 @@
<div class="value code">{{ currentKey.sequence || '-' }}</div>
</div>
</div>
<div class="char-display">
Character: <span class="char-val">{{ currentKey.charDisplay || '-' }}</span>
Character:
<span class="char-val">{{ currentKey.charDisplay || '-' }}</span>
</div>
</div>
@@ -71,7 +77,7 @@ const handleBlur = () => {
const handleKeydown = (e) => {
e.preventDefault()
let name = e.key
let bytes = ''
let sequence = ''
@@ -80,15 +86,15 @@ const handleKeydown = (e) => {
// 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' },
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]) {
@@ -117,7 +123,7 @@ const handleKeydown = (e) => {
const keyData = { name, bytes, sequence, charDisplay }
currentKey.value = keyData
history.value.unshift(keyData)
if (history.value.length > 5) history.value.pop()
}
@@ -138,7 +144,9 @@ const handleKeydown = (e) => {
flex-direction: column;
justify-content: space-between;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.input-visualizer:focus {
@@ -190,7 +198,9 @@ const handleKeydown = (e) => {
flex-direction: column;
justify-content: center;
align-items: center;
transition: opacity 0.2s, filter 0.2s;
transition:
opacity 0.2s,
filter 0.2s;
}
.blur-content {
@@ -237,8 +247,12 @@ const handleKeydown = (e) => {
font-variant-numeric: tabular-nums;
}
.highlight { color: #facc15; /* Yellow 400 */ }
.code { color: #22d3ee; /* Cyan 400 */ }
.highlight {
color: #facc15; /* Yellow 400 */
}
.code {
color: #22d3ee; /* Cyan 400 */
}
.char-display {
color: #a1a1aa; /* Zinc 400 */
@@ -275,9 +289,9 @@ const handleKeydown = (e) => {
border: 1px solid #27272a;
}
.arrow {
.arrow {
color: #71717a; /* Lighter grey for better visibility */
margin: 0 8px;
margin: 0 8px;
}
.h-name {
@@ -285,8 +299,8 @@ const handleKeydown = (e) => {
color: #e4e4e7;
}
.h-bytes {
color: #facc15;
.h-bytes {
color: #facc15;
font-family: monospace;
}
</style>
@@ -6,37 +6,41 @@
## 组件列表
| 组件名 | 描述 | 对应文档章节 |
| :--- | :--- | :--- |
| **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) 的高级布局能力(如面板、进度条)。 | (文档中可能引用) |
| 组件名 | 描述 | 对应文档章节 |
| :------------------------- | :------------------------------------------------------------------------------------ | :--------------- |
| **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`
@@ -15,8 +15,8 @@
<div class="signals-demo">
<div class="left-panel">
<div class="signal-list">
<div
class="signal-item"
<div
class="signal-item"
:class="{ active: activeSignal === 'SIGINT' }"
@click="sendSignal('SIGINT')"
>
@@ -27,8 +27,8 @@
<div class="signal-name">SIGINT</div>
</div>
<div
class="signal-item"
<div
class="signal-item"
:class="{ active: activeSignal === 'SIGTSTP' }"
@click="sendSignal('SIGTSTP')"
>
@@ -43,22 +43,33 @@
<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>
<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>
<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.
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>
<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>
<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.
Example: Pressing Ctrl+Z pauses a running editor like vim, returning
you to the shell.
</div>
</div>
<div v-else>
@@ -74,12 +85,18 @@
<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">
<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>
<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>
@@ -88,7 +105,9 @@
</div>
<div class="controls">
<button class="btn" @click="runCommand" :disabled="isRunning">Run Command</button>
<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>
@@ -97,9 +116,10 @@
<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.
Click "Run Command" to start a simulated process, then try sending
different signals.
</p>
</div>
</div>
@@ -110,9 +130,7 @@ import { ref, computed } from 'vue'
const activeSignal = ref('SIGINT')
const isRunning = ref(false)
const lines = ref([
{ type: 'input', text: '$ sleep 100' }
])
const lines = ref([{ type: 'input', text: '$ sleep 100' }])
const processState = ref('Running')
const inputBuffer = ref('')
@@ -131,7 +149,7 @@ const runCommand = () => {
const sendSignal = (sig) => {
activeSignal.value = sig
if (!isRunning.value && sig === 'SIGINT') return
if (sig === 'SIGINT') {
@@ -142,7 +160,10 @@ const sendSignal = (sig) => {
} 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' })
lines.value.push({
type: 'output',
text: '[1]+ Stopped sleep 100'
})
isRunning.value = false
processState.value = 'Process suspended (stopped)'
}
@@ -161,7 +182,10 @@ reset()
<style scoped>
.signals-demo {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); /* 自动适应宽度,不够时换行 */
grid-template-columns: repeat(
auto-fit,
minmax(280px, 1fr)
); /* 自动适应宽度,不够时换行 */
gap: 30px;
background: #09090b;
padding: 30px;
@@ -237,9 +261,15 @@ reset()
font-weight: bold;
}
.highlight { color: #facc15; }
.signal-green { color: #22c55e; }
.signal-blue { color: #3b82f6; }
.highlight {
color: #facc15;
}
.signal-green {
color: #22c55e;
}
.signal-blue {
color: #3b82f6;
}
.info-desc {
color: #a1a1aa;
@@ -271,7 +301,9 @@ reset()
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);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.window-header {
@@ -300,10 +332,19 @@ reset()
margin-bottom: 5px;
}
.control { color: #ef4444; }
.output { color: #d4d4d8; }
.input { color: #fff; }
.prompt { color: #71717a; margin-right: 8px; }
.control {
color: #ef4444;
}
.output {
color: #d4d4d8;
}
.input {
color: #fff;
}
.prompt {
color: #71717a;
margin-right: 8px;
}
.cursor {
display: inline-block;
@@ -348,9 +389,15 @@ reset()
margin-top: 10px;
}
.state-green { color: #22c55e; }
.state-red { color: #ef4444; }
.state-blue { color: #3b82f6; }
.state-green {
color: #22c55e;
}
.state-red {
color: #ef4444;
}
.state-blue {
color: #3b82f6;
}
.instruction {
color: #a1a1aa;
@@ -1,16 +1,10 @@
<template>
<div class="terminal-definition">
<div class="mode-switch">
<button
:class="{ active: mode === 'cli' }"
@click="mode = 'cli'"
>
<button :class="{ active: mode === 'cli' }" @click="mode = 'cli'">
🖥 CLI (命令行界面)
</button>
<button
:class="{ active: mode === 'gui' }"
@click="mode = 'gui'"
>
<button :class="{ active: mode === 'gui' }" @click="mode = 'gui'">
🖱 GUI (图形用户界面)
</button>
</div>
@@ -21,7 +15,28 @@
<!-- 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>
<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>
@@ -31,8 +46,8 @@
<div class="stream-path">
<div class="stream-line"></div>
<div class="stream-label">Character Stream / 字符流</div>
<div
v-for="char in activeChars"
<div
v-for="char in activeChars"
:key="char.id"
class="stream-char"
:style="{ left: char.progress + '%' }"
@@ -45,16 +60,20 @@
<div class="stage output-stage">
<div class="terminal-screen">
<div class="screen-content">
<span class="prompt">$</span> {{ typedContent }}<span class="cursor">_</span>
<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>
<p>
<strong>CLI (Command Line Interface)</strong>:
这种模式下计算机只认识字符你的每一次按键都会被转换成编码发送给系统系统处理后返回文字结果它不关心你在哪里点击只关心你输入了什么
</p>
</div>
<div class="control-bar">
@@ -71,7 +90,20 @@
<!-- 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>
<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>
@@ -81,7 +113,7 @@
<div class="stream-path">
<div class="stream-line dashed"></div>
<div class="stream-label">Event Loop / 事件循环</div>
<div
<div
v-for="ev in guiEvents"
:key="ev.id"
class="gui-event-packet"
@@ -98,11 +130,22 @@
<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" :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>
<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>
@@ -113,7 +156,11 @@
</div>
<div class="desc-box">
<p><strong>GUI (Graphical User Interface)</strong>: 这种模式下计算机实时追踪鼠标坐标和点击事件并每秒刷新 60 次屏幕像素它更直观但需要消耗大量资源来处理图形渲染</p>
<p>
<strong>GUI (Graphical User Interface)</strong>:
这种模式下计算机实时追踪鼠标坐标和点击事件并每秒刷新 60
次屏幕像素它更直观但需要消耗大量资源来处理图形渲染
</p>
</div>
<div class="control-bar">
@@ -123,7 +170,6 @@
</button>
</div>
</div>
</div>
</template>
@@ -143,9 +189,9 @@ const startSimulation = () => {
isAnimating.value = true
typedContent.value = ''
activeChars.value = []
let index = 0
const processNextChar = () => {
if (index >= demoText.length) {
setTimeout(() => {
@@ -156,36 +202,36 @@ const startSimulation = () => {
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 += 4 // Faster speed
const charObj = activeChars.value.find(c => c.id === charId)
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)
activeChars.value = activeChars.value.filter((c) => c.id !== charId)
typedContent.value += char
// Next char
index++
setTimeout(processNextChar, 100) // Faster typing
}
}, 20)
}
processNextChar()
}
@@ -208,26 +254,29 @@ const startGuiSimulation = () => {
inputMousePosition.value = { x: 80, y: 60 }
screenCursorPosition.value = { x: 80, y: 60 }
guiEvents.value = []
// 1. Move Cursor (Physical Mouse Movement)
let step = 0
const moveInterval = setInterval(() => {
step++
inputMousePosition.value = {
x: 80 - step * 2,
y: 60 - step * 1.5
inputMousePosition.value = {
x: 80 - step * 2,
y: 60 - step * 1.5
}
// Emit Move Event frequently (Simulate high polling rate)
if (step % 2 === 0) {
const targetX = inputMousePosition.value.x
const targetY = inputMousePosition.value.y
emitGuiEvent(`Move(${Math.round(targetX)},${Math.round(targetY)})`, () => {
// When packet arrives: Update screen cursor
screenCursorPosition.value = { x: targetX, y: targetY }
})
emitGuiEvent(
`Move(${Math.round(targetX)},${Math.round(targetY)})`,
() => {
// When packet arrives: Update screen cursor
screenCursorPosition.value = { x: targetX, y: targetY }
}
)
}
if (step >= 20) {
clearInterval(moveInterval)
// 2. Click
@@ -244,17 +293,17 @@ const emitGuiEvent = (type, onArrive) => {
progress: 10
}
guiEvents.value.push(newEvent)
let progress = 10
const packetInterval = setInterval(() => {
progress += 2
const ev = guiEvents.value.find(e => e.id === eventId)
const ev = guiEvents.value.find((e) => e.id === eventId)
if (ev) ev.progress = progress
if (progress >= 90) {
clearInterval(packetInterval)
guiEvents.value = guiEvents.value.filter(e => e.id !== eventId)
guiEvents.value = guiEvents.value.filter((e) => e.id !== eventId)
// Execute callback when packet arrives at Output
if (onArrive) onArrive()
}
@@ -264,21 +313,20 @@ const emitGuiEvent = (type, onArrive) => {
const performClick = () => {
setTimeout(() => {
isGuiClicking.value = true
// Send Click Event
emitGuiEvent('Click(40,30)', () => {
// When packet arrives: Select icon
iconSelected.value = true
iconSelected.value = true
setTimeout(() => {
isGuiAnimating.value = false
}, 1000)
})
setTimeout(() => {
isGuiClicking.value = false
isGuiClicking.value = false
}, 200) // Input click feedback is fast
}, 300)
}
</script>
@@ -380,7 +428,7 @@ const performClick = () => {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.2);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
@@ -439,7 +487,7 @@ const performClick = () => {
left: 0;
pointer-events: none;
transition: transform 0.1s linear; /* Smooth interpolation */
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.5));
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
}
.screen-content {
@@ -481,7 +529,13 @@ const performClick = () => {
}
.stream-line.dashed {
background: repeating-linear-gradient(90deg, #27272a 0, #27272a 6px, transparent 6px, transparent 10px);
background: repeating-linear-gradient(
90deg,
#27272a 0,
#27272a 6px,
transparent 6px,
transparent 10px
);
height: 1px;
}
@@ -539,7 +593,9 @@ const performClick = () => {
}
@keyframes blink {
50% { opacity: 0; }
50% {
opacity: 0;
}
}
.desc-box {
@@ -592,13 +648,13 @@ button:disabled {
height: auto;
gap: 20px;
}
.stream-path {
width: 100%;
height: 40px;
margin: 10px 0;
}
.stream-line {
transform: rotate(90deg);
width: 40px;
@@ -606,4 +662,4 @@ button:disabled {
margin-left: -20px;
}
}
</style>
</style>
@@ -15,13 +15,13 @@
<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"
<div
class="grid-cell"
v-for="(cell, cIndex) in row"
:key="cIndex"
:class="{
:class="{
'active-cursor': cursor.r === rIndex && cursor.c === cIndex,
'drawn': cell.drawn
drawn: cell.drawn
}"
@mousedown.prevent="handleCellMouseDown(rIndex, cIndex)"
@mouseover="handleCellHover(rIndex, cIndex)"
@@ -30,12 +30,12 @@
</div>
</div>
</div>
<div class="controls">
<input
<input
ref="inputRef"
type="text"
v-model="inputText"
type="text"
v-model="inputText"
placeholder="Type here..."
class="text-input"
@keydown="handleKeydown"
@@ -52,9 +52,10 @@ 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 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 })
@@ -76,12 +77,12 @@ const handleKeydown = (e) => {
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
@@ -116,8 +117,8 @@ const handleCellHover = (r, c) => {
}
const clearGrid = () => {
for(let r=0; r<ROW_COUNT; r++) {
for(let c=0; c<COL_COUNT; c++) {
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
}
@@ -181,66 +182,68 @@ onBeforeUnmount(() => {
}
.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;
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;
}
.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>
}
.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>
@@ -4,7 +4,9 @@
<!-- Left Panel: Task Guide -->
<div class="task-panel">
<div class="panel-header">
<span class="panel-title">🎯 实操任务 ({{ currentTaskIndex + 1 }}/{{ tasks.length }})</span>
<span class="panel-title"
>🎯 实操任务 ({{ currentTaskIndex + 1 }}/{{ tasks.length }})</span
>
<div class="os-selector">
<select v-model="currentOS" @change="resetCurrentTask">
<option value="mac">macOS</option>
@@ -18,7 +20,7 @@
<div class="task-content">
<h3>{{ currentTask.title }}</h3>
<p class="task-desc">{{ currentTask.description }}</p>
<div class="ai-helper">
<div class="ai-header">
<span class="ai-icon">🤖</span>
@@ -29,23 +31,36 @@
{{ currentTask.aiQuery }}
</div>
<div class="chat-bubble ai">
<p>{{ currentTask.aiResponse[currentOS] || currentTask.aiResponse.common }}</p>
<p>
{{
currentTask.aiResponse[currentOS] ||
currentTask.aiResponse.common
}}
</p>
<!-- Multiple Commands Support -->
<div v-if="currentTask.commands && currentTask.commands[currentOS]" class="cmd-buttons">
<button
v-for="(cmdItem, idx) in currentTask.commands[currentOS]"
<div
v-if="currentTask.commands && currentTask.commands[currentOS]"
class="cmd-buttons"
>
<button
v-for="(cmdItem, idx) in currentTask.commands[currentOS]"
:key="idx"
class="copy-btn"
class="copy-btn"
@click="copyCommand(cmdItem.cmd)"
>
{{ cmdItem.label || '复制命令' }}
</button>
</div>
<!-- Fallback for Single Command -->
<button
<button
v-else-if="currentTask.expectedCmd"
class="copy-btn"
@click="copyCommand(currentTask.expectedCmd[currentOS] || currentTask.expectedCmd.common)"
class="copy-btn"
@click="
copyCommand(
currentTask.expectedCmd[currentOS] ||
currentTask.expectedCmd.common
)
"
>
复制命令
</button>
@@ -57,11 +72,17 @@
<span class="label">预期目标</span>
<span class="value">{{ currentTask.goal }}</span>
</div>
<div class="success-message" v-if="isTaskCompleted">
<span class="icon">🎉</span>
<span>太棒了任务完成</span>
<button class="next-btn" @click="nextTask" v-if="currentTaskIndex < tasks.length - 1">下一关</button>
<button
class="next-btn"
@click="nextTask"
v-if="currentTaskIndex < tasks.length - 1"
>
下一关
</button>
<button class="reset-btn" @click="resetAll" v-else>重新开始</button>
</div>
</div>
@@ -79,22 +100,29 @@
</div>
<div class="terminal-body" ref="terminalBody" @click="focusInput">
<div v-for="(line, index) in history" :key="index" class="line">
<span v-if="line.type === 'input'" class="prompt">{{ line.prompt }}</span>
<span v-if="line.type === 'input'" class="prompt">{{
line.prompt
}}</span>
<span :class="line.type">{{ line.content }}</span>
</div>
<div class="line input-line" v-if="!isTaskCompleted || currentTaskIndex < tasks.length - 1">
<div
class="line input-line"
v-if="!isTaskCompleted || currentTaskIndex < tasks.length - 1"
>
<span class="prompt">{{ prompt }}</span>
<input
<input
ref="cmdInput"
v-model="inputCmd"
v-model="inputCmd"
@keydown.enter="executeCommand"
@keydown.tab.prevent
type="text"
type="text"
spellcheck="false"
autocomplete="off"
/>
<span v-if="inputCmd.length > 0" class="enter-hint"> 按回车执行</span>
<span v-if="inputCmd.length > 0" class="enter-hint"
> 按回车执行</span
>
</div>
</div>
</div>
@@ -115,10 +143,10 @@ const terminalBody = ref(null)
// System Configurations
const osConfig = {
'mac': { prompt: 'user@MacBook ~ % ', title: 'user — -zsh' },
mac: { prompt: 'user@MacBook ~ % ', title: 'user — -zsh' },
'win-ps': { prompt: 'PS C:\\Users\\User> ', title: 'Windows PowerShell' },
'win-cmd': { prompt: 'C:\\Users\\User> ', title: 'Command Prompt' },
'linux': { prompt: 'user@localhost:~$ ', title: 'user@localhost: ~' }
linux: { prompt: 'user@localhost:~$ ', title: 'user@localhost: ~' }
}
const prompt = computed(() => osConfig[currentOS.value].prompt)
@@ -132,14 +160,17 @@ const tasks = [
goal: '列出当前目录下的所有文件。',
aiQuery: '我想查看当前目录下的文件,应该用什么命令?',
aiResponse: {
'mac': '在 macOS 和 Linux 中,查看文件列表使用 `ls` 命令 (List)。',
'linux': '在 macOS 和 Linux 中,查看文件列表使用 `ls` 命令 (List)。',
mac: '在 macOS 和 Linux 中,查看文件列表使用 `ls` 命令 (List)。',
linux: '在 macOS 和 Linux 中,查看文件列表使用 `ls` 命令 (List)。',
'win-ps': '在 PowerShell 中,你可以使用 `ls` 或 `dir` 命令。',
'win-cmd': '在 Windows CMD 中,查看文件列表使用 `dir` 命令 (Directory)。',
'common': '通常使用 ls 或 dir。'
common: '通常使用 ls 或 dir。'
},
expectedCmd: {
'mac': 'ls', 'linux': 'ls', 'win-ps': 'ls', 'win-cmd': 'dir'
mac: 'ls',
linux: 'ls',
'win-ps': 'ls',
'win-cmd': 'dir'
},
validate: (cmd, os) => {
const valid = os === 'win-cmd' ? ['dir'] : ['ls', 'dir', 'll']
@@ -161,14 +192,16 @@ d---- 1/15/2026 9:00 AM Downloads
},
{
title: '第二步:创建一个新家',
description: '文件太多会很乱,我们创建一个专门的文件夹来存放今天的练习文件。',
description:
'文件太多会很乱,我们创建一个专门的文件夹来存放今天的练习文件。',
goal: '创建一个名为 "demo" 的文件夹。',
aiQuery: '怎么创建一个新的文件夹?名字叫 demo。',
aiResponse: {
'common': '创建文件夹(目录)的命令是 `mkdir` (Make Directory)。你可以输入 `mkdir demo`。'
common:
'创建文件夹(目录)的命令是 `mkdir` (Make Directory)。你可以输入 `mkdir demo`。'
},
expectedCmd: {
'common': 'mkdir demo'
common: 'mkdir demo'
},
validate: (cmd) => cmd.trim() === 'mkdir demo',
output: () => '' // mkdir usually has no output on success
@@ -179,10 +212,10 @@ d---- 1/15/2026 9:00 AM Downloads
goal: '进入 "demo" 文件夹。',
aiQuery: '怎么进入刚才建好的 demo 文件夹?',
aiResponse: {
'common': '切换目录使用 `cd` 命令 (Change Directory)。输入 `cd demo` 即可。'
common: '切换目录使用 `cd` 命令 (Change Directory)。输入 `cd demo` 即可。'
},
expectedCmd: {
'common': 'cd demo'
common: 'cd demo'
},
validate: (cmd) => cmd.trim() === 'cd demo',
output: () => '' // cd usually has no output, but prompt changes
@@ -193,19 +226,26 @@ d---- 1/15/2026 9:00 AM Downloads
goal: '创建一个名为 "hello.txt" 的文件。',
aiQuery: '我想新建一个空文件叫 hello.txt,怎么做?',
aiResponse: {
'mac': '在 Mac/Linux 上,使用 `touch hello.txt` 可以快速创建一个空文件。',
'linux': '在 Mac/Linux 上,使用 `touch hello.txt` 可以快速创建一个空文件。',
'win-ps': '在 PowerShell 中,可以使用 `ni hello.txt` 或 `echo "" > hello.txt`。',
'win-cmd': '在 CMD 中,可以使用 `type nul > hello.txt` 或 `echo. > hello.txt`。',
mac: '在 Mac/Linux 上,使用 `touch hello.txt` 可以快速创建一个空文件。',
linux: '在 Mac/Linux 上,使用 `touch hello.txt` 可以快速创建一个空文件。',
'win-ps':
'在 PowerShell 中,可以使用 `ni hello.txt` 或 `echo "" > hello.txt`。',
'win-cmd':
'在 CMD 中,可以使用 `type nul > hello.txt` 或 `echo. > hello.txt`。'
},
expectedCmd: {
'mac': 'touch hello.txt',
'linux': 'touch hello.txt',
mac: 'touch hello.txt',
linux: 'touch hello.txt',
'win-ps': 'ni hello.txt',
'win-cmd': 'type nul > hello.txt'
},
validate: (cmd, os) => {
if (cmd.includes('touch') || cmd.includes('echo') || cmd.includes('ni') || cmd.includes('type')) {
if (
cmd.includes('touch') ||
cmd.includes('echo') ||
cmd.includes('ni') ||
cmd.includes('type')
) {
return cmd.includes('hello.txt')
}
return false
@@ -214,22 +254,26 @@ d---- 1/15/2026 9:00 AM Downloads
},
{
title: '第五步:安装程序 (系统软件 & Python库)',
description: '终端不仅能管理文件,还能安装软件。我们来尝试两种常见的安装场景:安装系统工具(如 wget/git)和安装 Python 库(如 requests)。',
description:
'终端不仅能管理文件,还能安装软件。我们来尝试两种常见的安装场景:安装系统工具(如 wget/git)和安装 Python 库(如 requests)。',
goal: '任选其一:安装系统工具或 Python 库。',
aiQuery: '怎么用命令行安装软件?我想装 git 或者 python 的 requests 库。',
aiResponse: {
'mac': 'macOS 推荐使用 Homebrew 安装系统软件,使用 pip 安装 Python 库。',
'linux': 'Linux (Ubuntu/Debian) 使用 apt 安装系统软件,使用 pip 安装 Python 库。',
'win-ps': 'Windows PowerShell 推荐使用 winget 安装系统软件,使用 pip 安装 Python 库。',
'win-cmd': 'Windows CMD 推荐使用 winget 安装系统软件,使用 pip 安装 Python 库。',
'common': '不同系统有不同的包管理器。'
mac: 'macOS 推荐使用 Homebrew 安装系统软件,使用 pip 安装 Python 库。',
linux:
'Linux (Ubuntu/Debian) 使用 apt 安装系统软件,使用 pip 安装 Python 库。',
'win-ps':
'Windows PowerShell 推荐使用 winget 安装系统软件,使用 pip 安装 Python 库。',
'win-cmd':
'Windows CMD 推荐使用 winget 安装系统软件,使用 pip 安装 Python 库。',
common: '不同系统有不同的包管理器。'
},
commands: {
'mac': [
mac: [
{ label: '安装 wget (系统)', cmd: 'brew install wget' },
{ label: '安装 requests (Python)', cmd: 'pip install requests' }
],
'linux': [
linux: [
{ label: '安装 git (系统)', cmd: 'sudo apt install git' },
{ label: '安装 requests (Python)', cmd: 'pip install requests' }
],
@@ -244,34 +288,46 @@ d---- 1/15/2026 9:00 AM Downloads
},
expectedCmd: {
// Fallback/Legacy
'mac': 'brew install wget',
'linux': 'sudo apt install git',
mac: 'brew install wget',
linux: 'sudo apt install git',
'win-ps': 'pip install requests',
'win-cmd': 'pip install requests'
},
validate: (cmd, os) => {
const c = cmd.trim()
if (os === 'mac') return c === 'brew install wget' || c === 'pip install requests'
if (os === 'linux') return c === 'sudo apt install git' || c === 'apt install git' || c === 'pip install requests'
if (os === 'win-ps' || os === 'win-cmd') return c === 'winget install git.git' || c === 'winget install git' || c === 'pip install requests'
if (os === 'mac')
return c === 'brew install wget' || c === 'pip install requests'
if (os === 'linux')
return (
c === 'sudo apt install git' ||
c === 'apt install git' ||
c === 'pip install requests'
)
if (os === 'win-ps' || os === 'win-cmd')
return (
c === 'winget install git.git' ||
c === 'winget install git' ||
c === 'pip install requests'
)
return c === 'pip install requests'
},
output: (os, cmd) => { // Modified to accept cmd
output: (os, cmd) => {
// Modified to accept cmd
const c = cmd ? cmd.trim() : ''
// Python requests output
if (c.includes('pip install requests')) {
return `
return `
Downloading/unpacking requests
Downloading requests-2.31.0-py3-none-any.whl (62kB): 62kB downloaded
Installing collected packages: requests
Successfully installed requests
Cleaning up...`
}
// Windows winget output
if (c.includes('winget install')) {
return `
return `
Found Git [Git.Git] Version 2.43.0
This application is licensed to you by its owner.
Microsoft is not responsible for, nor does it grant any licenses to, third-party packages.
@@ -310,14 +366,15 @@ Setting up git (1:2.34.1-1ubuntu1.9) ...`
goal: '删除 "hello.txt" 文件。',
aiQuery: '我不想要 hello.txt 了,怎么删除它?',
aiResponse: {
'mac': '删除文件使用 `rm` 命令 (Remove)。小心,这个操作通常不可撤销!输入 `rm hello.txt`。',
'linux': '删除文件使用 `rm` 命令 (Remove)。小心,这个操作通常不可撤销!输入 `rm hello.txt`。',
mac: '删除文件使用 `rm` 命令 (Remove)。小心,这个操作通常不可撤销!输入 `rm hello.txt`。',
linux:
'删除文件使用 `rm` 命令 (Remove)。小心,这个操作通常不可撤销!输入 `rm hello.txt`。',
'win-ps': '在 PowerShell 中使用 `rm` 或 `del`。输入 `rm hello.txt`。',
'win-cmd': '在 CMD 中使用 `del` 命令 (Delete)。输入 `del hello.txt`。',
'win-cmd': '在 CMD 中使用 `del` 命令 (Delete)。输入 `del hello.txt`。'
},
expectedCmd: {
'mac': 'rm hello.txt',
'linux': 'rm hello.txt',
mac: 'rm hello.txt',
linux: 'rm hello.txt',
'win-ps': 'rm hello.txt',
'win-cmd': 'del hello.txt'
},
@@ -362,12 +419,18 @@ const executeCommand = () => {
// 1. Add to history
let currentPrompt = prompt.value
// Special handling for prompt update simulation (hacky way)
if (currentTaskIndex.value >= 2 && currentTaskIndex.value < 6 && history.value.length > 0) {
// If we are inside demo folder
if (currentOS.value === 'mac') currentPrompt = 'user@MacBook demo % '
else if (currentOS.value === 'linux') currentPrompt = 'user@localhost:~/demo$ '
else if (currentOS.value === 'win-ps') currentPrompt = 'PS C:\\Users\\User\\demo> '
else currentPrompt = 'C:\\Users\\User\\demo> '
if (
currentTaskIndex.value >= 2 &&
currentTaskIndex.value < 6 &&
history.value.length > 0
) {
// If we are inside demo folder
if (currentOS.value === 'mac') currentPrompt = 'user@MacBook demo % '
else if (currentOS.value === 'linux')
currentPrompt = 'user@localhost:~/demo$ '
else if (currentOS.value === 'win-ps')
currentPrompt = 'PS C:\\Users\\User\\demo> '
else currentPrompt = 'C:\\Users\\User\\demo> '
}
history.value.push({ type: 'input', prompt: currentPrompt, content: cmd })
@@ -375,7 +438,10 @@ const executeCommand = () => {
// 2. Process Command
// Check if it matches current task requirement
if (!isTaskCompleted.value && currentTask.value.validate(cmd, currentOS.value)) {
if (
!isTaskCompleted.value &&
currentTask.value.validate(cmd, currentOS.value)
) {
// Success
const out = currentTask.value.output(currentOS.value, cmd) // Pass cmd to output
if (out) {
@@ -386,19 +452,29 @@ const executeCommand = () => {
// Failure or just random command
// Simple mock responses for common commands if not matching task
if (cmd.trim() === 'ls' || cmd.trim() === 'dir') {
if (currentTaskIndex.value < 2) {
// Initial state
history.value.push({ type: 'output', content: tasks[0].output(currentOS.value) })
} else if (currentTaskIndex.value >= 2) {
// Inside demo
if (currentTaskIndex.value === 3) history.value.push({ type: 'output', content: '' }) // empty
else history.value.push({ type: 'output', content: 'hello.txt' })
}
if (currentTaskIndex.value < 2) {
// Initial state
history.value.push({
type: 'output',
content: tasks[0].output(currentOS.value)
})
} else if (currentTaskIndex.value >= 2) {
// Inside demo
if (currentTaskIndex.value === 3)
history.value.push({ type: 'output', content: '' }) // empty
else history.value.push({ type: 'output', content: 'hello.txt' })
}
} else if (cmd.trim() === 'clear' || cmd.trim() === 'cls') {
history.value = []
} else if (!isTaskCompleted.value) {
history.value.push({ type: 'error', content: `Command not found or not matching task: ${cmd}` })
history.value.push({ type: 'info', content: `💡 提示:试试点击左侧的“问问 AI”?` })
history.value.push({
type: 'error',
content: `Command not found or not matching task: ${cmd}`
})
history.value.push({
type: 'info',
content: `💡 提示:试试点击左侧的“问问 AI”?`
})
}
}
@@ -410,7 +486,10 @@ const nextTask = () => {
currentTaskIndex.value++
isTaskCompleted.value = false
// Clear history to keep it clean? Or keep it? Let's keep it but maybe add a separator
history.value.push({ type: 'info', content: `--- 进入下一关: ${currentTask.value.title} ---` })
history.value.push({
type: 'info',
content: `--- 进入下一关: ${currentTask.value.title} ---`
})
scrollToBottom()
}
}
@@ -446,7 +525,7 @@ watch(currentOS, () => {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
@media (max-width: 768px) {
@@ -606,11 +685,18 @@ watch(currentOS, () => {
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.next-btn, .reset-btn {
.next-btn,
.reset-btn {
margin-left: auto;
padding: 6px 16px;
background: #10b981;
@@ -622,7 +708,8 @@ watch(currentOS, () => {
transition: transform 0.1s;
}
.next-btn:hover, .reset-btn:hover {
.next-btn:hover,
.reset-btn:hover {
transform: scale(1.05);
background: #059669;
}
@@ -636,9 +723,21 @@ watch(currentOS, () => {
min-height: 400px;
}
.terminal-panel.win-cmd { background: #0c0c0c; color: #cccccc; font-family: 'Consolas', monospace; }
.terminal-panel.win-ps { background: #012456; color: #ffffff; font-family: 'Consolas', monospace; }
.terminal-panel.mac, .terminal-panel.linux { background: #2b2b2b; color: #f0f0f0; }
.terminal-panel.win-cmd {
background: #0c0c0c;
color: #cccccc;
font-family: 'Consolas', monospace;
}
.terminal-panel.win-ps {
background: #012456;
color: #ffffff;
font-family: 'Consolas', monospace;
}
.terminal-panel.mac,
.terminal-panel.linux {
background: #2b2b2b;
color: #f0f0f0;
}
.terminal-header {
padding: 8px 12px;
@@ -658,11 +757,21 @@ watch(currentOS, () => {
height: 12px;
border-radius: 50%;
}
.dot.red { background: #ff5f56; }
.dot.yellow { background: #ffbd2e; }
.dot.green { background: #27c93f; }
.dot.red {
background: #ff5f56;
}
.dot.yellow {
background: #ffbd2e;
}
.dot.green {
background: #27c93f;
}
.terminal-panel.win-cmd .dot, .terminal-panel.win-ps .dot { border-radius: 0; background: #ccc; }
.terminal-panel.win-cmd .dot,
.terminal-panel.win-ps .dot {
border-radius: 0;
background: #ccc;
}
.terminal-header .title {
position: absolute;
@@ -696,8 +805,12 @@ watch(currentOS, () => {
font-weight: bold;
}
.terminal-panel.win-cmd .prompt { color: #cccccc; }
.terminal-panel.win-ps .prompt { color: #ffffff; }
.terminal-panel.win-cmd .prompt {
color: #cccccc;
}
.terminal-panel.win-ps .prompt {
color: #ffffff;
}
.input-line {
display: flex;
@@ -725,8 +838,13 @@ watch(currentOS, () => {
}
@keyframes blink {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
.line.output {
@@ -1,10 +1,10 @@
<template>
<div class="terminal-os-demo">
<div class="os-switch">
<button
v-for="os in osList"
<button
v-for="os in osList"
:key="os.id"
:class="{ active: currentOS === os.id }"
:class="{ active: currentOS === os.id }"
@click="switchOS(os.id)"
>
<span class="os-icon">{{ os.icon }}</span>
@@ -21,12 +21,26 @@
</div>
<div class="window-title">{{ currentOSConfig.title }}</div>
<div class="window-controls">
<button class="control-btn" @click="resetDemo" title="Reset"></button>
<button class="control-btn" @click="resetDemo" title="Reset">
</button>
</div>
</div>
<div class="terminal-content" @click="nextStep" :class="{ 'clickable': !isTyping && !isFinished }">
<div
class="terminal-content"
@click="nextStep"
:class="{ clickable: !isTyping && !isFinished }"
>
<!-- Start Overlay -->
<div class="start-overlay" v-if="lines.length === 0 || (lines.length === 1 && lines[0].content === '' && currentStepIndex === -1)">
<div
class="start-overlay"
v-if="
lines.length === 0 ||
(lines.length === 1 &&
lines[0].content === '' &&
currentStepIndex === -1)
"
>
<div class="start-hint">
<span class="icon">👆</span>
<span class="text">不断点击屏幕演示 / Keep Clicking</span>
@@ -43,22 +57,33 @@
<div v-for="(line, index) in lines" :key="index" class="line">
<template v-if="line.type === 'input'">
<span class="prompt">{{ line.prompt }}</span><span class="cmd-text">{{ line.content }}</span>
<span class="prompt">{{ line.prompt }}</span
><span class="cmd-text">{{ line.content }}</span>
</template>
<template v-else>
<span class="output-text">{{ line.content }}</span>
</template>
</div>
<!-- Active Input Line (when not animating or just waiting) -->
<div class="line input-line" v-if="lines.length === 0 || (!isTyping && lines[lines.length-1].type !== 'input' && !isFinished)">
<span class="prompt">{{ currentOSConfig.prompt }}</span>
<span class="cursor">_</span>
<span v-if="lines.length === 0" class="hint"> (点击屏幕继续 / Click screen to continue)</span>
<span v-else class="hint blink-hint"> </span>
<div
class="line input-line"
v-if="
lines.length === 0 ||
(!isTyping &&
lines[lines.length - 1].type !== 'input' &&
!isFinished)
"
>
<span class="prompt">{{ currentOSConfig.prompt }}</span>
<span class="cursor">_</span>
<span v-if="lines.length === 0" class="hint">
(点击屏幕继续 / Click screen to continue)</span
>
<span v-else class="hint blink-hint"> </span>
</div>
</div>
<!-- Explanation Bar -->
<div class="explanation-bar" :class="{ visible: currentExplanation }">
<span class="icon">💡</span>
@@ -90,17 +115,54 @@ const configs = {
prompt: 'C:\\Users\\User>',
demo: [
{ type: 'explanation', content: '准备输入命令...' },
{ type: 'command', content: 'dir', delay: 400, explanation: '输入 `dir` (Directory)。这是 Windows 系统用来**列出当前文件夹内容**的命令。' },
{ type: 'output', content: ' Volume in drive C has no label.', delay: 100, explanation: '系统正在执行命令...' },
{ type: 'output', content: ' Volume Serial Number is A1B2-C3D4', delay: 50 },
{
type: 'command',
content: 'dir',
delay: 400,
explanation:
'输入 `dir` (Directory)。这是 Windows 系统用来**列出当前文件夹内容**的命令。'
},
{
type: 'output',
content: ' Volume in drive C has no label.',
delay: 100,
explanation: '系统正在执行命令...'
},
{
type: 'output',
content: ' Volume Serial Number is A1B2-C3D4',
delay: 50
},
{ type: 'output', content: '', delay: 50 },
{ type: 'output', content: ' Directory of C:\\Users\\User', delay: 50 },
{ type: 'output', content: '', delay: 50 },
{ type: 'output', content: '01/15/2026 10:00 AM <DIR> .', delay: 50 },
{ type: 'output', content: '01/15/2026 10:00 AM <DIR> ..', delay: 50 },
{ type: 'output', content: '01/15/2026 10:00 AM 128 demo.txt', delay: 50 },
{ type: 'output', content: ' 1 File(s) 128 bytes', delay: 50 },
{ type: 'output', content: ' 2 Dir(s) 50,000,000,000 bytes free', delay: 50, explanation: '系统返回了文件列表。`<DIR>` 表示这是一个文件夹,数字表示文件大小。' },
{
type: 'output',
content: '01/15/2026 10:00 AM <DIR> .',
delay: 50
},
{
type: 'output',
content: '01/15/2026 10:00 AM <DIR> ..',
delay: 50
},
{
type: 'output',
content: '01/15/2026 10:00 AM 128 demo.txt',
delay: 50
},
{
type: 'output',
content: ' 1 File(s) 128 bytes',
delay: 50
},
{
type: 'output',
content: ' 2 Dir(s) 50,000,000,000 bytes free',
delay: 50,
explanation:
'系统返回了文件列表。`<DIR>` 表示这是一个文件夹,数字表示文件大小。'
},
{ type: 'output', content: '', delay: 100 }
]
},
@@ -109,46 +171,137 @@ const configs = {
prompt: 'PS C:\\Users\\User>',
demo: [
{ type: 'explanation', content: '准备输入命令...' },
{ type: 'command', content: 'Get-Date', delay: 400, explanation: '输入 `Get-Date`。PowerShell 使用动词-名词的命名方式,这里是**获取当前时间**。' },
{ type: 'output', content: '', delay: 100, explanation: '系统返回了当前的日期和时间。' },
{ type: 'output', content: 'Thursday, January 15, 2026 10:00:00 AM', delay: 100 },
{
type: 'command',
content: 'Get-Date',
delay: 400,
explanation:
'输入 `Get-Date`。PowerShell 使用动词-名词的命名方式,这里是**获取当前时间**。'
},
{
type: 'output',
content: '',
delay: 100,
explanation: '系统返回了当前的日期和时间。'
},
{
type: 'output',
content: 'Thursday, January 15, 2026 10:00:00 AM',
delay: 100
},
{ type: 'output', content: '', delay: 100 },
{ type: 'command', content: 'echo "Hello World"', delay: 400, explanation: '输入 `echo`。这是让计算机**复读**你说的话,常用于测试或打印信息。' },
{ type: 'output', content: 'Hello World', delay: 100, explanation: '计算机乖乖地输出了 "Hello World"。' }
{
type: 'command',
content: 'echo "Hello World"',
delay: 400,
explanation:
'输入 `echo`。这是让计算机**复读**你说的话,常用于测试或打印信息。'
},
{
type: 'output',
content: 'Hello World',
delay: 100,
explanation: '计算机乖乖地输出了 "Hello World"。'
}
]
},
'mac': {
mac: {
title: 'user — -zsh — 80x24',
prompt: 'user@MacBook-Pro ~ % ',
demo: [
{ type: 'explanation', content: '准备输入命令...' },
{ type: 'command', content: 'ls -G', delay: 400, explanation: '输入 `ls` (List)。这是 Mac/Linux 系统用来**列出文件**的命令。`-G` 参数让输出带颜色。' },
{ type: 'output', content: 'Desktop Downloads Movies Music', delay: 100 },
{ type: 'output', content: 'Documents Library Pictures Public', delay: 100, explanation: '系统列出了你的主目录下的文件夹。' },
{ type: 'command', content: 'sw_vers', delay: 400, explanation: '输入 `sw_vers` (Software Version)。这是 macOS 特有的命令,查看**系统版本**。' },
{
type: 'command',
content: 'ls -G',
delay: 400,
explanation:
'输入 `ls` (List)。这是 Mac/Linux 系统用来**列出文件**的命令。`-G` 参数让输出带颜色。'
},
{
type: 'output',
content: 'Desktop Downloads Movies Music',
delay: 100
},
{
type: 'output',
content: 'Documents Library Pictures Public',
delay: 100,
explanation: '系统列出了你的主目录下的文件夹。'
},
{
type: 'command',
content: 'sw_vers',
delay: 400,
explanation:
'输入 `sw_vers` (Software Version)。这是 macOS 特有的命令,查看**系统版本**。'
},
{ type: 'output', content: 'ProductName: macOS', delay: 50 },
{ type: 'output', content: 'ProductVersion: 15.1', delay: 50 },
{ type: 'output', content: 'BuildVersion: 24B83', delay: 50, explanation: '系统返回了当前的 macOS 版本信息。' }
{
type: 'output',
content: 'BuildVersion: 24B83',
delay: 50,
explanation: '系统返回了当前的 macOS 版本信息。'
}
]
},
'linux': {
linux: {
title: 'user@hostname: ~',
prompt: 'user@hostname:~$ ',
demo: [
{ type: 'explanation', content: '准备输入命令...' },
{ type: 'command', content: 'ls -la', delay: 400, explanation: '输入 `ls` (List)。这是 Linux/Mac 系统用来**列出文件**的命令。`-la` 是参数,表示“列出所有文件(all)的详细信息(long)”。' },
{ type: 'output', content: 'total 8', delay: 100, explanation: '系统返回了文件列表。左边的 `drwxr-xr-x` 看起来像乱码,其实是**权限描述**(谁能读、谁能写)。' },
{ type: 'output', content: 'drwxr-xr-x 2 user user 4096 Jan 15 10:00 .', delay: 50 },
{ type: 'output', content: 'drwxr-xr-x 3 user user 4096 Jan 15 10:00 ..', delay: 50 },
{ type: 'output', content: '-rw-r--r-- 1 user user 128 Jan 15 10:00 demo.txt', delay: 50 },
{ type: 'command', content: 'whoami', delay: 400, explanation: '输入 `whoami` (Who am I)。这是一个经典的哲学命令(笑),告诉计算机:**我是谁?**(当前登录用户)。' },
{ type: 'output', content: 'user', delay: 100, explanation: '系统回答:你是 "user"。' }
{
type: 'command',
content: 'ls -la',
delay: 400,
explanation:
'输入 `ls` (List)。这是 Linux/Mac 系统用来**列出文件**的命令。`-la` 是参数,表示“列出所有文件(all)的详细信息(long)”。'
},
{
type: 'output',
content: 'total 8',
delay: 100,
explanation:
'系统返回了文件列表。左边的 `drwxr-xr-x` 看起来像乱码,其实是**权限描述**(谁能读、谁能写)。'
},
{
type: 'output',
content: 'drwxr-xr-x 2 user user 4096 Jan 15 10:00 .',
delay: 50
},
{
type: 'output',
content: 'drwxr-xr-x 3 user user 4096 Jan 15 10:00 ..',
delay: 50
},
{
type: 'output',
content: '-rw-r--r-- 1 user user 128 Jan 15 10:00 demo.txt',
delay: 50
},
{
type: 'command',
content: 'whoami',
delay: 400,
explanation:
'输入 `whoami` (Who am I)。这是一个经典的哲学命令(笑),告诉计算机:**我是谁?**(当前登录用户)。'
},
{
type: 'output',
content: 'user',
delay: 100,
explanation: '系统回答:你是 "user"。'
}
]
}
}
const currentOSConfig = computed(() => configs[currentOS.value])
const isFinished = computed(() => currentOSConfig.value && currentStepIndex.value >= currentOSConfig.value.demo.length - 1)
const isFinished = computed(
() =>
currentOSConfig.value &&
currentStepIndex.value >= currentOSConfig.value.demo.length - 1
)
const switchOS = (id) => {
currentOS.value = id
@@ -161,7 +314,11 @@ const resetDemo = () => {
currentStepIndex.value = -1
isTyping.value = false
// Add initial prompt
lines.value.push({ type: 'input', prompt: currentOSConfig.value.prompt, content: '' })
lines.value.push({
type: 'input',
prompt: currentOSConfig.value.prompt,
content: ''
})
}
// Initial reset
@@ -169,77 +326,80 @@ watch(currentOSConfig, resetDemo, { immediate: true })
const nextStep = async () => {
if (isTyping.value || isFinished.value) return
const demoLines = currentOSConfig.value.demo
const promptText = currentOSConfig.value.prompt
// Loop to process consecutive output lines or until a pause point
while (currentStepIndex.value < demoLines.length - 1) {
currentStepIndex.value++
const step = demoLines[currentStepIndex.value]
// 1. Update Explanation if exists
if (step.explanation) {
currentExplanation.value = step.explanation
}
// 2. Handle specific types
if (step.type === 'explanation') {
// Just show explanation and pause
break
}
if (step.type === 'command') {
// Ensure input line exists
if (lines.value.length === 0 || lines.value[lines.value.length - 1].type !== 'input') {
lines.value.push({ type: 'input', prompt: promptText, content: '' })
if (
lines.value.length === 0 ||
lines.value[lines.value.length - 1].type !== 'input'
) {
lines.value.push({ type: 'input', prompt: promptText, content: '' })
}
// Type effect
isTyping.value = true
const text = step.content
const targetLine = lines.value[lines.value.length - 1]
for (let i = 0; i < text.length; i++) {
targetLine.content += text[i]
await new Promise(r => setTimeout(r, 30 + Math.random() * 40))
await new Promise((r) => setTimeout(r, 30 + Math.random() * 40))
}
isTyping.value = false
// Pause after typing command
break
}
if (step.type === 'output') {
lines.value.push({ type: 'output', content: step.content })
// Logic to continue or pause:
// Pause if:
// - This output has an explanation (user needs to read)
// - Next step is NOT output (it's a command or explanation block)
// - Next step is output BUT has an explanation
if (step.explanation) {
break
}
const nextStep = demoLines[currentStepIndex.value + 1]
if (!nextStep || nextStep.type !== 'output' || nextStep.explanation) {
// If next is command, we might want to show a prompt before pausing?
// But the command step logic adds prompt.
// But the command step logic adds prompt.
// If we pause here, the user sees output. Next click -> types command.
// Seems correct.
break
}
// Small delay between batched outputs for visual smoothness
await new Promise(r => setTimeout(r, 50))
await new Promise((r) => setTimeout(r, 50))
}
}
// If we finished everything, add a final prompt
if (isFinished.value) {
lines.value.push({ type: 'input', prompt: promptText, content: '' })
lines.value.push({ type: 'input', prompt: promptText, content: '' })
}
}
</script>
@@ -345,9 +505,15 @@ const nextStep = async () => {
.terminal-window.linux .window-buttons .btn {
border-radius: 50%;
}
.terminal-window.linux .window-buttons .close { background: #ff5f56; }
.terminal-window.linux .window-buttons .minimize { background: #ffbd2e; }
.terminal-window.linux .window-buttons .maximize { background: #27c93f; }
.terminal-window.linux .window-buttons .close {
background: #ff5f56;
}
.terminal-window.linux .window-buttons .minimize {
background: #ffbd2e;
}
.terminal-window.linux .window-buttons .maximize {
background: #27c93f;
}
/* Common Layout */
.terminal-window.mac {
@@ -363,9 +529,15 @@ const nextStep = async () => {
.terminal-window.mac .window-buttons .btn {
border-radius: 50%;
}
.terminal-window.mac .window-buttons .close { background: #ff5f56; }
.terminal-window.mac .window-buttons .minimize { background: #ffbd2e; }
.terminal-window.mac .window-buttons .maximize { background: #27c93f; }
.terminal-window.mac .window-buttons .close {
background: #ff5f56;
}
.terminal-window.mac .window-buttons .minimize {
background: #ffbd2e;
}
.terminal-window.mac .window-buttons .maximize {
background: #27c93f;
}
.window-bar {
padding: 8px 12px;
@@ -478,14 +650,20 @@ const nextStep = async () => {
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.completed-overlay {
@@ -513,7 +691,7 @@ const nextStep = async () => {
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: transform 0.2s;
}
@@ -595,8 +773,13 @@ const nextStep = async () => {
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.hint {
@@ -23,7 +23,11 @@
<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">
<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>
@@ -35,9 +39,9 @@
<span class="path">{{ currentPath }}</span>
<span class="arrow">$ </span>
</span>
<input
type="text"
v-model="currentInput"
<input
type="text"
v-model="currentInput"
@keyup.enter="executeCommand"
@keydown.up.prevent="navigateHistory(-1)"
@keydown.down.prevent="navigateHistory(1)"
@@ -52,16 +56,26 @@
<div class="cheat-sheet">
<div class="sheet-title">
<span class="icon">📖</span>
<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="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-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>
@@ -77,8 +91,16 @@
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" 查看可用命令。' }
{
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)
@@ -91,41 +113,60 @@ const fileSystem = {
name: '/',
type: 'dir',
children: {
'home': {
home: {
name: 'home',
type: 'dir',
children: {
'user': {
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");' }
}
'hello.txt': {
name: 'hello.txt',
type: 'file',
content:
'Hello World! This is a mock file.\n你好!这是一个模拟文件。'
},
'Downloads': { name: 'Downloads', type: 'dir', children: {} }
'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': {
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' }
passwd: {
name: 'passwd',
type: 'file',
content:
'root:x:0:0:root:/root:/bin/bash\nuser:x:1000:1000:user:/home/user:/bin/zsh'
}
}
},
'bin': {
bin: {
name: 'bin',
type: 'dir',
children: {
'ls': { name: 'ls', type: 'file', content: 'Binary file' },
'cat': { name: 'cat', type: 'file', content: 'Binary file' }
ls: { name: 'ls', type: 'file', content: 'Binary file' },
cat: { name: 'cat', type: 'file', content: 'Binary file' }
}
}
}
@@ -135,12 +176,13 @@ let currentPath = '~'
let currentDirObj = fileSystem.children['home'].children['user']
const resolvePath = (path) => {
if (path === '~' || path === '') return fileSystem.children['home'].children['user']
if (path === '~' || path === '')
return fileSystem.children['home'].children['user']
if (path === '/') return fileSystem
let parts = path.split('/').filter(p => p)
let parts = path.split('/').filter((p) => p)
let current = path.startsWith('/') ? fileSystem : currentDirObj
for (const part of parts) {
if (part === '.') continue
if (part === '..') {
@@ -177,23 +219,25 @@ const navigateTo = (target) => {
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']
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
}
// 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
@@ -201,7 +245,8 @@ const navigateTo = (target) => {
const targetObj = currentDirObj.children[target]
if (targetObj.type === 'dir') {
newDir = targetObj
newPath = currentPath === '/' ? `/${target}` : `${currentPath}/${target}`
newPath =
currentPath === '/' ? `/${target}` : `${currentPath}/${target}`
} else {
return { error: `cd: not a directory: ${target}` }
}
@@ -216,24 +261,52 @@ 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: '显示当前所在的完整路径' }
{
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: '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: '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: '清空屏幕内容' }
@@ -242,8 +315,16 @@ const cheatSheet = [
{
category: 'Package Manager / 软件包 (Mock)',
commands: [
{ name: 'apt update', descEn: 'Update package list', descZh: '更新软件包列表' },
{ name: 'apt install <pkg>', descEn: 'Install package', descZh: '安装软件 (例如: apt install git)' }
{
name: 'apt update',
descEn: 'Update package list',
descZh: '更新软件包列表'
},
{
name: 'apt install <pkg>',
descEn: 'Install package',
descZh: '安装软件 (例如: apt install git)'
}
]
}
]
@@ -253,14 +334,14 @@ const commands = {
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 => {
const names = items.map((item) => {
return item.type === 'dir' ? `\x1b[1;34m${item.name}/\x1b[0m` : item.name
})
return names.join(' ')
@@ -292,7 +373,7 @@ const commands = {
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`
@@ -312,7 +393,8 @@ const commands = {
mkdir: (args) => {
const name = args[0]
if (!name) return 'usage: mkdir <dir>'
if (currentDirObj.children[name]) return `mkdir: cannot create directory '${name}': File exists`
if (currentDirObj.children[name])
return `mkdir: cannot create directory '${name}': File exists`
currentDirObj.children[name] = { name, type: 'dir', children: {} }
return null
},
@@ -322,7 +404,8 @@ const commands = {
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`
if (currentDirObj.children[name].type === 'dir')
return `rm: cannot remove '${name}': Is a directory`
delete currentDirObj.children[name]
return null
}
@@ -330,7 +413,7 @@ const commands = {
},
whoami: () => 'user',
date: () => new Date().toString(),
apt: (args) => {
@@ -363,7 +446,7 @@ Setting up ${pkg} (1.0.0) ...`
const executeCommand = () => {
const input = currentInput.value.trim()
if (!input) {
history.value.push({ type: 'input', content: '' })
currentInput.value = ''
@@ -378,32 +461,41 @@ const executeCommand = () => {
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.
// 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 })
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}` })
history.value.push({
type: 'error',
content: `Error executing command: ${e.message}`
})
}
} else {
history.value.push({ type: 'error', content: `zsh: command not found: ${cmd}` })
history.value.push({
type: 'error',
content: `zsh: command not found: ${cmd}`
})
}
currentInput.value = ''
@@ -412,12 +504,13 @@ const executeCommand = () => {
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)
historyIndex.value = commandHistory.value.length
if (historyIndex.value === commandHistory.value.length) {
currentInput.value = ''
} else {
@@ -430,9 +523,11 @@ const handleTabCompletion = () => {
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))
const matches = Object.keys(currentDirObj.children).filter((name) =>
name.startsWith(partial)
)
if (matches.length === 1) {
const completed = matches[0]
// Replace last arg with completed
@@ -506,9 +601,15 @@ onMounted(() => {
display: inline-block;
}
.red { background-color: #ef4444; }
.yellow { background-color: #facc15; }
.green { background-color: #22c55e; }
.red {
background-color: #ef4444;
}
.yellow {
background-color: #facc15;
}
.green {
background-color: #22c55e;
}
.terminal-title {
flex: 1;
@@ -652,8 +753,9 @@ input {
grid-template-columns: 1fr;
height: auto;
}
.terminal-container, .cheat-sheet {
.terminal-container,
.cheat-sheet {
height: 350px;
}
}