Files
TurboHR/frontend/src/pages/ai-assistant/HRReportTab.tsx
T
freedakgmail 2968484d2d 优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割
- AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割
- xlsx改为动态导入, OvertimeTab从345KB降至12.7KB
- api-services.ts: 请求参数 any→Record<string,unknown>
- 移除前端3处console.log残留
- 后端console替换为pino logger
- 前后端未使用import/变量清理
- Zod schema验证: termination/platform/special-status/work-process
- 新增 leave.routes.ts, acceptance-test.routes.ts
- UI组件: PageGuide, QueryError, Stepper
2026-08-04 07:53:37 +08:00

260 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AbortController | null>(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 (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-medium flex items-center gap-1.5">
<TrendingUp className="w-4 h-4 text-primary" />
AI
</h2>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
<div className="flex items-center gap-2">
{result && !loading && (
<button
onClick={handleExport}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
>
<Download className="w-3.5 h-3.5" />
Word
</button>
)}
<Button size="sm" onClick={handleGenerate} disabled={loading}>
{loading ? (
<><Loader2 className="w-4 h-4 mr-1 animate-spin" />...</>
) : (
<><Sparkles className="w-4 h-4 mr-1" /></>
)}
</Button>
</div>
</div>
{!result && !loading && (
<div className="text-center py-12 text-gray-400">
<TrendingUp className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm">"生成报告"AI </p>
</div>
)}
{loading && !result && (
<div className="text-center py-12">
<Loader2 className="w-8 h-8 mx-auto mb-3 text-primary animate-spin" />
<p className="text-sm text-gray-500">AI ...</p>
</div>
)}
{result && (
<div className="prose prose-sm max-w-none
prose-headings:text-gray-800 prose-headings:font-semibold
prose-h1:text-lg prose-h1:border-b prose-h1:pb-2 prose-h1:border-gray-200
prose-h2:text-base prose-h2:mt-4
prose-h3:text-sm prose-h3:mt-3
prose-p:text-gray-600 prose-p:leading-relaxed
prose-li:text-gray-600 prose-li:leading-relaxed
prose-strong:text-gray-800
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
prose-table:text-xs prose-table:border-collapse
prose-th:bg-gray-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{result}
</ReactMarkdown>
</div>
)}
</Card>
</div>
)
}