style: [FL-166] 优化AI问答界面样式,与系统风格保持一致
主要改动: - 使用标准的 Card 组件替代自定义布局 - 采用 Row/Col 响应式布局,适配移动端 - 使用系统统一的 Space、Typography 组件 - 添加 useToastFeedback 统一错误提示 - 使用 useMobileDetection 实现响应式设计 - 优化消息显示,使用系统配色和边距 - 添加 Popconfirm 二次确认删除操作 - 调整页面布局,移除全屏高度容器 - 统一按钮、图标和文字样式 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
+265
-140
@@ -4,25 +4,33 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
List,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
type CardProps,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SendOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
RobotOutlined,
|
||||
MessageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ComponentType, type RefAttributes } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||
import { useMobileDetection } from "@/hooks/use-mobile-detection";
|
||||
import { readApiError, API_BASE_URL } from "@/lib/api";
|
||||
import type {
|
||||
AiChatConversation,
|
||||
@@ -32,18 +40,30 @@ import type {
|
||||
} from "@/types/ai-chat";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Title, Text } = Typography;
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||
|
||||
export default function AiChatPage() {
|
||||
const { fetchWithAuth } = useAuth();
|
||||
const { user, initializing, fetchWithAuth } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useMobileDetection();
|
||||
|
||||
const [selectedConvId, setSelectedConvId] = useState<number | null>(null);
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [newConvTitle, setNewConvTitle] = useState("新对话");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useToastFeedback({
|
||||
errorMessage: error,
|
||||
successMessage: success,
|
||||
errorTitle: "操作失败",
|
||||
clearError: () => setError(""),
|
||||
clearSuccess: () => setSuccess(""),
|
||||
});
|
||||
|
||||
const { data: conversations, isLoading: convLoading } = useQuery({
|
||||
queryKey: ["ai-chat-conversations"],
|
||||
queryFn: async () => {
|
||||
@@ -55,6 +75,7 @@ export default function AiChatPage() {
|
||||
}
|
||||
return (await response.json()) as AiChatConversationListResponse;
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: currentConv, isLoading: convDetailLoading } = useQuery({
|
||||
@@ -92,10 +113,10 @@ export default function AiChatPage() {
|
||||
setSelectedConvId(data.id);
|
||||
setCreateModalOpen(false);
|
||||
setNewConvTitle("新对话");
|
||||
message.success("创建对话成功");
|
||||
setSuccess("创建对话成功");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`创建对话失败: ${error.message}`);
|
||||
onError: (err: Error) => {
|
||||
setError(`创建对话失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -112,10 +133,10 @@ export default function AiChatPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chat-conversations"] });
|
||||
setSelectedConvId(null);
|
||||
message.success("删除对话成功");
|
||||
setSuccess("删除对话成功");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`删除对话失败: ${error.message}`);
|
||||
onError: (err: Error) => {
|
||||
setError(`删除对话失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -146,8 +167,8 @@ export default function AiChatPage() {
|
||||
});
|
||||
setMessageInput("");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`发送消息失败: ${error.message}`);
|
||||
onError: (err: Error) => {
|
||||
setError(`发送消息失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -163,37 +184,58 @@ export default function AiChatPage() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [currentConv?.messages]);
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: "100%", padding: 24 }}>
|
||||
<Spin size="large" />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "16px", borderBottom: "1px solid #f0f0f0" }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
<RobotOutlined /> AI 问答
|
||||
</Title>
|
||||
</div>
|
||||
<Space direction="vertical" size="large" style={{ width: "100%", padding: isMobile ? 16 : 24 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Title level={3}>
|
||||
<RobotOutlined /> AI 问答助手
|
||||
</Title>
|
||||
<Paragraph type="secondary">
|
||||
基于 ChatGPT 的智能问答系统,支持多轮对话和上下文理解
|
||||
</Paragraph>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "280px",
|
||||
borderRight: "1px solid #f0f0f0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "16px" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
block
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
新建对话
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0 16px" }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={8}>
|
||||
<AntCard
|
||||
title={
|
||||
<Space>
|
||||
<MessageOutlined />
|
||||
<span>对话列表</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
}
|
||||
styles={{ body: { padding: 0, maxHeight: 600, overflowY: "auto" } }}
|
||||
>
|
||||
{convLoading ? (
|
||||
<Spin />
|
||||
<div style={{ textAlign: "center", padding: 40 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : conversations?.items.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无对话"
|
||||
style={{ padding: 40 }}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
dataSource={conversations?.items || []}
|
||||
@@ -204,115 +246,194 @@ export default function AiChatPage() {
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background:
|
||||
selectedConvId === conv.id ? "#e6f7ff" : "transparent",
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "8px",
|
||||
selectedConvId === conv.id
|
||||
? "var(--ant-color-primary-bg)"
|
||||
: "transparent",
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确认删除"
|
||||
description="删除后将无法恢复,确定删除此对话吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
deleteConvMutation.mutate(conv.id);
|
||||
}}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={conv.title}
|
||||
description={`${conv.message_count || 0} 条消息`}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteConvMutation.mutate(conv.id);
|
||||
}}
|
||||
description={
|
||||
<Space size={4}>
|
||||
<Tag color="blue">{conv.message_count || 0} 条消息</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{new Date(conv.updated_at).toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AntCard>
|
||||
</Col>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
{!selectedConvId ? (
|
||||
<Empty
|
||||
description="请选择或创建一个对话"
|
||||
style={{ marginTop: "100px" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
padding: "24px",
|
||||
background: "#fafafa",
|
||||
}}
|
||||
>
|
||||
{convDetailLoading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||
{currentConv?.messages?.map((msg) => (
|
||||
<Card
|
||||
key={msg.id}
|
||||
style={{
|
||||
maxWidth: "80%",
|
||||
marginLeft: msg.role === "user" ? "auto" : 0,
|
||||
background: msg.role === "user" ? "#1890ff" : "#fff",
|
||||
color: msg.role === "user" ? "#fff" : "#000",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
strong
|
||||
<Col xs={24} md={16}>
|
||||
<AntCard
|
||||
title={currentConv ? currentConv.title : "AI 对话"}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: 600,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!selectedConvId ? (
|
||||
<Empty
|
||||
description="请选择或创建一个对话开始聊天"
|
||||
style={{ margin: "auto" }}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: 16,
|
||||
background: "var(--ant-color-bg-layout)",
|
||||
}}
|
||||
>
|
||||
{convDetailLoading ? (
|
||||
<div style={{ textAlign: "center", padding: 40 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={12}>
|
||||
{currentConv?.messages?.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
color: msg.role === "user" ? "#fff" : "#000",
|
||||
display: "flex",
|
||||
justifyContent: msg.role === "user" ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
{msg.role === "user" ? "我" : "AI"}
|
||||
</Text>
|
||||
<div style={{ marginTop: "8px", whiteSpace: "pre-wrap" }}>
|
||||
{msg.content}
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
maxWidth: "80%",
|
||||
background:
|
||||
msg.role === "user"
|
||||
? "var(--ant-color-primary)"
|
||||
: "var(--ant-color-bg-container)",
|
||||
border:
|
||||
msg.role === "user"
|
||||
? "1px solid var(--ant-color-primary)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: "100%" }}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color:
|
||||
msg.role === "user"
|
||||
? "var(--ant-color-white)"
|
||||
: "var(--ant-color-text)",
|
||||
}}
|
||||
>
|
||||
{msg.role === "user" ? "我" : "AI 助手"}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
color:
|
||||
msg.role === "user"
|
||||
? "var(--ant-color-white)"
|
||||
: "var(--ant-color-text)",
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color:
|
||||
msg.role === "user"
|
||||
? "rgba(255, 255, 255, 0.65)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{new Date(msg.created_at).toLocaleTimeString("zh-CN")}
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<TextArea
|
||||
value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)}
|
||||
placeholder="输入消息..."
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={sendMessageMutation.isPending}
|
||||
onClick={handleSendMessage}
|
||||
disabled={!messageInput.trim()}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderTop: "1px solid var(--ant-color-border)",
|
||||
background: "var(--ant-color-bg-container)",
|
||||
}}
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<TextArea
|
||||
value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)}
|
||||
placeholder="输入消息...(Shift + Enter 换行,Enter 发送)"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
disabled={sendMessageMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={sendMessageMutation.isPending}
|
||||
onClick={handleSendMessage}
|
||||
disabled={!messageInput.trim()}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AntCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title="新建对话"
|
||||
@@ -324,12 +445,16 @@ export default function AiChatPage() {
|
||||
}}
|
||||
confirmLoading={createConvMutation.isPending}
|
||||
>
|
||||
<Input
|
||||
placeholder="对话标题"
|
||||
value={newConvTitle}
|
||||
onChange={(e) => setNewConvTitle(e.target.value)}
|
||||
/>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="对话标题">
|
||||
<Input
|
||||
placeholder="请输入对话标题"
|
||||
value={newConvTitle}
|
||||
onChange={(e) => setNewConvTitle(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user