优化: 大文件拆分+代码分割+按需加载+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
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user