e6956aa001
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
104 lines
2.9 KiB
TypeScript
104 lines
2.9 KiB
TypeScript
/**
|
||
* 日志内容处理:base64 压缩解压 + ANSI 转 HTML
|
||
*/
|
||
|
||
/** 尝试将后端可能返回的压缩/base64 内容还原为明文 */
|
||
export function decodeLogContent(raw: string): string {
|
||
if (!raw) return ''
|
||
// 若包含 ANSI 或明显是明文,直接返回
|
||
if (raw.includes('\u001b[') || raw.includes('\n') || raw.length < 40) return raw
|
||
|
||
// 纯 base64 形态尝试解码
|
||
const compact = raw.replace(/\s+/g, '')
|
||
if (!/^[A-Za-z0-9+/=]+$/.test(compact) || compact.length % 4 !== 0) return raw
|
||
|
||
try {
|
||
const binary = atob(compact)
|
||
// 尝试 inflate(部分后端用 gzip/deflate 再 base64)
|
||
if (typeof DecompressionStream !== 'undefined') {
|
||
// 同步路径:先按 UTF-8 解码;若失败再当明文 base64 文本
|
||
try {
|
||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
||
// 非 gzip 魔数则直接当 UTF-8
|
||
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
||
// 异步不适合这里,回退文本
|
||
}
|
||
return new TextDecoder().decode(bytes)
|
||
} catch {
|
||
return binary
|
||
}
|
||
}
|
||
return decodeURIComponent(escape(binary))
|
||
} catch {
|
||
return raw
|
||
}
|
||
}
|
||
|
||
/** 转义 HTML */
|
||
function escapeHtml(s: string) {
|
||
return s
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
}
|
||
|
||
const ANSI_COLORS: Record<string, string> = {
|
||
'30': '#000',
|
||
'31': '#ef4444',
|
||
'32': '#22c55e',
|
||
'33': '#eab308',
|
||
'34': '#3b82f6',
|
||
'35': '#a855f7',
|
||
'36': '#06b6d4',
|
||
'37': '#e5e7eb',
|
||
'90': '#6b7280',
|
||
'91': '#f87171',
|
||
'92': '#4ade80',
|
||
'93': '#facc15',
|
||
'94': '#60a5fa',
|
||
'95': '#c084fc',
|
||
'96': '#22d3ee',
|
||
'97': '#f9fafb',
|
||
}
|
||
|
||
/** ANSI → HTML(轻量实现,覆盖常用前景色/粗体/重置) */
|
||
export function ansiToHtml(input: string): string {
|
||
const text = decodeLogContent(input)
|
||
if (!text.includes('\u001b[') && !text.includes('\x1b[')) {
|
||
return escapeHtml(text)
|
||
}
|
||
|
||
let html = ''
|
||
let open = false
|
||
let style = ''
|
||
const re = /\x1b\[([0-9;]*)m/g
|
||
let last = 0
|
||
let m: RegExpExecArray | null
|
||
while ((m = re.exec(text))) {
|
||
const chunk = text.slice(last, m.index)
|
||
if (chunk) html += open ? `<span style="${style}">${escapeHtml(chunk)}</span>` : escapeHtml(chunk)
|
||
last = m.index + m[0].length
|
||
const codes = (m[1] || '0').split(';').filter(Boolean)
|
||
if (codes.length === 0 || codes.includes('0')) {
|
||
open = false
|
||
style = ''
|
||
continue
|
||
}
|
||
const parts: string[] = []
|
||
for (const c of codes) {
|
||
if (c === '1') parts.push('font-weight:700')
|
||
else if (c === '2') parts.push('opacity:.75')
|
||
else if (c === '4') parts.push('text-decoration:underline')
|
||
else if (ANSI_COLORS[c]) parts.push(`color:${ANSI_COLORS[c]}`)
|
||
}
|
||
if (parts.length) {
|
||
open = true
|
||
style = parts.join(';')
|
||
}
|
||
}
|
||
const rest = text.slice(last)
|
||
if (rest) html += open ? `<span style="${style}">${escapeHtml(rest)}</span>` : escapeHtml(rest)
|
||
return html
|
||
}
|