components: Add Chat component
This commit is contained in:
84
web/src/components/Chat/ChatContent.tsx
Normal file
84
web/src/components/Chat/ChatContent.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2025-12-10 16:46:17
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2025-12-11 13:40:18
|
||||||
|
*/
|
||||||
|
import { type FC, useRef, useEffect } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Markdown from '@/components/Markdown'
|
||||||
|
import type { ChatContentProps } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天内容显示组件
|
||||||
|
* 负责渲染聊天消息列表,支持不同角色的消息样式和自动滚动
|
||||||
|
*/
|
||||||
|
const ChatContent: FC<ChatContentProps> = ({
|
||||||
|
classNames,
|
||||||
|
contentClassNames,
|
||||||
|
data = [],
|
||||||
|
streamLoading = false,
|
||||||
|
empty,
|
||||||
|
labelPosition = 'bottom',
|
||||||
|
labelFormat,
|
||||||
|
errorDesc
|
||||||
|
}) => {
|
||||||
|
// 滚动容器引用,用于控制自动滚动到底部
|
||||||
|
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
|
||||||
|
|
||||||
|
// 当数据变化时,自动滚动到底部显示最新消息
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}, [data])
|
||||||
|
return (
|
||||||
|
<div ref={scrollContainerRef} className={clsx("rb:relative rb:overflow-y-auto", classNames)}>
|
||||||
|
{data.length === 0
|
||||||
|
? empty // 显示空状态
|
||||||
|
: data.map((item, index) => (
|
||||||
|
<div key={index} className={clsx("rb:relative", {
|
||||||
|
'rb:mt-6': index !== 0, // 非第一条消息添加上边距
|
||||||
|
'rb:right-0 rb:text-right': item.role === 'user', // 用户消息右对齐
|
||||||
|
'rb:left-0 rb:text-left': item.role === 'assistant', // 助手消息左对齐
|
||||||
|
})}>
|
||||||
|
{/* 流式加载时且内容为空则不显示 */}
|
||||||
|
{streamLoading && item.content === ''
|
||||||
|
? null
|
||||||
|
: <>
|
||||||
|
{/* 顶部标签(如时间戳、用户名等) */}
|
||||||
|
{labelPosition === 'top' &&
|
||||||
|
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular">
|
||||||
|
{labelFormat(item)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{/* 消息气泡框 */}
|
||||||
|
<div className={clsx('rb:border rb:text-left rb:rounded-lg rb:mt-1.5 rb:leading-[18px] rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-[400px]', contentClassNames, {
|
||||||
|
// 错误消息样式(内容为null且非助手消息)
|
||||||
|
'rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': errorDesc && item.role === 'assistant' && item.content === null,
|
||||||
|
// 助手消息样式
|
||||||
|
'rb:bg-[rgba(21,94,239,0.08)] rb:border-[rgba(21,94,239,0.30)]': item.role === 'user',
|
||||||
|
// 用户消息样式
|
||||||
|
'rb:bg-[#FFFFFF] rb:border-[#EBEBEB]': item.role === 'assistant' && (item.content || item.content === ''),
|
||||||
|
})}>
|
||||||
|
{/* 使用Markdown组件渲染消息内容 */}
|
||||||
|
<Markdown content={item.content ?? errorDesc ?? ''} />
|
||||||
|
</div>
|
||||||
|
{/* 底部标签(如时间戳、用户名等) */}
|
||||||
|
{labelPosition === 'bottom' &&
|
||||||
|
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular">
|
||||||
|
{labelFormat(item)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatContent
|
||||||
80
web/src/components/Chat/ChatInput.tsx
Normal file
80
web/src/components/Chat/ChatInput.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2025-12-10 16:46:14
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2025-12-10 16:49:13
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Flex, Input, Form } from 'antd'
|
||||||
|
import SendIcon from '@/assets/images/conversation/send.svg'
|
||||||
|
import SendDisabledIcon from '@/assets/images/conversation/sendDisabled.svg'
|
||||||
|
import LoadingIcon from '@/assets/images/conversation/loading.svg'
|
||||||
|
import type { ChatInputProps } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天输入框组件
|
||||||
|
* 提供消息输入、发送功能,支持键盘快捷键和加载状态显示
|
||||||
|
*/
|
||||||
|
const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputProps) => {
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
// 监听表单值变化,用于控制发送按钮状态
|
||||||
|
const values = Form.useWatch([], form);
|
||||||
|
|
||||||
|
// 当外部message为空时,清空表单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!message) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
message: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [form, message])
|
||||||
|
|
||||||
|
// 当加载状态时,清空输入框
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
message: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [loading])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rb:absolute rb:bottom-3 rb:left-0 rb:right-0">
|
||||||
|
<Flex vertical justify="space-between" className="rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:min-h-[120px]">
|
||||||
|
{/* 消息输入表单 */}
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="message" noStyle>
|
||||||
|
<Input.TextArea
|
||||||
|
className="rb:m-[10px_12px_10px_12px]! rb:p-0! rb:w-[calc(100%-24px)]! rb:flex-[1_1_auto]"
|
||||||
|
variant="borderless"
|
||||||
|
autoSize={{ minRows: 2, maxRows: 2 }}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter键发送,Shift+Enter换行
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* 底部操作区域 */}
|
||||||
|
<Flex align="center" justify="space-between" className="rb:m-[0_10px_10px_10px]!">
|
||||||
|
{/* 子组件内容(如按钮等) */}
|
||||||
|
{children}
|
||||||
|
{/* 发送按钮 - 根据状态显示不同图标 */}
|
||||||
|
{loading
|
||||||
|
? <img src={LoadingIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" />
|
||||||
|
: !values || !values?.message || values?.message?.trim() === ''
|
||||||
|
? <img src={SendDisabledIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" />
|
||||||
|
: <img src={SendIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" onClick={onSend} />
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatInput
|
||||||
47
web/src/components/Chat/index.tsx
Normal file
47
web/src/components/Chat/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2025-12-10 16:46:09
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2025-12-11 13:43:51
|
||||||
|
*/
|
||||||
|
import { type FC } from 'react'
|
||||||
|
import ChatInput from './ChatInput'
|
||||||
|
import type { ChatProps } from './types'
|
||||||
|
import ChatContent from './ChatContent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天组件 - 主要组件,由内容区域和输入框组成
|
||||||
|
* 提供完整的聊天界面功能,包括消息显示和输入交互
|
||||||
|
*/
|
||||||
|
const Chat: FC<ChatProps> = ({
|
||||||
|
empty,
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
onSend,
|
||||||
|
streamLoading = false,
|
||||||
|
loading,
|
||||||
|
contentClassName = '',
|
||||||
|
children,
|
||||||
|
labelFormat,
|
||||||
|
errorDesc
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="rb:h-full rb:relative rb:pt-2">
|
||||||
|
{/* 聊天内容显示区域 */}
|
||||||
|
<ChatContent
|
||||||
|
classNames={contentClassName}
|
||||||
|
data={data}
|
||||||
|
streamLoading={streamLoading}
|
||||||
|
empty={empty}
|
||||||
|
labelFormat={labelFormat}
|
||||||
|
errorDesc={errorDesc}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 聊天输入框区域 */}
|
||||||
|
<ChatInput onChange={onChange} onSend={onSend} loading={loading}>
|
||||||
|
{children}
|
||||||
|
</ChatInput>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default Chat
|
||||||
84
web/src/components/Chat/types.ts
Normal file
84
web/src/components/Chat/types.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2025-12-10 16:45:54
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2025-12-11 13:43:52
|
||||||
|
*/
|
||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天消息项接口
|
||||||
|
*/
|
||||||
|
export interface ChatItem {
|
||||||
|
/** 消息唯一标识 */
|
||||||
|
id?: string;
|
||||||
|
/** 会话ID */
|
||||||
|
conversation_id?: string | null;
|
||||||
|
/** 消息角色:用户或助手 */
|
||||||
|
role?: 'user' | 'assistant';
|
||||||
|
/** 消息内容 */
|
||||||
|
content?: string | null;
|
||||||
|
/** 创建时间 */
|
||||||
|
created_at?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天组件主要属性接口
|
||||||
|
*/
|
||||||
|
export interface ChatProps {
|
||||||
|
/** 空状态显示内容 */
|
||||||
|
empty?: ReactNode;
|
||||||
|
/** 聊天数据列表 */
|
||||||
|
data: ChatItem[];
|
||||||
|
/** 输入内容变化回调 */
|
||||||
|
onChange: (message: string) => void;
|
||||||
|
/** 发送消息回调 */
|
||||||
|
onSend: () => void;
|
||||||
|
/** 流式加载状态 */
|
||||||
|
streamLoading?: boolean;
|
||||||
|
/** 加载状态 */
|
||||||
|
loading: boolean;
|
||||||
|
/** 内容区域自定义样式类名 */
|
||||||
|
contentClassName?: string;
|
||||||
|
/** 子组件内容 */
|
||||||
|
children?: ReactNode;
|
||||||
|
/** 标签格式化函数 */
|
||||||
|
labelFormat: (item: ChatItem) => any;
|
||||||
|
errorDesc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天输入框组件属性接口
|
||||||
|
*/
|
||||||
|
export interface ChatInputProps {
|
||||||
|
/** 当前输入消息 */
|
||||||
|
message?: string;
|
||||||
|
/** 输入内容变化回调 */
|
||||||
|
onChange: (message: string) => void;
|
||||||
|
/** 发送消息回调 */
|
||||||
|
onSend: () => void;
|
||||||
|
/** 加载状态 */
|
||||||
|
loading: boolean;
|
||||||
|
/** 子组件内容 */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天内容区域组件属性接口
|
||||||
|
*/
|
||||||
|
export interface ChatContentProps {
|
||||||
|
/** 自定义样式类名 */
|
||||||
|
classNames?: string | Record<string, boolean>;
|
||||||
|
contentClassNames?: string | Record<string, boolean>;
|
||||||
|
/** 聊天数据列表 */
|
||||||
|
data: ChatItem[];
|
||||||
|
/** 流式加载状态 */
|
||||||
|
streamLoading: boolean;
|
||||||
|
/** 空状态显示内容 */
|
||||||
|
empty?: ReactNode;
|
||||||
|
/** 标签位置:顶部或底部 */
|
||||||
|
labelPosition?: 'top' | 'bottom';
|
||||||
|
/** 标签格式化函数 */
|
||||||
|
labelFormat: (item: ChatItem) => any;
|
||||||
|
errorDesc?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user