feat(frontend): 步骤 6.6 — 页面与布局(登录页、聊天主页)

This commit is contained in:
hhs
2026-06-10 15:26:25 +08:00
parent 60f183b8e4
commit eb0061b644
5 changed files with 539 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
// Runtime config injection — generated at container startup
// Uncomment and set the value for production deployment:
// window.__ENV = { NEXT_PUBLIC_API_BASE_URL: "http://your-backend:8091/api/v1" };

View File

@@ -0,0 +1,31 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #94a3b8;
border-radius: 3px;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AI Agent Chat",
description: "AI 智能体对话平台",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh">
<body className="antialiased">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { setUserInfo, getUserInfo, clearUserInfo } from '@/utils/cookie';
export default function Login() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [msg, setMsg] = useState({ text: '', type: '' });
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [currentUser, setCurrentUser] = useState('');
useEffect(() => {
const info = getUserInfo();
if (info?.user) {
setIsLoggedIn(true);
setCurrentUser(info.user);
router.push('/');
}
}, [router]);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
setMsg({ text: '', type: '' });
if (!username || !password) {
setMsg({ text: '请输入账号与密码。', type: 'error' });
return;
}
if (username !== 'admin' || password !== 'admin') {
setMsg({ text: '账号或密码错误演示admin / admin。', type: 'error' });
return;
}
setUserInfo(username);
setMsg({ text: '登录成功,正在跳转…', type: 'info' });
setTimeout(() => router.push('/'), 500);
};
const handleLogout = () => {
clearUserInfo();
setIsLoggedIn(false);
setCurrentUser('');
setMsg({ text: '已退出登录。', type: 'info' });
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="w-full max-w-md p-8 bg-slate-800/80 backdrop-blur rounded-2xl shadow-2xl border border-slate-700">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">AI </h1>
<p className="text-slate-400"> AI </p>
</div>
{isLoggedIn ? (
<div className="text-center space-y-4">
<p className="text-slate-300">
<span className="text-emerald-400 font-semibold">{currentUser}</span>
</p>
<button
onClick={handleLogout}
className="w-full py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition"
>
退
</button>
</div>
) : (
<form onSubmit={handleLogin} className="space-y-5">
<div>
<label className="block text-sm text-slate-400 mb-1"></label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-emerald-400 transition"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1"></label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-emerald-400 transition"
placeholder="admin"
/>
</div>
<button
type="submit"
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-semibold rounded-lg transition"
>
</button>
<button
type="button"
onClick={() => { setUsername('admin'); setPassword('admin'); }}
className="w-full py-2 text-sm text-slate-400 hover:text-slate-300 transition"
>
</button>
</form>
)}
{msg.text && (
<div
className={`mt-4 p-3 rounded-lg text-sm text-center ${
msg.type === 'error'
? 'bg-red-500/20 text-red-400'
: 'bg-emerald-500/20 text-emerald-400'
}`}
>
{msg.text}
</div>
)}
</div>
</div>
);
}

367
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,367 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { getUserInfo, clearUserInfo } from '@/utils/cookie';
import { agentApi } from '@/api/agent';
import { AiAgentConfigResponseDTO } from '@/types/api';
interface Message {
id: string;
role: 'user' | 'agent';
content: string;
timestamp: number;
}
interface Session {
id: string;
backendSessionId?: string;
title: string;
messages: Message[];
lastModified: number;
}
const STORAGE_KEY = 'ai_agent_sessions';
function loadSessions(): Session[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveSessions(sessions: Session[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
}
export default function Home() {
const router = useRouter();
const messagesEndRef = useRef<HTMLDivElement>(null);
// 用户状态
const [currentUser, setCurrentUser] = useState('');
// Agent 状态
const [agents, setAgents] = useState<AiAgentConfigResponseDTO[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState('');
// 会话状态
const [sessions, setSessions] = useState<Session[]>([]);
const [currentSessionId, setCurrentSessionId] = useState('');
// 聊天状态
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [isSending, setIsSending] = useState(false);
// 检查登录状态 & 加载数据
useEffect(() => {
const info = getUserInfo();
if (!info?.user) {
router.push('/login');
return;
}
setCurrentUser(info.user);
// 加载 Agent 列表
agentApi
.queryAgentList()
.then(res => {
setAgents(res.data);
// 恢复上次选择的 Agent
const last = localStorage.getItem('ai_agent_last_agent');
if (last && res.data.some(a => a.agentId === last)) {
setSelectedAgentId(last);
} else if (res.data.length > 0) {
setSelectedAgentId(res.data[0].agentId);
}
})
.catch(console.error);
// 加载本地会话
const saved = loadSessions();
setSessions(saved);
if (saved.length > 0) {
setCurrentSessionId(saved[0].id);
setMessages(saved[0].messages);
}
}, [router]);
// 自动滚动到最新消息
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// 会话变更时持久化
useEffect(() => {
if (sessions.length > 0) saveSessions(sessions);
}, [sessions]);
const currentSession = sessions.find(s => s.id === currentSessionId);
const createSession = () => {
const id = Date.now().toString();
const newSession: Session = {
id,
title: `对话 ${sessions.length + 1}`,
messages: [],
lastModified: Date.now(),
};
setSessions(prev => [newSession, ...prev]);
setCurrentSessionId(id);
setMessages([]);
};
const deleteSession = (id: string) => {
setSessions(prev => {
const next = prev.filter(s => s.id !== id);
if (currentSessionId === id) {
if (next.length > 0) {
setCurrentSessionId(next[0].id);
setMessages(next[0].messages);
} else {
setCurrentSessionId('');
setMessages([]);
}
}
return next;
});
};
const switchSession = (id: string) => {
setCurrentSessionId(id);
const s = sessions.find(s => s.id === id);
setMessages(s?.messages || []);
};
const updateSessionMessages = (sessionId: string, msgs: Message[]) => {
setSessions(prev =>
prev.map(s =>
s.id === sessionId
? {
...s,
messages: msgs,
lastModified: Date.now(),
title:
msgs.length === 1
? msgs[0].content.slice(0, 20)
: s.title,
}
: s
)
);
};
const sendMessage = async () => {
if (!inputValue.trim() || !selectedAgentId || isSending) return;
if (!currentSessionId) createSession();
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: inputValue.trim(),
timestamp: Date.now(),
};
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInputValue('');
setIsSending(true);
// 记住上次选择的 Agent
localStorage.setItem('ai_agent_last_agent', selectedAgentId);
try {
// 获取或创建后端会话
let sessionId = currentSession?.backendSessionId;
if (!sessionId) {
const res = await agentApi.createSession(selectedAgentId, currentUser);
sessionId = res.data.sessionId;
setSessions(prev =>
prev.map(s =>
s.id === (currentSessionId || sessions[0]?.id)
? { ...s, backendSessionId: sessionId }
: s
)
);
}
// 发送消息
const res = await agentApi.chat({
agentId: selectedAgentId,
userId: currentUser,
sessionId: sessionId!,
message: userMsg.content,
});
const agentMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'agent',
content: res.data.content,
timestamp: Date.now(),
};
const updated = [...newMessages, agentMsg];
setMessages(updated);
updateSessionMessages(currentSessionId || sessions[0]?.id, updated);
} catch (err) {
const errMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'agent',
content: `错误:${err instanceof Error ? err.message : '请求失败'}`,
timestamp: Date.now(),
};
const updated = [...newMessages, errMsg];
setMessages(updated);
} finally {
setIsSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const handleLogout = () => {
clearUserInfo();
router.push('/login');
};
return (
<div className="h-screen flex bg-slate-900 text-white">
{/* 左侧栏 — 会话列表 */}
<div className="w-64 bg-slate-800 border-r border-slate-700 flex flex-col">
<div className="p-4 border-b border-slate-700">
<button
onClick={createSession}
className="w-full py-2 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg transition text-sm"
>
+
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{sessions.map(s => (
<div
key={s.id}
onClick={() => switchSession(s.id)}
className={`group flex items-center justify-between p-3 rounded-lg cursor-pointer transition ${
s.id === currentSessionId
? 'bg-slate-700'
: 'hover:bg-slate-700/50'
}`}
>
<span className="text-sm truncate flex-1">{s.title}</span>
<button
onClick={e => {
e.stopPropagation();
deleteSession(s.id);
}}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-400 transition ml-2"
>
</button>
</div>
))}
</div>
</div>
{/* 主聊天区域 */}
<div className="flex-1 flex flex-col">
{/* 顶部栏 */}
<div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-6">
<div className="flex items-center gap-3">
<span className="text-lg font-semibold">AI </span>
<select
value={selectedAgentId}
onChange={e => setSelectedAgentId(e.target.value)}
className="bg-slate-700 text-sm text-slate-300 px-3 py-1 rounded-lg border border-slate-600 focus:outline-none"
>
{agents.map(a => (
<option key={a.agentId} value={a.agentId}>
{a.agentName}
</option>
))}
</select>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-400">{currentUser}</span>
<button
onClick={handleLogout}
className="text-sm text-slate-400 hover:text-white transition"
>
退
</button>
</div>
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{messages.length === 0 && (
<div className="text-center text-slate-500 mt-20">
<p className="text-4xl mb-4">💬</p>
<p> Agent</p>
</div>
)}
{messages.map(msg => (
<div
key={msg.id}
className={`flex ${
msg.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-3 ${
msg.role === 'user'
? 'bg-emerald-600 text-white'
: 'bg-slate-700 text-slate-200'
}`}
>
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
<p className="text-xs mt-1 opacity-50">
{new Date(msg.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
))}
{isSending && (
<div className="flex justify-start">
<div className="bg-slate-700 rounded-2xl px-4 py-3">
<div className="flex items-center gap-2 text-slate-400 text-sm">
<span className="animate-spin"></span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="p-4 bg-slate-800 border-t border-slate-700">
<div className="flex gap-3">
<textarea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder="输入消息… (Enter 发送, Shift+Enter 换行)"
className="flex-1 px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-emerald-400 transition resize-none"
/>
<button
onClick={sendMessage}
disabled={isSending || !inputValue.trim()}
className="px-6 py-3 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition font-medium"
>
</button>
</div>
</div>
</div>
</div>
);
}