first commit

This commit is contained in:
zch1234qq
2025-10-16 21:24:18 +08:00
commit b9d03d05cc
35 changed files with 10191 additions and 0 deletions

View 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
View 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;

View 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>
);
};

View 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
View 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
View File

@ -0,0 +1,3 @@
export * from './ChatMessage';
export * from './MessageInput';
export * from './Sidebar';