import { Fragment, type ReactNode } from 'react' /** * 轻量 Markdown 渲染器 — 零依赖,专为 AI 财务分析报告设计。 * * 支持的语法(AI 财务分析提示词约束的子集,足够用): * - 标题 # ## ### #### * - 加粗 **text** * - 行内代码 `code` * - 无序列表 - / * * - 有序列表 1. * - 表格 | a | b | * - 引用 > * - 分隔线 --- / *** * - 段落 * * 不追求完整 GFM,只覆盖 AI 报告会产出的结构。 */ // ===== 行内格式:加粗 / 行内代码 / 星号评级 ===== function renderInline(text: string, keyBase: string): ReactNode[] { const nodes: ReactNode[] = [] // 正则:匹配 **加粗** 或 `代码` 或 ★ 评级 const re = /(\*\*([^*]+)\*\*)|(`([^`]+)`)/g let last = 0 let m: RegExpExecArray | null let i = 0 while ((m = re.exec(text)) !== null) { if (m.index > last) nodes.push({text.slice(last, m.index)}) if (m[1]) { // 加粗 nodes.push({m[2]}) } else if (m[3]) { // 行内代码 nodes.push( {m[4]} , ) } last = m.index + m[0].length i++ } if (last < text.length) nodes.push({text.slice(last)}) return nodes } // ===== 表格解析 ===== function parseTable(lines: string[], start: number): { rows: string[][]; consumed: number } | null { // 找到连续的表格行(以 | 开头) const tableLines: string[] = [] let idx = start while (idx < lines.length && lines[idx].trim().startsWith('|')) { tableLines.push(lines[idx].trim()) idx++ } if (tableLines.length < 2) return null // 第二行必须是分隔行 |---|---| if (!/^|[\s-:|]+$/.test(tableLines[1]) && !tableLines[1].split('|').every(c => /^[\s-:]*$/.test(c))) { return null } const parseRow = (line: string) => line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim()) const header = parseRow(tableLines[0]) const body = tableLines.slice(2).map(parseRow) return { rows: [header, ...body], consumed: tableLines.length } } // ===== 主渲染 ===== export function MarkdownRenderer({ content }: { content: string }) { const lines = content.replace(/\r\n/g, '\n').split('\n') const blocks: ReactNode[] = [] let i = 0 let key = 0 while (i < lines.length) { const line = lines[i] const trimmed = line.trim() // 空行 if (!trimmed) { i++ continue } // 分隔线 if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { blocks.push(
) i++ continue } // 标题 const hMatch = trimmed.match(/^(#{1,4})\s+(.+)$/) if (hMatch) { const level = hMatch[1].length const text = hMatch[2] const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs' const mtCls = level <= 2 ? 'mt-6' : 'mt-5' blocks.push(
{renderInline(text, `h-${key}`)}
, ) i++ continue } // 引用 if (trimmed.startsWith('>')) { const quoteLines: string[] = [] while (i < lines.length && lines[i].trim().startsWith('>')) { quoteLines.push(lines[i].trim().replace(/^>\s?/, '')) i++ } blocks.push(
{renderInline(quoteLines.join(' '), `q-${key}`)}
, ) continue } // 表格 if (trimmed.startsWith('|')) { const table = parseTable(lines, i) if (table) { const [header, ...body] = table.rows const ncol = header.length blocks.push(
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */} {Array.from({ length: ncol - 1 }).map((_, ci) => ( ))} {header.map((cell, ci) => ( ))} {body.map((row, ri) => ( {row.map((cell, ci) => ( ))} ))}
{renderInline(cell, `th-${key}-${ci}`)}
{renderInline(cell, `td-${key}-${ri}-${ci}`)}
, ) i += table.consumed continue } } // 无序列表 if (/^[-*]\s+/.test(trimmed)) { const items: string[] = [] while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*]\s+/, '')) i++ } blocks.push( , ) continue } // 有序列表 if (/^\d+\.\s+/.test(trimmed)) { const items: string[] = [] while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+\.\s+/, '')) i++ } blocks.push(
    {items.map((item, ii) => (
  1. {ii + 1} {renderInline(item, `ol-${key}-${ii}`)}
  2. ))}
, ) continue } // 普通段落 blocks.push(

{renderInline(trimmed, `p-${key}`)}

, ) i++ } // 注意:外层不加 space-y-*,否则会覆盖各块自己的 margin-top 导致间距失效。 // 让各块的 my-* 自然叠加(margin collapse),间距更可控。 return
{blocks}
}