2026-01-15 12:25:48 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="parser-demo">
|
|
|
|
|
|
<div class="demo-header">
|
|
|
|
|
|
<div class="title">转义序列解析原理 (Parser Mechanism)</div>
|
|
|
|
|
|
<div class="controls">
|
|
|
|
|
|
<button @click="reset" :disabled="isPlaying">Reset</button>
|
2026-01-15 14:11:44 +08:00
|
|
|
|
<button @click="togglePlay" class="play-btn">
|
|
|
|
|
|
{{ isPlaying ? '⏸ Pause' : (isFinished ? '↺ Replay' : '▶ Play Animation') }}
|
2026-01-15 12:25:48 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 1. 字节流传送带 -->
|
|
|
|
|
|
<div class="stream-container">
|
|
|
|
|
|
<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"
|
|
|
|
|
|
:key="index"
|
|
|
|
|
|
class="char-box"
|
|
|
|
|
|
:class="{
|
|
|
|
|
|
'active': index === currentIndex,
|
|
|
|
|
|
'processed': index < currentIndex,
|
|
|
|
|
|
'special': char.isSpecial,
|
|
|
|
|
|
'arg': char.isArg
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="char-val">{{ char.display }}</span>
|
|
|
|
|
|
<span class="char-code">{{ char.hex }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 指针 -->
|
|
|
|
|
|
<div class="pointer">
|
|
|
|
|
|
<div class="arrow">⬆</div>
|
|
|
|
|
|
<div class="pointer-label">Current Byte</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 2. 解析器状态机 -->
|
|
|
|
|
|
<div class="parser-state-machine">
|
|
|
|
|
|
<div class="state-box" :class="{ active: parserState === 'NORMAL' }">
|
|
|
|
|
|
<div class="state-name">NORMAL</div>
|
|
|
|
|
|
<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-name">ESCAPE MODE</div>
|
|
|
|
|
|
<div class="state-desc">Buffer Command...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 指令说明框 -->
|
|
|
|
|
|
<div class="action-log" v-if="lastAction">
|
|
|
|
|
|
<span class="action-icon">⚡</span>
|
|
|
|
|
|
{{ lastAction }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 3. 终端屏幕 -->
|
|
|
|
|
|
<div class="terminal-screen">
|
|
|
|
|
|
<div class="label">Terminal Screen / 屏幕显示</div>
|
|
|
|
|
|
<div class="screen-content">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="(char, index) in outputBuffer"
|
|
|
|
|
|
:key="index"
|
|
|
|
|
|
:style="char.style"
|
|
|
|
|
|
>{{ 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>)并执行。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2026-01-15 14:11:44 +08:00
|
|
|
|
import { ref } from 'vue'
|
2026-01-15 12:25:48 +08:00
|
|
|
|
|
|
|
|
|
|
// 原始字符串: Hello [RED]World[RESET]!
|
|
|
|
|
|
// \x1B [ 3 1 m
|
|
|
|
|
|
const RAW_DATA = [
|
|
|
|
|
|
{ val: 'H', display: 'H', hex: '48' },
|
|
|
|
|
|
{ val: 'i', display: 'i', hex: '69' },
|
|
|
|
|
|
{ val: ' ', display: ' ', hex: '20' },
|
|
|
|
|
|
{ val: '\x1B', display: 'ESC', hex: '1B', isSpecial: true },
|
|
|
|
|
|
{ val: '[', display: '[', hex: '5B', isSpecial: true },
|
|
|
|
|
|
{ val: '3', display: '3', hex: '33', isArg: true },
|
|
|
|
|
|
{ val: '1', display: '1', hex: '31', isArg: true },
|
|
|
|
|
|
{ val: 'm', display: 'm', hex: '6D', isSpecial: true }, // End of seq
|
|
|
|
|
|
{ val: 'V', display: 'V', hex: '56' },
|
|
|
|
|
|
{ val: 'i', display: 'i', hex: '69' },
|
|
|
|
|
|
{ val: 'b', display: 'b', hex: '62' },
|
|
|
|
|
|
{ val: 'e', display: 'e', hex: '65' },
|
|
|
|
|
|
{ val: '\x1B', display: 'ESC', hex: '1B', isSpecial: true },
|
|
|
|
|
|
{ val: '[', display: '[', hex: '5B', isSpecial: true },
|
|
|
|
|
|
{ val: '0', display: '0', hex: '30', isArg: true },
|
|
|
|
|
|
{ val: 'm', display: 'm', hex: '6D', isSpecial: true },
|
|
|
|
|
|
{ val: '!', display: '!', hex: '21' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const charStream = ref(RAW_DATA)
|
|
|
|
|
|
const currentIndex = ref(0)
|
|
|
|
|
|
const outputBuffer = ref([])
|
|
|
|
|
|
const parserState = ref('NORMAL') // NORMAL, ESCAPE
|
|
|
|
|
|
const currentStyle = ref({})
|
|
|
|
|
|
const isPlaying = ref(false)
|
|
|
|
|
|
const isFinished = ref(false)
|
|
|
|
|
|
const lastAction = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
const reset = () => {
|
2026-01-15 14:11:44 +08:00
|
|
|
|
isPlaying.value = false // Stop first
|
2026-01-15 12:25:48 +08:00
|
|
|
|
currentIndex.value = 0
|
|
|
|
|
|
outputBuffer.value = []
|
|
|
|
|
|
parserState.value = 'NORMAL'
|
|
|
|
|
|
currentStyle.value = {}
|
|
|
|
|
|
isFinished.value = false
|
|
|
|
|
|
lastAction.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 14:11:44 +08:00
|
|
|
|
const togglePlay = () => {
|
|
|
|
|
|
if (isPlaying.value) {
|
|
|
|
|
|
isPlaying.value = false
|
|
|
|
|
|
} else {
|
|
|
|
|
|
play()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 12:25:48 +08:00
|
|
|
|
const play = async () => {
|
|
|
|
|
|
if (isPlaying.value) return
|
|
|
|
|
|
isPlaying.value = true
|
|
|
|
|
|
|
2026-01-15 14:11:44 +08:00
|
|
|
|
// If finished, reset first
|
|
|
|
|
|
if (isFinished.value) {
|
|
|
|
|
|
reset()
|
|
|
|
|
|
isPlaying.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 12:25:48 +08:00
|
|
|
|
while (currentIndex.value < charStream.value.length) {
|
2026-01-15 14:11:44 +08:00
|
|
|
|
if (!isPlaying.value) break
|
|
|
|
|
|
|
2026-01-15 12:25:48 +08:00
|
|
|
|
const char = charStream.value[currentIndex.value]
|
|
|
|
|
|
|
|
|
|
|
|
// Processing Logic
|
|
|
|
|
|
if (parserState.value === 'NORMAL') {
|
|
|
|
|
|
if (char.val === '\x1B') {
|
|
|
|
|
|
parserState.value = 'ESCAPE'
|
|
|
|
|
|
lastAction.value = 'Start Sequence'
|
|
|
|
|
|
} else {
|
|
|
|
|
|
outputBuffer.value.push({
|
|
|
|
|
|
val: char.val,
|
|
|
|
|
|
style: { ...currentStyle.value }
|
|
|
|
|
|
})
|
|
|
|
|
|
lastAction.value = 'Print Char'
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (parserState.value === 'ESCAPE') {
|
|
|
|
|
|
// 简单模拟:遇到 'm' 结束
|
|
|
|
|
|
if (char.val === 'm') {
|
|
|
|
|
|
// 解析指令 (Hardcoded for demo)
|
|
|
|
|
|
const prevChar = charStream.value[currentIndex.value - 1]
|
|
|
|
|
|
if (prevChar.val === '1') {
|
|
|
|
|
|
currentStyle.value = { color: '#ff5f56', fontWeight: 'bold' }
|
|
|
|
|
|
lastAction.value = 'Execute: Set Color Red'
|
|
|
|
|
|
} else if (prevChar.val === '0') {
|
|
|
|
|
|
currentStyle.value = {}
|
|
|
|
|
|
lastAction.value = 'Execute: Reset Style'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Small delay to show "Executing" state
|
|
|
|
|
|
await new Promise(r => setTimeout(r, 200))
|
|
|
|
|
|
parserState.value = 'NORMAL'
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lastAction.value = 'Buffering...'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await new Promise(r => setTimeout(r, 600)) // Animation speed
|
2026-01-15 14:11:44 +08:00
|
|
|
|
|
|
|
|
|
|
// Check playing again after wait
|
|
|
|
|
|
if (!isPlaying.value) break
|
|
|
|
|
|
|
2026-01-15 12:25:48 +08:00
|
|
|
|
currentIndex.value++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 14:11:44 +08:00
|
|
|
|
if (currentIndex.value >= charStream.value.length) {
|
|
|
|
|
|
isPlaying.value = false
|
|
|
|
|
|
isFinished.value = true
|
|
|
|
|
|
lastAction.value = 'Done'
|
|
|
|
|
|
}
|
2026-01-15 12:25:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.parser-demo {
|
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-family: 'Menlo', monospace;
|
|
|
|
|
|
margin: 20px 0;
|
|
|
|
|
|
border: 1px solid #333;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.demo-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title {
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #ccc;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls button {
|
|
|
|
|
|
background: #333;
|
|
|
|
|
|
border: 1px solid #555;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
padding: 5px 12px;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
margin-left: 10px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls button.play-btn {
|
|
|
|
|
|
background: #0dbc79;
|
|
|
|
|
|
border-color: #0dbc79;
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls button:disabled {
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Stream Track */
|
|
|
|
|
|
.stream-container {
|
|
|
|
|
|
background: #111;
|
|
|
|
|
|
padding: 15px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.label {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stream-track {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
height: 60px;
|
2026-01-15 14:11:44 +08:00
|
|
|
|
/* Use a fixed height to contain the items */
|
2026-01-15 12:25:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stream-window-mask {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
position: relative;
|
2026-01-15 14:11:44 +08:00
|
|
|
|
height: 100%;
|
2026-01-15 12:25:48 +08:00
|
|
|
|
/* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stream-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
position: absolute;
|
2026-01-15 14:11:44 +08:00
|
|
|
|
left: 50%; /* Center the container start */
|
|
|
|
|
|
/*
|
|
|
|
|
|
Correct centering logic:
|
|
|
|
|
|
- Item width: 36px
|
|
|
|
|
|
- Gap: 4px
|
|
|
|
|
|
- Total unit: 40px
|
|
|
|
|
|
- We want Item[0] center to be at left:0 (relative to left:50%)
|
|
|
|
|
|
- Item[0] center is at: 18px (half width)
|
|
|
|
|
|
- So we need to shift left by 18px initially.
|
|
|
|
|
|
*/
|
|
|
|
|
|
margin-left: -18px;
|
2026-01-15 12:25:48 +08:00
|
|
|
|
transition: transform 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.char-box {
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 48px;
|
|
|
|
|
|
background: #2d2d2d;
|
|
|
|
|
|
border: 1px solid #444;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
transition: all 0.3s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.char-box.active {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
transform: scale(1.1);
|
|
|
|
|
|
box-shadow: 0 0 10px rgba(255,255,255,0.5);
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
border-color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.char-box.processed {
|
|
|
|
|
|
opacity: 0.3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.char-box.special {
|
|
|
|
|
|
border-color: #e5e510;
|
|
|
|
|
|
color: #e5e510;
|
|
|
|
|
|
}
|
|
|
|
|
|
.char-box.active.special {
|
|
|
|
|
|
background: #e5e510;
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.char-box.arg {
|
|
|
|
|
|
border-color: #11a8cd;
|
|
|
|
|
|
color: #11a8cd;
|
|
|
|
|
|
}
|
|
|
|
|
|
.char-box.active.arg {
|
|
|
|
|
|
background: #11a8cd;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.char-val { font-size: 14px; font-weight: bold; }
|
|
|
|
|
|
.char-code { font-size: 9px; opacity: 0.7; margin-top: 2px; }
|
|
|
|
|
|
|
|
|
|
|
|
.pointer {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: -5px;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: #0dbc79;
|
|
|
|
|
|
}
|
|
|
|
|
|
.arrow { font-size: 20px; line-height: 1; }
|
|
|
|
|
|
.pointer-label { font-size: 10px; white-space: nowrap; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* State Machine */
|
|
|
|
|
|
.parser-state-machine {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
background: #252526;
|
|
|
|
|
|
padding: 10px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
height: 60px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.state-box {
|
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: #333;
|
|
|
|
|
|
opacity: 0.3;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
transition: all 0.3s;
|
|
|
|
|
|
min-width: 100px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.state-box.active {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
background: #0dbc79;
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
box-shadow: 0 0 15px rgba(13, 188, 121, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.state-box.warning.active {
|
|
|
|
|
|
background: #e5e510;
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.state-name { font-weight: bold; font-size: 12px; }
|
|
|
|
|
|
.state-desc { font-size: 10px; opacity: 0.8; }
|
|
|
|
|
|
|
|
|
|
|
|
.arrow-right { color: #555; font-size: 18px; }
|
|
|
|
|
|
|
|
|
|
|
|
.action-log {
|
|
|
|
|
|
margin-left: 20px;
|
|
|
|
|
|
padding: 4px 12px;
|
|
|
|
|
|
background: #000;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
border: 1px solid #444;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
animation: flash 0.5s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Screen */
|
|
|
|
|
|
.terminal-screen {
|
|
|
|
|
|
background: #000;
|
|
|
|
|
|
border: 1px solid #333;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
padding: 15px;
|
|
|
|
|
|
min-height: 80px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.screen-content {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cursor {
|
|
|
|
|
|
animation: blink 1s infinite;
|
|
|
|
|
|
color: #0dbc79;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.explanation {
|
|
|
|
|
|
margin-top: 15px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge {
|
|
|
|
|
|
padding: 2px 6px;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
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; } }
|
|
|
|
|
|
|
|
|
|
|
|
</style>
|