import { useState, useRef } from 'react' import { toast } from 'sonner' import { Sparkles, Loader2, Download, TrendingUp } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import { Document, Packer, Paragraph, HeadingLevel, TextRun, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType } from 'docx' import { saveAs } from 'file-saver' import { useAuthStore } from '../../store/authStore' import Card from '../../components/ui/Card' import Button from '../../components/ui/Button' /** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */ function parseInlineBold(text: string): TextRun[] { const runs: TextRun[] = [] const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g let lastIndex = 0 let match while ((match = regex.exec(text)) !== null) { if (match.index > lastIndex) { runs.push(new TextRun({ text: text.slice(lastIndex, match.index) })) } if (match[2]) { runs.push(new TextRun({ text: match[2], bold: true })) } else if (match[3]) { runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 })) } lastIndex = regex.lastIndex } if (lastIndex < text.length) { runs.push(new TextRun({ text: text.slice(lastIndex) })) } return runs.length ? runs : [new TextRun({ text })] } /** 导出 Markdown 文本为 Word 文档 */ async function exportMarkdownToWord(markdown: string, fileName: string) { const lines = markdown.split('\n') const children: (Paragraph | Table)[] = [] let i = 0 while (i < lines.length) { const line = lines[i] if (!line.trim()) { i++; continue } if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) { const headerCells = line.split('|').map(c => c.trim()).filter(Boolean) i += 2 const rows: TableRow[] = [] rows.push(new TableRow({ children: headerCells.map(text => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })], shading: { fill: 'F3F4F6' }, })), })) while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean) rows.push(new TableRow({ children: cells.map(text => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text })] })], })), })) i++ } children.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } })) continue } if (line.startsWith('### ')) { children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] })) } else if (line.startsWith('## ')) { children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] })) } else if (line.startsWith('# ')) { children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] })) } else if (line.startsWith('> ')) { children.push(new Paragraph({ children: [new TextRun({ text: line.slice(2), italics: true })], indent: { left: 720 } })) } else if (line.startsWith('- ') || line.startsWith('* ')) { children.push(new Paragraph({ children: parseInlineBold(line.slice(2)), bullet: { level: 0 } })) } else if (/^\d+\.\s/.test(line)) { children.push(new Paragraph({ children: parseInlineBold(line.replace(/^\d+\.\s/, '')), numbering: { reference: 'default-numbering', level: 0 } })) } else if (line === '---' || line === '***') { children.push(new Paragraph({ children: [], border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } } })) } else { children.push(new Paragraph({ children: parseInlineBold(line) })) } i++ } const doc = new Document({ numbering: { config: [{ reference: 'default-numbering', levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }] }] }, sections: [{ children }], }) const blob = await Packer.toBlob(doc) saveAs(blob, fileName) } // 通用 AI 历史记录 hook // 通用历史记录栏组件 export function HRReportTab() { const [result, setResult] = useState('') const [loading, setLoading] = useState(false) const abortRef = useRef(null) const handleGenerate = async () => { if (loading) return abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller setLoading(true) setResult('') try { const token = useAuthStore.getState().accessToken const url = import.meta.env.DEV ? `http://localhost:3000/api/v1/ai/hr-report-stream` : `/api/v1/ai/hr-report-stream` const response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, signal: controller.signal, }) if (!response.ok) { const errData = await response.json().catch(() => null) throw new Error(errData?.error?.message || '请求失败') } const reader = response.body?.getReader() const decoder = new TextDecoder() let accumulated = '' let buffer = '' if (reader) { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6).trim() if (data === '[DONE]') continue try { const parsed = JSON.parse(data) if (parsed.delta) { accumulated += parsed.delta setResult(accumulated) } if (parsed.error) { throw new Error(parsed.error) } } catch (parseErr: any) { if (parseErr instanceof SyntaxError) continue throw parseErr } } } } setResult(accumulated) } } catch (err: any) { if (err.name !== 'AbortError') { toast.error(err.message || '生成报告失败') } } finally { setLoading(false) } } const handleExport = async () => { if (!result) return try { await exportMarkdownToWord(result, `人力分析报告_${new Date().toISOString().slice(0, 10)}.docx`) toast.success('Word 文档已导出') } catch { toast.error('导出失败') } } return (

AI 人力分析报告

基于企业实际数据自动生成:人力概况、风险提示、成本分析、合规建议、改进方向

{result && !loading && ( )}
{!result && !loading && (

点击"生成报告",AI 将基于企业当前数据自动生成结构化人力分析报告

)} {loading && !result && (

AI 正在分析企业数据并生成报告...

)} {result && (
{result}
)}
) }