
1. 项目概述当复古情怀遇上现代开发最近在逛GitHub的时候偶然发现了一个叫gtwatts/mainframe的项目。说实话第一眼看到这个名字我脑子里立刻浮现出上世纪六七十年代那些占据整个房间、嗡嗡作响、面板上闪烁着无数指示灯的大型计算机。那种充满机械美感和历史厚重感的“大型机”Mainframe形象对很多老派程序员和科技爱好者来说有着难以言喻的吸引力。但显然在个人电脑和云计算如此普及的今天没人会真的在家里部署一台物理大型机。那么这个项目究竟想做什么点进去一看我发现gtwatts/mainframe是一个旨在通过现代技术很可能是容器化、模拟器或Web技术来复刻、模拟或致敬经典大型机操作环境与美学的开源项目。它不是为了运行真正的COBOL或汇编商业应用而是为了捕捉那种独特的交互体验、视觉风格和“感觉”。这就像有人用乐高积木搭建了一台复古收音机它不一定能真的收听广播但摆在那里就是一种情怀和艺术。对于我这样经历过从命令行到图形界面变迁的开发者来说这个项目瞬间击中了我的兴趣点。它解决的“需求”非常独特不是性能不是效率而是一种文化保存、教育体验和纯粹的美学乐趣。它适合谁呢我认为有几类人会对它感兴趣一是对计算机历史充满好奇的开发者与学生想亲手“触摸”一下计算史的活化石二是复古计算Retrocomputing爱好者喜欢收集和体验各种老式系统三是UI/UX设计师可以从那些极具时代特色的界面设计中汲取灵感四是任何对“极客艺术”有共鸣的人欣赏将复杂技术以怀旧形式呈现出来的创意。这个项目背后的核心其实是“模拟”Emulation与“再现”Recreation的艺术。它需要深入理解老式大型机的架构特点如通道、控制器、终端、操作逻辑如作业控制语言JCL以及那标志性的绿屏或琥珀色字符界面。然后用今天的HTML/CSS/JavaScript、Docker容器或者更底层的模拟器框架如QEMU将这些元素重新构建出来。这不仅仅是一个技术项目更是一次跨越时间的数字考古和再创作。2. 核心思路与技术选型拆解要构建一个像mainframe这样的项目摆在面前的第一道选择题就是究竟要做到多“真实”是追求指令集级别的硬件模拟能运行真实的操作系统如IBM z/OS和应用还是仅仅实现一个外观和交互逻辑相似的终端模拟器这两种路径的技术栈和复杂度天差地别。从项目名称和常见的开源实践来看gtwatts/mainframe更可能选择后者——即“终端模拟与界面再现”路线。原因很简单实现一个完整的系统模拟工作量巨大且涉及老式商业操作系统的版权问题虽然有些开源替代品如Hercules模拟器配合MVS 3.8j这类已进入公共领域的系统。而做一个“形神兼备”的终端则灵活得多也更容易在Web上传播和体验。2.1 架构设计分层模拟策略基于这个判断项目的核心架构可以分解为三个层次表现层Presentation Layer这是用户直接看到和交互的部分。核心任务是完美复刻大型机终端的视觉风格。这包括字体必须使用等宽字体最好是那种经典的“点阵”风格字体比如 IBM Plex Mono、VT323或者专门为复古终端设计的字体。字符的边缘不能太光滑要带点像素感。色彩经典的绿底黑字Phosphor Green或琥珀色Amber是灵魂。CSS中需要精确还原这种色调通常不是纯绿#00FF00而是偏暗、偏黄的绿比如 #00CC00 或 #33FF33背景则是深黑或深灰。光标块状闪烁光标而不是现代终端里的下划线或竖线光标。界面元素模拟终端周围的“边框”、状态指示灯如“电源”、“联机”、甚至虚拟的按键标签这些都能极大地增强沉浸感。交互逻辑层Interaction Logic Layer这一层负责处理用户的输入并模拟大型机终端的响应行为。关键点在于键盘映射大型机终端如IBM 3270的键盘布局和功能键PF1-PF24与PC键盘不同。需要将用户的按键映射到对应的功能上。输入域Field处理3270终端是块模式的屏幕被划分为多个输入域用户需要在这些域之间跳转用Tab键填写完毕后一次性提交。这与现代行模式的Unix终端每输入一行回车就发送截然不同。JavaScript需要模拟这种“填写表单-提交作业”的交互流程。“系统”响应当用户执行一个“命令”比如提交一个虚构的作业后需要生成符合大型机风格的响应信息例如作业号如JOB12345、返回码CC 0000表示成功等。后端/模拟层Backend/Emulation Layer这是最复杂的一层决定了项目的“仿真度”上限。有几个可能的选择纯前端模拟所有逻辑都在浏览器里用JavaScript完成。可以预置一些固定的“场景”和“响应”。优点是部署简单打开网页即玩。缺点是交互有限深度不足。后端模拟器桥接在服务器端运行一个轻量级的模拟器比如用Python模拟一个简单的作业调度系统前端通过WebSocket与之通信。这样就能实现更动态的、可编程的交互比如解析简单的JCL脚本并返回模拟的执行结果。这是平衡功能与复杂度的不错选择。完整系统模拟代理在服务器上运行Hercules这类完整的大型机模拟器前端作为其Web终端。这能提供最真实的体验但资源消耗大部署复杂且可能涉及版权灰色地带通常不适合作为一个面向大众的开源演示项目。对于gtwatts/mainframe这类以展示和体验为核心的项目“纯前端模拟”或“轻量后端桥接”是更务实和常见的选择。技术栈上前端自然是HTML/CSS/JavaScript的三件套可能会用到Canvas来绘制更复杂的界面元素或者用React/Vue来管理复杂的终端状态。后端如果存在可能会用Node.js、PythonFlask/FastAPI或Go来快速搭建一个处理模拟逻辑的API服务。注意在模拟任何历史系统尤其是与IBM大型机相关的元素时需要特别注意商标和版权的使用。项目应明确声明其“非官方”、“教育”、“致敬”性质避免使用受保护的Logo和商业软件名称。使用开源字体和自主创作的图形素材是最安全的方式。3. 关键实现细节与核心技术点确定了“轻量级终端模拟”这个方向后我们来深入拆解几个必须攻克的技术难点。这些点做得好项目的“味道”就对了。3.1 终端屏幕的精确渲染CSS的艺术用现代CSS去模拟一个40年前的CRT显示器效果听起来有点穿越但正是这种反差让项目有趣。核心挑战是“不完美感”的塑造。首先颜色和对比度不能太“数码”。纯黑#000000背景配上纯绿#00FF00文字在LED屏幕上会显得刺眼且虚假。我们需要调整色值。我经过多次试验发现一个比较舒服的方案是背景色使用#0a0a0a或#001100文字色使用#33ff33或#00cc00。可以再添加一个非常微妙的径向渐变背景模仿CRT屏幕中心稍亮、四周稍暗的效果。.terminal-screen { background-color: #0a0a0a; background-image: radial-gradient(circle at center, #1a1a1a 0%, #0a0a0a 70%); color: #33ff33; font-family: IBM Plex Mono, Courier New, monospace; font-size: 18px; line-height: 1.2; }其次扫描线效果是CRT的灵魂。可以通过CSS的线性渐变叠加一个半透明的条纹图案来实现。但要注意密度太密了像栅格太疏了没感觉。通常使用1px或2px高的透明条纹间隔。.terminal-screen::after { content: ; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(to bottom, transparent 0%, rgba(0, 255, 0, 0.05) 1px, transparent 2px ); background-size: 100% 3px; /* 控制扫描线间距 */ pointer-events: none; /* 确保不影响点击 */ z-index: 1; }最后字体和字符间距。必须使用等宽字体。font-weight不能太粗经典的终端字体是中等粗细。letter-spacing可以稍微调整让字符不那么拥挤。为了增强复古感甚至可以尝试给文字添加极轻微的text-shadow模拟CRT的轻微光晕text-shadow: 0 0 1px #33ff33;但这个要非常克制否则会模糊。3.2 块模式交互的JavaScript实现这是整个项目交互逻辑的核心。现代CLI是“行编辑模式”而IBM 3270是“全屏块模式”。我们需要在网页里模拟出这种体验。数据结构设计首先我们需要一个二维数组或一个对象数组来代表屏幕上的“字段”。每个字段对象需要包含起始行、起始列、长度、当前值、是否受保护只读、是否高亮当前输入焦点等属性。class TerminalField { constructor(row, col, length, protected false) { this.row row; this.col col; this.length length; this.value ; this.protected protected; // 受保护字段不可编辑 this.highlighted false; // 是否当前焦点 } }焦点导航我们需要模拟Tab键在可输入字段之间跳转的逻辑。这需要维护一个所有可输入字段protectedfalse的列表并按它们在屏幕上出现的顺序通常是先行后列排序。当用户按下Tab键时JavaScript找到当前焦点字段在列表中的索引然后切换到下一个字段并更新屏幕渲染比如将光标移动到新字段的起始位置并高亮该字段背景。输入处理当焦点在一个字段内时用户的键盘输入应该追加或插入到该字段的value中并受字段length的限制。同时屏幕上对应位置的字需要实时更新。这里需要小心处理退格键删除前一个字符和删除键的逻辑以及字段内光标的左右移动。提交与响应需要一个“提交”动作通常模拟为Enter键或某个PF键。提交时收集所有字段的值组装成一个“作业”对象。然后可以触发一个模拟的处理过程清空屏幕显示“作业已提交...”停顿片刻再显示一个模拟的系统响应页面包含作业号、返回码和可能的输出列表。这个响应页面可以是预定义的模板也可以根据输入字段的值动态生成一些简单的文本。实操心得在实现块模式时最大的坑是光标和焦点的管理。网页的输入焦点天然在input或textarea上但我们要模拟的是整个屏幕都是“可输入区域”。一个常见的技巧是在屏幕区域上方覆盖一个透明的、只读的textarea它始终获得网页焦点用于接收所有键盘事件。然后我们根据当前“模拟焦点字段”的位置在textarea旁边用绝对定位画一个自定义的块状光标。所有按键事件都由JavaScript拦截并转化为对我们内部字段数据模型的操作。这样既能获得键盘输入又能完全控制光标的显示和行为。3.3 模拟“系统”与命令集为了让体验不只是一个静态图片我们需要设计一套简单的、符合大型机语境的“命令”或“作业”来与用户互动。这不需要真实的大型机系统只需要一点创意和脚本。设计原则命令和响应要“像那么回事”。使用大型机系统中常见的缩写和格式。例如系统提示符可以是READY或。查看“作业”状态可以用STATUS JOB(xxxxx)。提交一个“作业”可以模拟一个简单的SUBMIT MY.JCL。响应信息格式固定如IEF142I JOB(JOB123) STARTED - TIME14:30:05 IEF143I JOB(JOB123) ENDED - CC0000 - TIME14:30:07IEF142I/IEF143I是IBM作业调度器的典型消息IDCC0000表示条件码0即成功。实现方式可以在前端维护一个状态机和一个命令解析器。当用户在命令行输入特定文本并回车后解析器匹配预定义的命令模式然后更新状态并生成响应文本输出到终端屏幕上。对于更复杂的模拟比如一个虚构的文件列表LIST USER.DATA.*可以预先准备一些静态数据或者用JavaScript动态生成一些符合命名规范如USER.DATA.FILE001的随机条目。增加趣味性可以隐藏一些“彩蛋”命令。比如输入DATE返回今天的日期但格式是YY.DDD年.一年中的第几天这种大型机常用格式。输入TIME返回UTC时间。甚至可以做一个简单的“Fortune Cookie”式命令随机返回一条复古风格的计算机格言或幽默信息。4. 从零搭建一个基础版“Mainframe”终端理论说了这么多我们来动手搭一个最简单的架子看看核心部分如何跑通。假设我们采用纯前端方案目标是实现一个具有复古外观并能响应几个基本命令的终端。4.1 项目初始化与结构首先创建一个标准的Web项目目录。mainframe-demo/ ├── index.html ├── style.css └── script.jsindex.html是入口结构要简单核心是一个用于显示终端输出的pre或div元素以及一个隐藏的input或textarea用于捕获全局按键。!DOCTYPE html html langen head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleMainframe Terminal Simulator/title link relstylesheet hrefstyle.css link hrefhttps://fonts.googleapis.com/css2?familyIBMPlexMono:wght400;500displayswap relstylesheet /head body div classmainframe-cabinet !-- 模拟机柜外壳 -- div classcontrol-panel div classled power-led on/div span classlabelPOWER/span !-- 可以添加更多虚拟指示灯和开关 -- /div div classterminal-container div idscreen classterminal-screen/div !-- 这个透明的textarea是捕获键盘事件的秘密武器 -- textarea idkeyboard-input classkeyboard-input autofocus/textarea /div div classbrand-labelSYSTEM/370 COMPATIBLE TERMINAL/div /div script srcscript.js/script /body /html4.2 核心样式塑造复古感style.css文件负责所有的视觉效果。我们从外到内定义样式。body { margin: 0; padding: 20px; background-color: #222; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: sans-serif; } /* 模拟大型机机柜 */ .mainframe-cabinet { background: linear-gradient(to bottom, #444, #222); border: 15px solid #333; border-radius: 10px; padding: 25px; box-shadow: 0 10px 30px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.1); position: relative; } /* 控制面板区域 */ .control-panel { background-color: #2a2a2a; padding: 10px 15px; border-radius: 5px; margin-bottom: 20px; display: flex; align-items: center; gap: 15px; } .led { width: 12px; height: 12px; border-radius: 50%; background-color: #555; box-shadow: inset 0 0 5px #000; } .led.on { background-color: #f00; box-shadow: 0 0 10px #f00, inset 0 0 5px #fff; } .label { color: #aaa; font-size: 12px; font-family: IBM Plex Mono, monospace; letter-spacing: 1px; } /* 终端屏幕容器 */ .terminal-container { background-color: #111; border: 8px solid #000; border-radius: 4px; padding: 5px; position: relative; overflow: hidden; /* 防止扫描线溢出 */ } /* 终端屏幕本身 - 核心区域 */ .terminal-screen { background-color: #0a0a0a; color: #33ff33; font-family: IBM Plex Mono, monospace; font-size: 18px; line-height: 1.25; height: 400px; padding: 20px; overflow-y: auto; white-space: pre-wrap; /* 保留空格和换行 */ word-wrap: break-word; position: relative; /* 添加CRT曲面效果非常 subtle */ border-radius: 2px; } /* 扫描线效果 */ .terminal-screen::after { content: ; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(to bottom, transparent 50%, rgba(0, 100, 0, 0.03) 51% ); background-size: 100% 4px; pointer-events: none; z-index: 2; } /* 隐藏的键盘输入捕获区 */ .keyboard-input { position: absolute; opacity: 0; width: 1px; height: 1px; top: 0; left: 0; pointer-events: none; /* 避免它干扰但保持可聚焦 */ } /* 品牌标签 */ .brand-label { color: #888; text-align: center; margin-top: 15px; font-size: 11px; letter-spacing: 2px; font-family: IBM Plex Mono, monospace; }4.3 JavaScript逻辑让终端活起来script.js是大脑。我们需要处理输入、维护输出缓冲区、解析命令。class MainframeTerminal { constructor(screenElementId, inputElementId) { this.screen document.getElementById(screenElementId); this.input document.getElementById(inputElementId); this.commandHistory []; this.historyIndex -1; this.currentInput ; this.prompt READY; this.init(); } init() { // 清屏并显示欢迎信息 this.clearScreen(); this.println(*** WELCOME TO MAINFRAME TERMINAL SIMULATOR ***); this.println(Type HELP for available commands.\n); this.printPrompt(); // 监听键盘输入 this.input.addEventListener(keydown, this.handleKeyDown.bind(this)); // 确保输入框始终聚焦 this.input.focus(); document.body.addEventListener(click, () this.input.focus()); } clearScreen() { this.screen.textContent ; } print(text) { this.screen.textContent text; this.scrollToBottom(); } println(text) { this.print(text \n); } printPrompt() { this.print(this.prompt ); } scrollToBottom() { this.screen.scrollTop this.screen.scrollHeight; } handleKeyDown(event) { // 阻止默认行为完全由我们控制 event.preventDefault(); const key event.key; if (key Enter) { // 执行命令 this.executeCommand(this.currentInput.trim()); this.commandHistory.push(this.currentInput); this.historyIndex this.commandHistory.length; this.currentInput ; } else if (key Backspace) { // 删除最后一个字符 if (this.currentInput.length 0) { // 从屏幕上删除最后一个字符需要更精细的控制这里简化 // 更好的做法是维护一个行缓冲区这里为简单直接重绘 this.currentInput this.currentInput.slice(0, -1); this.redrawInputLine(); } } else if (key ArrowUp) { // 历史命令上翻 if (this.commandHistory.length 0) { if (this.historyIndex 0) this.historyIndex--; if (this.historyIndex 0) { this.currentInput this.commandHistory[this.historyIndex]; this.redrawInputLine(); } } } else if (key ArrowDown) { // 历史命令下翻 if (this.historyIndex this.commandHistory.length - 1) { this.historyIndex; this.currentInput this.commandHistory[this.historyIndex]; this.redrawInputLine(); } else { this.historyIndex this.commandHistory.length; this.currentInput ; this.redrawInputLine(); } } else if (key.length 1 !event.ctrlKey !event.metaKey) { // 可打印字符 this.currentInput key; this.print(key); } // Tab, CtrlC 等键可以在这里添加特殊处理 } redrawInputLine() { // 这是一个简化版本。理想情况下应该只重写当前输入行。 // 这里为了演示先清屏再重绘所有内容仅用于Backspace/历史命令。 // 在生产代码中需要维护一个行缓冲区来高效更新。 let lines this.screen.textContent.split(\n); lines.pop(); // 移除旧的输入行 this.screen.textContent lines.join(\n) \n this.prompt this.currentInput; this.scrollToBottom(); } executeCommand(cmd) { this.println(); // 换行 cmd cmd.toUpperCase(); switch(cmd) { case : break; case HELP: this.println(Available commands:); this.println( HELP - Display this help); this.println( DATE - Show current date (YY.DDD format)); this.println( TIME - Show current time (UTC)); this.println( STATUS - Display system status); this.println( CLEAR - Clear the screen); this.println( ECHO ... - Echo back the input); this.println( JCL - Submit a sample JCL job); break; case DATE: const now new Date(); const year now.getFullYear().toString().slice(-2); const start new Date(now.getFullYear(), 0, 0); const diff now - start; const oneDay 1000 * 60 * 60 * 24; const dayOfYear Math.floor(diff / oneDay); this.println(Current date: ${year}.${dayOfYear.toString().padStart(3, 0)}); break; case TIME: const utcTime new Date().toUTCString().split( )[4]; this.println(Current time (UTC): ${utcTime}); break; case STATUS: this.println(SYSTEM STATUS:); this.println( CPU USAGE : 12%); this.println( MEMORY : 3456/16384 MB); this.println( ACTIVE JOBS : 7); this.println( SYSTEM : OPERATIONAL); break; case CLEAR: this.clearScreen(); this.printPrompt(); return; // 不打印新的提示符因为clearScreen后已经打印了 case JCL: this.println(IEF285I VOL SER NOS SYSRES.); this.println(IEF142I JOB(JOB00123) STARTED - TIME14:30:05); this.println(IEF376I JOB/JOB00123/00/ WAS PURGED.); this.println(IEF143I JOB(JOB00123) ENDED - CC0000 - TIME14:30:07); this.println( MAX COND CODE 0000 - NO JCL ERRORS); break; default: if (cmd.startsWith(ECHO )) { this.println(cmd.substring(5)); } else { this.println(COMMAND ${cmd} NOT RECOGNIZED. TYPE HELP.); } } this.printPrompt(); } } // 初始化终端 document.addEventListener(DOMContentLoaded, () { new MainframeTerminal(screen, keyboard-input); });这个基础版本已经具备了复古外观、命令输入、历史记录和几个内置命令。打开index.html你就能看到一个有模有样的“大型机”终端在浏览器中运行了。你可以输入HELP查看命令试试DATE、TIME和JCL感受一下模拟的系统响应。5. 进阶实现模拟块模式与作业提交基础命令行终端有了但我们最初的目标是模拟IBM 3270那样的块模式终端。让我们在前一个例子的基础上增加一个简单的块模式表单界面。5.1 设计一个简单的作业提交表单我们设计一个屏幕包含几个典型的3270字段作业名、程序员名、作业类别和提交按钮用一个PF键模拟。首先在HTML中定义屏幕的结构。我们将不再使用单一的文本输出区而是用多个div来模拟字段。!-- 替换掉原来的 #screen div -- div idscreen classterminal-screen div classscreen-titleISPF/PDF PRIMARY OPTION MENU (SIMULATED)/div div classform-row span classfield-labelJob Name:/span span idfield-jobname classinput-field>.input-field { background-color: #0a0a0a; color: #33ff33; border: none; outline: none; min-width: 150px; display: inline-block; font-family: IBM Plex Mono, monospace; letter-spacing: 1px; } .input-field.highlighted { background-color: #113311; /* 焦点字段的背景高亮色 */ /* 或者使用下划线经典3270是高亮背景 */ /* text-decoration: underline; */ } .field-label { color: #33ff33; min-width: 120px; display: inline-block; } .screen-title { color: #33ff33; text-align: center; margin-bottom: 30px; font-weight: bold; } .message-area { margin-top: 30px; color: #33ff33; min-height: 100px; border-top: 1px dashed #33ff33; padding-top: 10px; }5.2 JavaScript实现块模式逻辑我们需要重写JavaScript类来管理字段焦点、输入和PF键。class BlockModeTerminal { constructor() { this.fields []; this.currentFieldIndex 0; this.initFields(); this.initKeyboard(); this.setFocus(0); // 初始焦点在第一个字段 } initFields() { // 收集所有可输入字段 const fieldElements document.querySelectorAll(.input-field); fieldElements.forEach((el, idx) { const field { element: el, id: el.dataset.fieldId, value: el.textContent.trim().replace(/_/g, ), // 下划线代表空 length: el.textContent.length, protected: el.classList.contains(protected) }; this.fields.push(field); el.textContent field.value.padEnd(field.length, _); // 用下划线显示空白 }); } initKeyboard() { document.addEventListener(keydown, (event) { const key event.key; const currentField this.fields[this.currentFieldIndex]; if (key.startsWith(F) (parseInt(key.substring(1)) 1 parseInt(key.substring(1)) 24)) { // 处理PF键 event.preventDefault(); this.handlePFKey(parseInt(key.substring(1))); return; } if (event.key Tab) { // Tab键切换字段 event.preventDefault(); if (event.shiftKey) { this.moveFocus(-1); // ShiftTab 向前 } else { this.moveFocus(1); // Tab 向后 } return; } if (event.key Enter) { // 在块模式中Enter键可能用于字段内换行或提交这里我们简单处理为提交PF3 event.preventDefault(); this.handlePFKey(3); return; } // 处理字符输入到当前字段 if (key.length 1 !event.ctrlKey !event.metaKey !currentField.protected) { event.preventDefault(); this.handleCharacterInput(key, currentField); } // 处理退格键 if (event.key Backspace !currentField.protected) { event.preventDefault(); this.handleBackspace(currentField); } }); } moveFocus(direction) { // 移除当前焦点高亮 this.fields[this.currentFieldIndex].element.classList.remove(highlighted); // 寻找下一个可输入字段 let newIndex this.currentFieldIndex; do { newIndex (newIndex direction this.fields.length) % this.fields.length; } while (this.fields[newIndex].protected newIndex ! this.currentFieldIndex); // 跳过受保护字段 this.setFocus(newIndex); } setFocus(index) { this.currentFieldIndex index; const field this.fields[index]; field.element.classList.add(highlighted); // 在实际的3270模拟中这里还需要将光标一个闪烁的方块定位到字段的起始位置。 // 我们可以用一个绝对定位的div来模拟光标这里简化处理。 console.log(Focus moved to field ${field.id}); } handleCharacterInput(char, field) { let newValue field.value; // 简单逻辑替换掉第一个下划线或追加 const underscoreIndex newValue.indexOf(_); if (underscoreIndex ! -1) { // 替换第一个下划线 newValue newValue.substring(0, underscoreIndex) char newValue.substring(underscoreIndex 1); } else if (newValue.length field.length) { // 如果没有下划线但还没满追加 newValue char; } else { // 字段已满可以忽略或实现“覆盖”模式按Insert键切换 return; } field.value newValue; this.updateFieldDisplay(field); } handleBackspace(field) { let newValue field.value; // 找到最后一个非下划线字符将其替换为下划线 for (let i newValue.length - 1; i 0; i--) { if (newValue[i] ! _) { newValue newValue.substring(0, i) _ newValue.substring(i 1); field.value newValue; this.updateFieldDisplay(field); break; } } } updateFieldDisplay(field) { // 显示时用下划线填充剩余长度 field.element.textContent field.value.padEnd(field.length, _); } handlePFKey(pfNumber) { const msgArea document.getElementById(message-area); msgArea.innerHTML ; // 清空旧消息 if (pfNumber 3) { // PF3 - 提交 const jobName this.fields[0].value.replace(/_/g, ).trim() || NONAME; const programmer this.fields[1].value.replace(/_/g, ).trim() || USER; const jobClass this.fields[2].value.replace(/_/g, ).trim() || A; msgArea.innerHTML divIEF285I VOL SER NOS SYSRES./div; msgArea.innerHTML divIEF142I JOB(${jobName}) STARTED - TIME${new Date().toTimeString().split( )[0]}/div; // 模拟处理延迟 setTimeout(() { msgArea.innerHTML divIEF376I JOB/${jobName}/00/ WAS PURGED./div; msgArea.innerHTML divIEF143I JOB(${jobName}) ENDED - CC0000 - TIME${new Date().toTimeString().split( )[0]}/div; msgArea.innerHTML div MAX COND CODE 0000 - NO JCL ERRORS/div; msgArea.innerHTML div*** Job submitted by ${programmer}, Class${jobClass} ***/div; }, 800); } else if (pfNumber 12) { // PF12 - 取消/返回 msgArea.innerHTML div*** Operation cancelled ***/div; // 可以在这里重置表单 this.fields.forEach(f { f.value .padEnd(f.length, _); this.updateFieldDisplay(f); }); this.setFocus(0); } else { msgArea.innerHTML divPF${pfNumber} is not programmed in this simulation./div; } } } document.addEventListener(DOMContentLoaded, () { new BlockModeTerminal(); });现在打开页面你会看到一个表单式的界面。你可以用Tab键在字段间跳转直接输入字符用退格键删除。按下F3PF3键会“提交作业”并在下方消息区显示模拟的系统输出按下F12PF12会取消并重置表单。这就初步模拟了块模式终端的基本交互。注意事项这个示例为了清晰简化了很多。真实的3270模拟需要处理字段属性高强度、非显示、数字只读等、光标精确定位、更多的PF键和PA键、以及更复杂的屏幕流Screen Flow。此外捕获F1-F12键需要浏览器支持有些浏览器或环境可能需要特殊处理。在生产级项目中可能会使用更专业的终端模拟库如xterm.js并进行深度定制。6. 部署、优化与扩展思路一个本地运行的Demo和一个可以分享给别人的在线项目是两回事。为了让gtwatts/mainframe这类项目更具可玩性和传播性我们需要考虑部署和优化。6.1 静态部署与动态化我们的基础版和块模式版都是纯前端的这意味着它们可以轻松地部署到任何静态网站托管服务上比如GitHub Pages、Vercel、Netlify或Cloudflare Pages。只需将index.html,style.css,script.js三个文件推送到Git仓库并开启Pages功能即可获得一个永久的在线访问链接。如果想增加动态性比如让多个用户连接到同一个模拟的“系统”或者实现更复杂的作业处理逻辑就需要引入后端。一个轻量级的方案是使用Node.js Express或Python Flask搭建一个WebSocket服务器。前端通过WebSocket与后端通信后端维护一个简单的作业队列和状态机并向前端广播状态更新。这样就能模拟出“作业正在被处理”、“打印机正在输出”等需要状态同步的场景。6.2 性能与体验优化防抖与渲染优化在频繁更新屏幕内容比如快速输入时直接操作textContent或innerHTML可能导致性能问题。可以考虑使用文档片段DocumentFragment进行批量更新或者对输入事件进行防抖debounce。可访问性我们的模拟终端对视障用户不太友好。可以考虑增加ARIA标签或者提供一个“文本模式”版本将屏幕内容以纯文本形式暴露给屏幕阅读器。移动端适配复古终端通常在宽屏上效果更好但可以通过调整字体大小、布局和虚拟键盘来适配移动设备。持久化与状态保存使用浏览器的localStorage来保存用户偏好如颜色主题、字体大小或命令历史。6.3 内容与功能扩展一个基础的终端模拟器只是开始要让项目更有深度可以考虑以下扩展方向集成真实模拟器后端如前所述可以尝试将前端作为Hercules模拟器的Web终端。这需要后端服务器运行Hercules并提供一个WebSocket桥接服务。这样用户就能运行真实的MVS或z/OS系统如果拥有合法授权体验原汁原味的大型机操作。构建“软件博物馆”除了终端可以模拟其他经典大型机外围设备或软件界面。例如卡片阅读机做一个可视化界面让用户上传一个文本文件然后将其“转换”为一叠虚拟的穿孔卡片动画。磁带机模拟磁带旋转和读写指示灯的动画。行式打印机模拟打印机逐行输出的效果和声音。教育小游戏将学习大型机概念变成游戏。例如JCL拼图给出一个作业描述让用户用拖放的方式排列JCL语句。核心转储分析提供一个虚构的十六进制内存转储让用户找出其中的“bug”。主题与皮肤系统允许用户切换不同的终端主题不仅是绿屏和琥珀屏还可以是蓝屏、白屏甚至模仿特定型号终端如IBM 3279的彩色模式。API与插件化设计一个简单的API让其他开发者可以为其编写“插件”或“应用”比如一个模拟的COBOL编译器实际上只是语法检查器、一个虚拟的数据库查询工具等。7. 常见问题与避坑指南在开发和玩转这类复古模拟项目的过程中我踩过不少坑也总结了一些经验。Q1如何让终端字体看起来更“正宗”A1除了使用IBM Plex Mono还可以考虑以下开源字体Glass TTY VT220 完美复刻VT220终端的点阵字体。Perfect DOS VGA 437 经典的DOS字体也很有复古味。Terminus 一款优秀的等宽点阵字体清晰易读。 关键是要在CSS中设置font-smooth: never;或-webkit-font-smoothing: none;来关闭字体抗锯齿这样才能还原CRT显示器那种清晰的像素边缘。Q2键盘事件处理混乱特别是功能键和组合键A2JavaScript的event.key对于F1-F12、PrintScreen等键的支持因浏览器而异。更可靠的方法是同时监听event.keyCode已废弃但广泛支持或event.code。建议使用像Mousetrap或hotkeys-js这样的库来处理复杂的键盘快捷键它们能更好地处理跨浏览器兼容性和键位冲突。Q3模拟的“系统响应”太假如何增加真实感A3细节决定成败。延迟真实的系统会有处理时间。在显示“作业已开始”和“作业已完成”之间添加一个随机的、合理的延迟比如1-3秒。随机性不要让作业总是成功CC0000。可以随机引入一些“软错误”比如“数据集未找到IEF212I”或“空间不足IEF233A”并给出相应的错误码和信息。格式一致性收集真实的大型机系统消息样本严格模仿其格式、缩写和消息ID。甚至可以创建一个消息模板库。Q4项目结构随着功能增加变得混乱怎么办A4从一开始就考虑模块化。将代码分离terminal-ui.js 只负责屏幕渲染、光标绘制、颜色管理。keyboard-handler.js 处理所有键盘输入和映射。command-parser.js 解析用户输入的命令或表单数据。emulation-core.js 核心模拟逻辑如作业调度、虚拟文件系统。>