Initial commit: TaskPool React panel
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 日志内容处理: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
|
||||
}
|
||||
Reference in New Issue
Block a user