Skip to main content

模拟命令行终端小组件

· 23 min read
Castamere
Code Aesthetic

一个在 React 中模拟命令行终端的小组件,用于向读者演示/教学时使用,目前支持以下功能:

  • 支持修改终端的 title
  • 支持修改用户、路径、conda 环境(同一终端能多次修改,便于演示修改的命令)
  • 支持两种输出模式:换行顶格显示、前缀空格,对齐显示
  • 新增支持 mysql 模式 New !

缘起

从刚接触 docusaurus 开始,就一直想做一个模拟终端的小组件,最初是看到了 prismjs 中的 command-line 插件,也了解到 docusaurus 中就是用 prismjs 进行代码块高亮的。但苦于一直没搞明白怎么用这个插件(而且后面觉得也没那么好看),遂动手写一个

首先来讲效果与如何使用,代码实现先放到了最后

使用

使用方式大致如下(在 docusaurus 中),首先将组件放到 src/components 目录下(组件在完整代码处查看),然后在文件中将必要的组件 import 进来

component

import {
Line,
TerminalLine,
TerminalResponse,
TerminalRoot,
} from "@site/src/components/CommandLine";
import Terminal from "./components/Terminal";

然后在需要使用的地方添加 TerminalRoot 组件,只有唯一参数 title,然后在其中根据需求使用 TerminalLineTerminalResponse 即可,这里给出一个样例:

接下来挨个讲两个关键组件 TerminalLineTerminalResponse

自定义内容 (参数)

TerminalLineTerminalResponse 都有以下的几个参数

  • userName:用户名,默认为空
  • dir:当前目录,默认为 ~
  • conda:当前 conda 环境,默认为空

接下来挨个演示参数

用户

默认不包含用户,当添加用户后,在路径前会有 userName@ 的标识符,效果如下:

代码如下:

<TerminalRoot title={"用户"}>
<TerminalLine userName="castamere" dir="">
<Comment text="# 有用户" />
</TerminalLine>
<TerminalLine dir="">
<Comment text="# 无用户" />
</TerminalLine>
</TerminalRoot>

路径

有时,我们可能想演示切换路径的操作,比如 cd, z 等命令,这时就可以添加 dir 参数,效果如下:

代码如下:

<TerminalRoot title={"路径"}>
<TerminalLine dir="home">
<Cmd text="cd" />
<Line text="./castamere/My-Website" />
</TerminalLine>
<TerminalLine dir="home/castamere/My-Website">
<Line text="" />
</TerminalLine>
</TerminalRoot>

conda

同样的,AI 深度学习这方面,我们很有可能会演示 conda 的操作,这里笔者也添加了一个 conda 的参数,效果如下:

代码如下:

<TerminalRoot title={"conda"}>
<TerminalLine>
<Cmd text="conda activate" />
<Line text="base" />
</TerminalLine>
<TerminalLine conda="base">
<Line text="" />
</TerminalLine>
</TerminalRoot>
important

读者们如果有其他好玩的需求,在评论区里留言,笔者会尽量满足大家的需求,目前笔者也就还想到了一个 git 的样式,可能后期会加上去

输入 (TerminalLine)

TerminalLine 输入的一般为一串命令,其中包括命令、参数、路径/文件、管道、注释等元素,这里逐个演示

important

注意,TerminalLine 的所有 children 都会渲染到同一行,并且每个元素直接会自动添加空格,读者们不需要手动添加空格

命令

首先是命令,组件为 <Cmd>,效果如下:

代码如下:

<TerminalRoot title={"命令"}>
<TerminalLine>
<Cmd text="ll" />
</TerminalLine>
<TerminalLine>
<Cmd text="grep" />
</TerminalLine>
<TerminalLine>
<Cmd text="mysql" />
</TerminalLine>
</TerminalRoot>

参数

参数的组件为 <Args>,效果如下:

代码如下:

<TerminalRoot title={"参数"}>
<TerminalLine>
<Cmd text="ps" />
<Args text="-a" />
</TerminalLine>
<TerminalLine>
<Cmd text="awk" />
<Args text="-F'|'" />
<Line text="'{print $1}'" />
</TerminalLine>
</TerminalRoot>

管道

管道的组件为 <Tunnel>,效果如下:

代码如下:

管道演示代码
<TerminalRoot title={"管道"}>
<TerminalLine>
<Cmd text="docker ps" />
<Args text="-a" />
<Tunnel text="|" />
<Cmd text="grep" />
<Line text="neutron" />
</TerminalLine>
<TerminalLine>
<Cmd text="cat" />
<Line text="/etc/ssh/sshd_config" />
<Tunnel text="|" />
<Cmd text="grep" />
<Line text="-v '^#'" />
<Tunnel text="|" />
<Cmd text="grep" />
<Line text="-v '^$'" />
</TerminalLine>
<TerminalLine>
<Cmd text="cat" />
<Line text="./id_rsa.pub" />
<Tunnel text="&gt;&gt;" />
<Line text="authorized_keys" />
</TerminalLine>
</TerminalRoot>

注释

注释的组件为 <Comment>,效果如下:

代码如下:

<TerminalRoot title={"注释"}>
<TerminalLine>
<Comment text="# 这是一条注释" />
</TerminalLine>
</TerminalRoot>

输出 (TerminalResponse)

TerminalResponse 输出的是命令执行后的结果,这里有两种输出方式。分别为换行顶格输出和对齐输出,当前缀内容比较长时,应该用换行顶格输出,效果在后面有演示

important

注意,TerminalResponsechildren 中的每一个 child 都会被渲染到一个新的行

换行顶格输出

换行顶格输出,即不显示前缀的任何内容,包括路径,用户等内容,直接开一个新行输出,效果如下:

代码如下:

换行顶格输出演示代码
<TerminalRoot title={"输出方式1: 换行顶格输出"}>
<TerminalLine
userName="castamere"
dir="home/castamere/My-Website"
conda="py26"
>
<Comment text="# 当前缀比较长,建议用👇这种方式" />
</TerminalLine>
<TerminalLine
userName="castamere"
dir="home/castamere/My-Website"
conda="py26"
>
<Cmd text="ps" />
<Args text="-a" />
</TerminalLine>
<TerminalResponse response_style="NEWLINE">
<Line text="PID TTY TIME CMD" />
<Line text=" 12 tty1 00:00:00 sh" />
<Line text=" 13 tty1 00:00:00 sh" />
<Line text=" 18 tty1 00:00:00 sh" />
<Line text=" 22 tty1 00:00:08 node" />
</TerminalResponse>
<TerminalLine
userName="castamere"
dir="home/castamere/My-Website"
conda="py26"
>
<Comment text="# 对比这个👇就会觉得很丑" />
</TerminalLine>
<TerminalLine
userName="castamere"
dir="home/castamere/My-Website"
conda="py26"
>
<Cmd text="ps" />
<Args text="-a" />
</TerminalLine>
<TerminalResponse
userName="castamere"
dir="home/castamere/My-Website"
conda="py26"
>
<Line text="PID TTY TIME CMD" />
<Line text=" 12 tty1 00:00:00 sh" />
<Line text=" 13 tty1 00:00:00 sh" />
<Line text=" 18 tty1 00:00:00 sh" />
<Line text=" 22 tty1 00:00:08 node" />
</TerminalResponse>
</TerminalRoot>

前缀空格,对齐输出

代码如下:

对齐输出演示代码
<TerminalRoot title={"输出方式2: 前缀空格,对齐输出"}>
<TerminalLine dir="">
<Comment text="# 大部分时候,用这个👇会比较舒服" />
</TerminalLine>
<TerminalLine dir="">
<Cmd text="ps" />
<Args text="-a" />
</TerminalLine>
<TerminalResponse dir="">
<Line text="PID TTY TIME CMD" />
<Line text=" 12 tty1 00:00:00 sh" />
<Line text=" 13 tty1 00:00:00 sh" />
<Line text=" 18 tty1 00:00:00 sh" />
<Line text=" 22 tty1 00:00:08 node" />
</TerminalResponse>
</TerminalRoot>

其他功能

高亮

给读者介绍时,如果输出内容较多,有时可能想高亮某一行,使用 <Emph> 组件即可,效果如下

滚动

内置了横向滚动条,如果输出太长,或者在移动端查看,也不用担心格式问题

智能选择

现在,Terminal 组件中,前缀内容将不可选中,读者可以直接复制多行命令进行使用。如下,读者可以体验一下区别

Mysql 模式 New !

Mysql 模式即模仿一个 Mysql 交互命令行,同样包括输入和输出,效果如下: 输入时,第一行开头固定为 mysql>,后续换行会自动补全 ->

输入 (MysqlLine)

MysqlLine 输入的为一个 sql 语句,其中包括 sql 关键字、函数、常量等元素,接下来逐个演示

sql 关键字

sql 关键字即 select, from, where 这些,组件为<Dbkey>。尽管 sql 是大小写不敏感的,但为了美观,笔者设置了自动将关键字大写

代码如下:

<TerminalRoot title={"sql 关键字"}>
<MysqlLine>
<Dbkey text="use" />
<Dbkey text="select" />
<Dbkey text="from" />
<Dbkey text="orderby" />
<Dbkey text="desc" />
</MysqlLine>
</TerminalRoot>

函数

这里指的是 sql 的内置函数,比如 sum, concat, replace 这些,组件为 <Dbfunction>,同样设置了自动大写

代码如下:

sql函数
<TerminalRoot title={"sql 函数"}>
<MysqlLine linebreak="MANUAL">
<Dbfunction text="sum" />
<br />
<Dbfunction text="concat" />
<br />
<Dbfunction text="replace" />
<br />
<Dbkey text="select" />
<Dbfunction text="ABS" />
<Line text="(-10)" />
<Dbkey text="as" />
<Line text="AbsoluteValue;" />
<br />
<Dbkey text="select" />
<Dbfunction text="getdate()" />
<Dbkey text=" as" />
<Line text="CurrentDate;" />
</MysqlLine>
</TerminalRoot>

常量

常量包括字符串常量、数字常量、日期常量等,组件为 <Dbvalue>

sql 常量
<TerminalRoot title={"sql 常量"}>
<MysqlLine linebreak="MANUAL">
<Dbkey text="select" />
<Dbvalue text="'hello world'" />
<Dbkey text="as" />
<Line text="greeting;" />
<br />
<Dbkey text="select" />
<Dbvalue text="123" />
<Dbkey text="as" />
<Line text="number;" />
<br />
<Dbkey text="select" />
<Dbvalue text="'2024-01-01'" />
<Dbkey text="as" />
<Line text="date;" />
</MysqlLine>
</TerminalRoot>

换行方式

important

注意,MysqlLine 可以设置两种换行方式,即 AUTOMANUAL,默认为 AUTO

  • AUTO:自动换行,每次遇到 sql 关键字都会换行,适用于 sql 较短的情况
  • MANUAL:手动换行,只在手动添加 <br/> 处换行

代码如下:

换行方式
换行方式
<TerminalRoot title="Mysql Mode">
<TerminalLine>
<Cmd text="mysql" />
<Args text="-uroot -p" />
</TerminalLine>
<TerminalResponse response_style="NEWLINE">
<Comment text="手动换行:" />
</TerminalResponse>
<MysqlLine linebreak="MANUAL">
<Dbkey text="use" />
<Line text="my_db;" />
<br />
<Dbkey text="select" />
<Line text="*" />
<Dbkey text="from" />
<Line text="user" />
<Dbkey text="orderby" />
<Line text="id" />
<Dbkey text="desc" />
<Line text=";" />
</MysqlLine>
<TerminalResponse response_style="NEWLINE">
<Comment text="自动换行:" />
</TerminalResponse>
<MysqlLine>
<Dbkey text="use" />
<Line text="my_db;" />
<Dbkey text="select" />
<Line text="*" />
<Dbkey text="from" />
<Line text="user" />
<Dbkey text="orderby" />
<Line text="id" />
<Dbkey text="desc" />
<Line text=";" />
</MysqlLine>
</TerminalRoot>

完整代码

最终完整代码如下,也可访问 github 下载

完整代码
CommandLine.tsx
import React from "react";
import styles from "./styles.module.css";

const USERNAME = "";
const CONDA = "";
const DIR = "castamere";
const RESPONSE_STYLE = "PLAIN";
const LINE_BREAK = "AUTO";

export const TerminalLine = ({
children,
conda = CONDA,
userName = USERNAME,
dir = DIR,
}) => {
const userNamePath = userName === "" ? `~/${dir} ` : `${userName}@~/${dir} `;
const env = conda === "" ? "" : `(${conda}) `;
const frontMatterContent = (
<>
<span className={styles.env}>{env}</span>
<span className={styles.rightArrow}></span>
<span className={styles.userNamePath}>{userNamePath}</span>
<span className={styles.dolar}>$ </span>
</>
);

return (
<div>
{frontMatterContent}
{children}
</div>
);
};

export const MysqlLine = ({ children, linebreak = LINE_BREAK }) => {
const childrenArray = React.Children.toArray(children);
const frontMatterContent = [
<span className={styles.mysql}>{"mysql> "}</span>,
<span className={styles.mysql}>{" -> "}</span>,
];

switch (linebreak) {
case "AUTO":
return (
<>
{
<div style={{ lineHeight: "1.5rem", alignItems: "" }}>
{childrenArray.map((child, index) => {
if (index === 0)
return (
<React.Fragment key={index}>
{frontMatterContent[0]}
{child}
</React.Fragment>
);

if (
React.isValidElement(child) &&
typeof child.type === "function"
) {
if (child.type.name === "Dbkey") {
return (
<React.Fragment key={index}>
<br />
{frontMatterContent[1]}
{child}
</React.Fragment>
);
}
return <React.Fragment key={index}>{child}</React.Fragment>;
}
})}
</div>
}
</>
);

case "MANUAL":
return (
<>
{
<div style={{ lineHeight: "1.5rem", alignItems: "" }}>
{childrenArray.map((child, index) => {
if (index === 0)
return (
<React.Fragment key={index}>
{frontMatterContent[0]}
{child}
</React.Fragment>
);

if (React.isValidElement(child)) {
if (child.type === "br") {
return (
<React.Fragment key={index}>
<br />
{frontMatterContent[1]}
</React.Fragment>
);
} else
return <React.Fragment key={index}>{child}</React.Fragment>;
}
})}
</div>
}
</>
);
break;
default:
break;
}
};

export const TerminalResponse = ({
children,
conda = CONDA,
userName = USERNAME,
dir = DIR,
response_style = RESPONSE_STYLE,
}) => {
const childrenArray = React.Children.toArray(children);
const userNamePath = userName === "" ? `~/${dir} ` : `${userName}@~/${dir} `;
const env = conda === "" ? "" : `(${conda}) `;
const frontMatterContent = (
<>
<span className={styles.env}>{env}</span>
<span className={styles.rightArrow}>+ </span>
<span className={styles.userNamePath}>{userNamePath}</span>
</>
);

switch (response_style) {
case "NEWLINE":
return (
<>
<div style={{ lineHeight: "1.5rem", alignItems: "" }}>
{childrenArray.map((child, index) => (
<div key={index}>{child}</div>
))}
</div>
</>
);

case "PLAIN":
return (
<div style={{ lineHeight: "1.5rem", alignItems: "" }}>
{childrenArray.map((child, index) => {
const frontMatter =
index === 0 ? (
frontMatterContent
) : (
<span style={{ opacity: 0 }}>{frontMatterContent}</span>
);

return (
<div key={index}>
{frontMatter}
{child}
</div>
);
})}
</div>
);

default:
break;
}
};

export const TerminalHeader = ({ title }) => {
return (
<div className={styles.toolBar}>
<div className={styles.dot} style={{ backgroundColor: "#fb5f57" }} />
<div className={styles.dot} style={{ backgroundColor: "#fdbf2d" }} />
<div className={styles.dot} style={{ backgroundColor: "#27cb3f" }} />
<div className={styles.title}>{title}</div>
</div>
);
};

export const Line = ({ text }) => {
return <span className={styles.line}>{text} </span>;
};

export const Cmd = ({ text }) => {
return <span className={styles.command}>{text} </span>;
};

export const Tunnel = ({ text }) => {
return <span className={styles.tunnel}>{text} </span>;
};

export const Emph = ({ text }) => {
return <span className={styles.emph}>{text} </span>;
};

export const Comment = ({ text }) => {
return <span className={styles.comment}>{text} </span>;
};

export const Args = ({ text }) => {
return <span className={styles.args}>{text} </span>;
};

export const Dbkey = ({ text }) => {
return <span className={styles.dbkey}>{text} </span>;
};

export const Dbvalue = ({ text }) => {
return <span className={styles.dbvalue}>{text} </span>;
};

export const Dbfunction = ({ text }) => {
return <span className={styles.dbfunction}>{text}</span>;
};

export const TerminalRoot = ({ children, title }) => {
return (
<div className={styles.card} aria-hidden="true">
<TerminalHeader title={title} />
<div className={styles.main}>{children}</div>
</div>
);
};

后记

希望这篇文章能帮助到你,如果你有任何问题,欢迎在评论区留言

P.S: 如果有什么好的需求与建议,也欢迎留言

Buy me a coffee ☕: