#978 safe-align: 固化 frontend-v2 空白页修复真相源

- 新增 frontend-v2/ 为生产前端真相源(此前仅容器 dist,未入 git)
- vite.config.ts: minify:false 根治压缩器压坏 react-router 6.28 (o.slice)
- useApi.ts: asArray select 防御非裸数组返回值
- LandingPage/AppServices: Array.isArray coerce 双重防御 (apps.slice)
- 关联: INCIDENT-2026-08-23-blank-page.md / SOP-FRONTEND-ITERATIVE-DEVELOPMENT.md
- 公网验证通过 2026-08-23 09:31 (DOM 24493B, 无 slice 错误)
This commit is contained in:
QihuAI Design committed 2026-08-23 09:36:35 +08:00
1 parent 552a962123
commit 8f57956904
36 files changed
+7436

No files matched your search

@@ -0,0 +1,270 @@
import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import {
Send,
Sparkles,
Plus,
Search,
Settings,
MessageSquare,
Copy,
ThumbsUp,
ThumbsDown,
RotateCcw,
Trash2,
Loader2,
} from 'lucide-react';
import {
useConversations,
useMessages,
useCreateConversation,
useSendMessage,
useDeleteConversation,
} from '@/hooks/useApi';
import type { Conversation, ChatMessage } from '@/api/client';
export function ChatPage() {
const [selectedConv, setSelectedConv] = useState<string | null>(null);
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const [streamingContent, setStreamingContent] = useState('');
// API hooks
const { data: conversations = [], isLoading: convsLoading } = useConversations();
const { data: messages = [], isLoading: msgsLoading } = useMessages(selectedConv);
const createConv = useCreateConversation();
const sendMessage = useSendMessage(selectedConv || '');
const deleteConv = useDeleteConversation();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent]);
const handleNewChat = async () => {
try {
const conv = await createConv.mutateAsync({ title: '新对话' });
setSelectedConv(conv.id);
} catch {}
};
const handleSend = async () => {
if (!input.trim() || !selectedConv) return;
const content = input;
setInput('');
setStreamingContent('');
try {
// Try streaming first
const stream = chatApi.streamMessage(selectedConv, { content });
let accumulated = '';
for await (const chunk of stream) {
accumulated += chunk;
setStreamingContent(accumulated);
}
setStreamingContent('');
// Refresh messages
} catch {
// Fallback to regular send
await sendMessage.mutateAsync({ content });
}
};
const handleDelete = async (id: string) => {
try {
await deleteConv.mutateAsync(id);
if (selectedConv === id) setSelectedConv(null);
} catch {}
};
return (
<div className="flex h-[calc(100vh-4rem)] gap-4">
{/* Conversation List */}
<div className="w-72 flex-shrink-0 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="搜索对话..."
className="h-9 w-full rounded-lg bg-muted pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<Button
className="mx-3 mt-3 gap-2"
variant="outline"
onClick={handleNewChat}
disabled={createConv.isPending}
>
<Plus size={16} /> 新建对话
</Button>
<ScrollArea className="flex-1 px-2 py-2">
<div className="space-y-1">
{convsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
) : (
conversations.map((conv: Conversation) => (
<div key={conv.id} className="group relative">
<button
onClick={() => setSelectedConv(conv.id)}
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
selectedConv === conv.id
? 'bg-primary/10 text-primary'
: 'hover:bg-muted text-muted-foreground'
}`}
>
<div className="flex items-center gap-2">
<MessageSquare size={14} className="shrink-0" />
<span className="text-sm truncate flex-1">{conv.title}</span>
</div>
<div className="flex items-center gap-2 mt-1 ml-5">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{conv.model || 'Auto'}
</Badge>
<span className="text-[10px] text-muted-foreground">
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
</span>
</div>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(conv.id); }}
className="absolute right-2 top-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
>
<Trash2 size={12} className="text-destructive" />
</button>
</div>
))
)}
</div>
</ScrollArea>
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
{/* Messages */}
<ScrollArea className="flex-1 p-6">
{!selectedConv ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<Sparkles className="h-8 w-8 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">开始与 AI 对话吧</h3>
<p className="text-sm text-muted-foreground max-w-md">
支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天
</p>
<Button className="mt-4 gap-2" onClick={handleNewChat}>
<Plus size={16} /> 新建对话
</Button>
</div>
) : msgsLoading ? (
<div className="h-full flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : (
<div className="space-y-6">
{messages.map((msg: ChatMessage) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
}`}>
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<Copy size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<ThumbsUp size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<ThumbsDown size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<RotateCcw size={14} className="text-muted-foreground" />
</button>
</div>
)}
</div>
</motion.div>
))}
{/* Streaming indicator */}
{streamingContent && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="max-w-[80%] rounded-xl px-4 py-3 bg-muted text-foreground">
<div className="text-sm whitespace-pre-wrap">{streamingContent}</div>
<div className="flex items-center gap-1 mt-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.2s]" />
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.4s]" />
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
)}
</ScrollArea>
{/* Input Area */}
<div className="p-4 border-t border-border">
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'}
className="flex-1"
disabled={!selectedConv || sendMessage.isPending}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || !selectedConv || sendMessage.isPending}
className="gap-2"
>
{sendMessage.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Send size={16} />
)}
</Button>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles size={12} /> Auto(自动匹配)
</div>
<button className="p-1 hover:bg-muted rounded transition-colors">
<Settings size={16} className="text-muted-foreground" />
</button>
</div>
</div>
</div>
</div>
);
}
// Import for streaming
import { chatApi } from '@/api/client';