feat: 统一页面布局与字体样式
This commit is contained in:
@@ -2,6 +2,8 @@ import { useState, useRef, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -36,7 +38,13 @@ export default function AIAssistant() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xs font-medium">AI 合规顾问</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">AI 合规顾问</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">智能分析用工风险,辅助合同审查与合规决策</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
{tabs.map((t) => {
|
||||
@@ -45,7 +53,7 @@ export default function AIAssistant() {
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -170,8 +178,9 @@ function ChatTab() {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 35 * 1000)
|
||||
const response = await fetch('/api/v1/ai/chat-stream', {
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60 * 1000)
|
||||
const chatUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1/ai/chat-stream' : '/api/v1/ai/chat-stream'
|
||||
const response = await fetch(chatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -191,6 +200,21 @@ function ChatTab() {
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ''
|
||||
let buffer = ''
|
||||
let rafId: number | null = null
|
||||
let pendingFlush = false
|
||||
|
||||
// 用 RAF 批量刷新,避免每个 token 触发一次 React 重渲染
|
||||
const flush = () => {
|
||||
pendingFlush = false
|
||||
rafId = null
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
}
|
||||
const scheduleFlush = () => {
|
||||
if (!pendingFlush) {
|
||||
pendingFlush = true
|
||||
rafId = requestAnimationFrame(flush)
|
||||
}
|
||||
}
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
@@ -207,14 +231,27 @@ function ChatTab() {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.delta) {
|
||||
accumulated += parsed.delta
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
scheduleFlush()
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
if (parsed.error) {
|
||||
throw new Error(parsed.error)
|
||||
}
|
||||
} catch (parseErr: any) {
|
||||
// 只有业务错误(有 message 且不是 SyntaxError)才抛出
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
// JSON 解析失败,可能是 SSE 分块截断,跳过等下一块
|
||||
continue
|
||||
}
|
||||
throw parseErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 确保最后一批内容被刷新
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (accumulated) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
}
|
||||
}
|
||||
if (!accumulated) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
|
||||
@@ -254,10 +291,40 @@ function ChatTab() {
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 pb-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-xs whitespace-pre-wrap ${
|
||||
msg.role === 'user' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-800'
|
||||
<div className={`max-w-[85%] px-4 py-3 rounded-lg text-sm leading-relaxed ${
|
||||
msg.role === 'user' ? 'bg-primary text-white whitespace-pre-wrap' : 'bg-white border border-gray-200 text-gray-800 shadow-sm'
|
||||
}`}>
|
||||
{msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')}
|
||||
{msg.role === 'assistant' ? (
|
||||
msg.content ? (
|
||||
<div className="prose prose-sm max-w-none
|
||||
prose-headings:text-gray-900 prose-headings:font-semibold
|
||||
prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2
|
||||
prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2
|
||||
prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1
|
||||
prose-p:my-2 prose-p:leading-relaxed
|
||||
prose-li:my-0.5 prose-li:leading-relaxed
|
||||
prose-ul:my-2 prose-ol:my-2
|
||||
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
|
||||
prose-strong:text-gray-900
|
||||
prose-hr:border-gray-200 prose-hr:my-4
|
||||
">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : loading && i === messages.length - 1 ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-500">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
思考中...
|
||||
</span>
|
||||
) : null
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user