first commit
This commit is contained in:
44
app/components/ChatMessage.tsx
Normal file
44
app/components/ChatMessage.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ChatMessageProps {
|
||||
content: string;
|
||||
sender: 'user' | 'bot';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ content, sender, timestamp }) => {
|
||||
// 格式化时间戳
|
||||
const formattedTime = timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
// 将消息内容按换行符分割
|
||||
const messageParts = content.split('\n');
|
||||
|
||||
return (
|
||||
<div className={`flex items-end mb-4 ${sender === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{sender === 'bot' && (
|
||||
<div className="user-avatar mr-2">
|
||||
<span>AI</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-3 rounded-lg ${sender === 'user' ? 'message-user' : 'message-bot'}`}>
|
||||
{messageParts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
{index < messageParts.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sender === 'user' && (
|
||||
<div className="user-avatar ml-2">
|
||||
<span>我</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-gray-500 ml-2 opacity-70">
|
||||
{formattedTime}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
app/components/Message.tsx
Normal file
117
app/components/Message.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Paper, Typography, Avatar, useMediaQuery, useTheme } from '@mui/material';
|
||||
import type { Message as MessageType } from '../types/types';
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const Message: React.FC<MessageProps> = ({ message, isLast = false }) => {
|
||||
const isUser = message.sender === 'user';
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const paperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 计算气泡的borderRadius
|
||||
const borderRadius = isUser
|
||||
? '18px 18px 4px 18px'
|
||||
: '18px 18px 18px 4px';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={message.id}
|
||||
display="flex"
|
||||
marginBottom={2}
|
||||
justifyContent={isUser ? "flex-end" : "flex-start"}
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-end"
|
||||
maxWidth={isMobile ? "85%" : "70%"}
|
||||
gap={1.5}
|
||||
>
|
||||
{!isUser && (
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: theme.palette.primary.main,
|
||||
boxShadow: theme.shadows[2],
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
🤖
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
ref={paperRef}
|
||||
elevation={isLast ? 2 : 1}
|
||||
sx={{
|
||||
padding: 2,
|
||||
backgroundColor: isUser
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.background.default,
|
||||
color: isUser ? 'white' : theme.palette.text.primary,
|
||||
borderRadius: borderRadius,
|
||||
position: 'relative',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: theme.shadows[isLast ? 4 : 2],
|
||||
transform: 'translateY(-1px)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
{message.content.split('\n').map((line, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
{index < message.content.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
marginTop: 1,
|
||||
opacity: 0.7,
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'right'
|
||||
}}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{isUser && (
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: theme.palette.primary.dark,
|
||||
boxShadow: theme.shadows[2],
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
👤
|
||||
</Avatar>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Message;
|
||||
135
app/components/MessageInput.tsx
Normal file
135
app/components/MessageInput.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { TextField, IconButton, Box, Tooltip, CircularProgress, useTheme } from '@mui/material';
|
||||
import { SendRounded } from '@mui/icons-material';
|
||||
|
||||
interface MessageInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const MessageInput: React.FC<MessageInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onKeyDown,
|
||||
disabled
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const textareaRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
const handleBlur = () => setIsFocused(false);
|
||||
|
||||
// 自动调整文本框高度
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
// 找到实际的textarea元素
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
boxShadow: '0 -2px 10px rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-end"
|
||||
gap={1.5}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
ref={textareaRef}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="输入您的问题..."
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
multiline
|
||||
rows={1}
|
||||
inputProps={{
|
||||
style: {
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '24px',
|
||||
lineHeight: 1.5,
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 4,
|
||||
'& fieldset': {
|
||||
borderColor: theme.palette.divider,
|
||||
borderWidth: '2px',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: theme.palette.primary.light,
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
borderWidth: '2px',
|
||||
},
|
||||
'&.Mui-disabled fieldset': {
|
||||
borderColor: theme.palette.divider,
|
||||
opacity: 0.6,
|
||||
}
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
transform: isFocused ? 'translateY(-1px)' : 'translateY(0)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip title="发送消息" placement="top">
|
||||
<IconButton
|
||||
onClick={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
sx={{
|
||||
backgroundColor: (disabled || !value.trim())
|
||||
? theme.palette.action.disabledBackground
|
||||
: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
width: 48,
|
||||
height: 48,
|
||||
'&:hover': {
|
||||
backgroundColor: (disabled || !value.trim())
|
||||
? theme.palette.action.disabledBackground
|
||||
: theme.palette.primary.dark,
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: theme.shadows[2],
|
||||
}}
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{disabled ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : (
|
||||
<SendRounded fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
21
app/components/MuiThemeProvider.tsx
Normal file
21
app/components/MuiThemeProvider.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import React from 'react';
|
||||
import theme from '../muiTheme';
|
||||
|
||||
interface MuiThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const MuiThemeProvider: React.FC<MuiThemeProviderProps> = ({ children }) => {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MuiThemeProvider;
|
||||
296
app/components/Sidebar.tsx
Normal file
296
app/components/Sidebar.tsx
Normal file
@ -0,0 +1,296 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Typography,
|
||||
Button,
|
||||
Divider,
|
||||
useTheme,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import { Add, Settings } from '@mui/icons-material';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
name: string;
|
||||
lastMessage?: string;
|
||||
timestamp?: Date;
|
||||
isActive?: boolean;
|
||||
avatar?: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// 模拟对话历史数据
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '开始',
|
||||
lastMessage: '守正出奇,让你的内容脱颖而出',
|
||||
timestamp: new Date(),
|
||||
isActive: true,
|
||||
avatar: '🤖',
|
||||
unreadCount: 0
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '营销文案',
|
||||
lastMessage: '爆款标题的3个关键要素...',
|
||||
timestamp: new Date(Date.now() - 3600000),
|
||||
isActive: false,
|
||||
avatar: '📝',
|
||||
unreadCount: 1
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '社交媒体策略',
|
||||
lastMessage: '如何提高社交媒体互动率?',
|
||||
timestamp: new Date(Date.now() - 86400000),
|
||||
isActive: false,
|
||||
avatar: '📱',
|
||||
unreadCount: 0
|
||||
}
|
||||
];
|
||||
|
||||
// 处理新建对话
|
||||
const handleNewConversation = () => {
|
||||
console.log('创建新对话');
|
||||
};
|
||||
|
||||
// 处理选择对话
|
||||
const handleSelectConversation = (conversationId: string) => {
|
||||
console.log('选择对话:', conversationId);
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 86400000) { // 24小时内
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diff < 172800000) { // 48小时内
|
||||
return '昨天';
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: { xs: '100%', sm: 300 },
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 用户信息区域 */}
|
||||
<Box sx={{ p: 2, borderBottom: `1px solid ${theme.palette.divider}` }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.primary.main,
|
||||
mb: 0.5,
|
||||
fontSize: '1.1rem',
|
||||
}}
|
||||
>
|
||||
爆款文案助手
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: '0.85rem' }}
|
||||
>
|
||||
亲爱的安先生
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 新建对话按钮 */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Add fontSize="small" />}
|
||||
onClick={handleNewConversation}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
},
|
||||
py: 1.2,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.9rem',
|
||||
boxShadow: theme.shadows[1],
|
||||
}}
|
||||
>
|
||||
新建对话
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 对话历史列表 */}
|
||||
<List
|
||||
component="nav"
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: 6,
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.divider,
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1,
|
||||
display: 'block',
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
最近对话
|
||||
</Typography>
|
||||
|
||||
{conversations.map((conversation) => (
|
||||
<React.Fragment key={conversation.id}>
|
||||
<ListItem
|
||||
onClick={() => handleSelectConversation(conversation.id)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mx: 1,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
backgroundColor: conversation.isActive ? theme.palette.action.selected : 'transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
paddingX: 2,
|
||||
paddingY: 1.5,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
bgcolor: conversation.isActive ? theme.palette.primary.main : theme.palette.background.default,
|
||||
color: conversation.isActive ? 'white' : theme.palette.text.primary,
|
||||
fontSize: '1.2rem',
|
||||
boxShadow: conversation.isActive ? theme.shadows[2] : 'none',
|
||||
}}
|
||||
>
|
||||
{conversation.avatar}
|
||||
</Avatar>
|
||||
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" gap={1}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: conversation.isActive ? 600 : 500,
|
||||
fontSize: '0.9rem',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{conversation.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
>
|
||||
{conversation.timestamp && formatTime(conversation.timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
{conversation.lastMessage}
|
||||
</Typography>
|
||||
{conversation.unreadCount && conversation.unreadCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '9px',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600 }}>
|
||||
{conversation.unreadCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
primaryTypographyProps={{ component: 'div' }}
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider light />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* 底部设置区域 */}
|
||||
<Box sx={{ p: 2, borderTop: `1px solid ${theme.palette.divider}` }}>
|
||||
<Button
|
||||
fullWidth
|
||||
startIcon={<Settings fontSize="small" />}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
color: theme.palette.text.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
设置
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
3
app/components/index.ts
Normal file
3
app/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './ChatMessage';
|
||||
export * from './MessageInput';
|
||||
export * from './Sidebar';
|
||||
Reference in New Issue
Block a user