feat: 统一页面布局与字体样式

This commit is contained in:
selfrelease
2026-07-24 17:30:36 +08:00
parent 4ae24990a7
commit a28b065ab8
25 changed files with 2032 additions and 259 deletions
+77 -10
View File
@@ -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>
))}