261 lines
8.1 KiB
TypeScript
261 lines
8.1 KiB
TypeScript
import * as readline from 'node:readline';
|
||
import { EventEmitter } from 'node:events';
|
||
type font_style = 'normal' | 'italic' | 'bold' | 'light' |'underline' | 'url';
|
||
|
||
interface log {
|
||
message: any;
|
||
font_color: string;
|
||
font_style: font_style[];
|
||
}
|
||
|
||
// 将 font_style 转为 ANSI 参数;normal 不产生参数
|
||
function style_codes(font_style: font_style[]): number[] {
|
||
const codes: number[] = [];
|
||
for (const style of font_style ?? []) {
|
||
switch (style) {
|
||
case 'bold':
|
||
codes.push(1);
|
||
break;
|
||
case 'light':
|
||
codes.push(2);
|
||
break;
|
||
case 'italic':
|
||
codes.push(3);
|
||
break;
|
||
case 'underline':
|
||
case 'url':
|
||
codes.push(4);
|
||
break;
|
||
case 'normal':
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
return codes;
|
||
}
|
||
|
||
// 解析 #rgb / #rrggbb,失败返回 null
|
||
function parse_hex_color(color: string): [number, number, number] | null {
|
||
if (typeof color !== 'string') {
|
||
return null;
|
||
}
|
||
const value = color.trim().replace(/^#/, '');
|
||
if (/^[0-9a-fA-F]{3}$/.test(value)) {
|
||
return [
|
||
parseInt(value[0] + value[0], 16),
|
||
parseInt(value[1] + value[1], 16),
|
||
parseInt(value[2] + value[2], 16),
|
||
];
|
||
}
|
||
if (/^[0-9a-fA-F]{6}$/.test(value)) {
|
||
return [
|
||
parseInt(value.slice(0, 2), 16),
|
||
parseInt(value.slice(2, 4), 16),
|
||
parseInt(value.slice(4, 6), 16),
|
||
];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 将一段 log 渲染为带颜色的字符串
|
||
function to_ansi(segment: log): string {
|
||
const message = segment.message === undefined
|
||
? 'undefined'
|
||
: typeof segment.message === 'string'
|
||
? segment.message
|
||
: String(segment.message);
|
||
|
||
const codes = style_codes(segment.font_style ?? []);
|
||
const rgb = parse_hex_color(segment.font_color);
|
||
if (rgb !== null) {
|
||
codes.push(38, 2, rgb[0], rgb[1], rgb[2]);
|
||
}
|
||
if (codes.length === 0) {
|
||
return message;
|
||
}
|
||
return `\x1b[${codes.join(';')}m${message}\x1b[0m`;
|
||
}
|
||
|
||
export type console_segment = log | string | number | boolean | null | undefined;
|
||
export type console_input = console_segment | console_segment[];
|
||
|
||
export interface _ConsolePrint {
|
||
info(message: any): log;
|
||
success(message: any): log;
|
||
warning(message: any): log;
|
||
error(message: any): log;
|
||
tip(message: any): log;
|
||
custom(message: any, font_color: string, font_style: font_style[]): log;
|
||
log(input: console_input): void;
|
||
on(event: string | symbol, listener: (...args: any[]) => void): this;
|
||
once(event: string | symbol, listener: (...args: any[]) => void): this;
|
||
enable_input(prompt?: string): void;
|
||
disable_input(): void;
|
||
set_prompt(prompt: string): void;
|
||
}
|
||
|
||
export class ConsolePrint extends EventEmitter implements _ConsolePrint {
|
||
private rl: readline.Interface | null = null;
|
||
private prompt = '> ';
|
||
private input_enabled = false;
|
||
// 输入行提交后是否需要重新显示提示符(write_line 已恢复时置为 false)
|
||
private need_prompt = false;
|
||
|
||
// 监听 input 时自动开启控制台输入
|
||
public on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||
if (event === 'input') {
|
||
this.enable_input();
|
||
}
|
||
return super.on(event, listener);
|
||
}
|
||
|
||
public once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||
if (event === 'input') {
|
||
this.enable_input();
|
||
}
|
||
return super.once(event, listener);
|
||
}
|
||
|
||
public info(message: any): log { // 白色
|
||
return {
|
||
message,
|
||
font_color: '#ffffff',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public success(message: any): log { // 柔和绿
|
||
return {
|
||
message,
|
||
font_color: '#8fb98f',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public warning(message: any): log { // 柔和沙黄
|
||
return {
|
||
message,
|
||
font_color: '#d1b478',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public error(message: any): log { // 柔和玫红
|
||
return {
|
||
message,
|
||
font_color: '#cf8e8e',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public tip(message: any): log { // 柔和钢蓝
|
||
return {
|
||
message,
|
||
font_color: '#8fb0c9',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public custom(message: any, font_color: string, font_style: font_style[]): log {
|
||
return {
|
||
message,
|
||
font_color,
|
||
font_style
|
||
};
|
||
}
|
||
|
||
// 多段拼接后在同一行输出,每段可拥有独立的颜色与样式;支持单段或数组
|
||
public log(input: console_input): void {
|
||
const segments: console_segment[] = Array.isArray(input) ? input : [input];
|
||
if (segments.length === 0) {
|
||
return;
|
||
}
|
||
const body = segments.map(segment => this.format(segment)).join('');
|
||
this.write_line(this.format(this.timestamp()) + body);
|
||
}
|
||
|
||
// 自动前缀:[month_day hh:mm:ss],统一使用 success 色
|
||
private timestamp(): log {
|
||
const now = new Date();
|
||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||
const day = String(now.getDate()).padStart(2, '0');
|
||
const hour = String(now.getHours()).padStart(2, '0');
|
||
const minute = String(now.getMinutes()).padStart(2, '0');
|
||
const second = String(now.getSeconds()).padStart(2, '0');
|
||
return this.success(`[${month}-${day} ${hour}:${minute}:${second}] `);
|
||
}
|
||
|
||
private format(segment: log | string | number | boolean | null | undefined): string {
|
||
if (segment !== null && segment !== undefined && typeof segment === 'object' && 'message' in segment) {
|
||
return to_ansi(segment as log);
|
||
}
|
||
return String(segment);
|
||
}
|
||
|
||
// 有输入时先清掉当前输入行,打印日志后再把输入行恢复,避免输入被打断
|
||
private write_line(text: string): void {
|
||
if (this.rl !== null && this.input_enabled && process.stdout.isTTY === true) {
|
||
readline.cursorTo(process.stdout, 0);
|
||
readline.clearLine(process.stdout, 0);
|
||
process.stdout.write(text + '\n');
|
||
this.rl.prompt(true);
|
||
this.need_prompt = false;
|
||
return;
|
||
}
|
||
process.stdout.write(text + '\n');
|
||
}
|
||
|
||
// 开启控制台输入,按回车后通过 'input' 事件抛出整行内容
|
||
public enable_input(prompt?: string): void {
|
||
if (prompt !== undefined) {
|
||
this.prompt = prompt;
|
||
}
|
||
if (this.rl !== null) {
|
||
this.rl.setPrompt(this.prompt);
|
||
return;
|
||
}
|
||
this.rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout,
|
||
terminal: process.stdout.isTTY === true
|
||
});
|
||
this.rl.setPrompt(this.prompt);
|
||
this.rl.on('line', (line: string) => {
|
||
// 若输入处理期间没有输出日志,readline 已在换行后清空输入行,这里补回提示符;
|
||
// 若期间输出过日志,write_line 已恢复提示符,避免重复
|
||
this.need_prompt = true;
|
||
this.emit('input', line);
|
||
if (this.rl !== null && this.need_prompt) {
|
||
this.rl.prompt();
|
||
}
|
||
this.need_prompt = false;
|
||
});
|
||
this.rl.on('close', () => {
|
||
this.rl = null;
|
||
this.input_enabled = false;
|
||
});
|
||
this.input_enabled = true;
|
||
this.rl.prompt();
|
||
}
|
||
|
||
public disable_input(): void {
|
||
if (this.rl === null) {
|
||
return;
|
||
}
|
||
const rl = this.rl;
|
||
this.rl = null;
|
||
this.input_enabled = false;
|
||
rl.close();
|
||
}
|
||
|
||
public set_prompt(prompt: string): void {
|
||
this.prompt = prompt;
|
||
this.rl?.setPrompt(prompt);
|
||
if (this.rl !== null && this.input_enabled) {
|
||
this.rl.prompt(true);
|
||
}
|
||
}
|
||
}
|
||
|
||
export const console_print = new ConsolePrint();
|