/** * 日志内容处理: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, '"') } const ANSI_COLORS: Record = { '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 ? `${escapeHtml(chunk)}` : 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 ? `${escapeHtml(rest)}` : escapeHtml(rest) return html }