2968484d2d
- 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
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|
||
|