feat: LeftSidebar (~280 行) - 对话历史列表 + 用户信息卡片

This commit is contained in:
zdl
2025-11-24 15:11:19 +08:00
parent dc789f57f7
commit 80084d607b
2 changed files with 389 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
// src/views/AgentChat/components/LeftSidebar/SessionCard.js
// 会话卡片组件
import React from 'react';
import { motion } from 'framer-motion';
import { Card, CardBody, Flex, Box, Text, Badge } from '@chakra-ui/react';
/**
* SessionCard - 会话卡片组件
*
* @param {Object} props
* @param {Object} props.session - 会话数据
* @param {boolean} props.isActive - 是否为当前选中的会话
* @param {Function} props.onPress - 点击回调函数
* @returns {JSX.Element}
*/
const SessionCard = ({ session, isActive, onPress }) => {
return (
<motion.div
whileHover={{ scale: 1.02, y: -4 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Card
cursor="pointer"
onClick={onPress}
bg={isActive ? 'rgba(139, 92, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)'}
backdropFilter="blur(12px)"
borderWidth={1}
borderColor={isActive ? 'purple.400' : 'rgba(255, 255, 255, 0.1)'}
_hover={{
bg: isActive ? 'rgba(139, 92, 246, 0.2)' : 'rgba(255, 255, 255, 0.08)',
borderColor: isActive ? 'purple.400' : 'rgba(255, 255, 255, 0.2)',
boxShadow: isActive
? '0 12px 24px rgba(139, 92, 246, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
}}
transition="all 0.3s"
>
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
{session.title || '新对话'}
</Text>
<Text fontSize="xs" color="gray.500" mt={1}>
{new Date(session.created_at || session.timestamp).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Box>
{session.message_count && (
<Badge
bgGradient={
isActive
? 'linear(to-r, blue.500, purple.500)'
: 'linear(to-r, gray.600, gray.700)'
}
color={isActive ? 'white' : 'gray.400'}
variant="subtle"
boxShadow={isActive ? '0 2px 8px rgba(139, 92, 246, 0.3)' : 'none'}
>
{session.message_count}
</Badge>
)}
</Flex>
</CardBody>
</Card>
</motion.div>
);
};
export default SessionCard;

View File

@@ -0,0 +1,313 @@
// src/views/AgentChat/components/LeftSidebar/index.js
// 左侧栏组件 - 对话历史列表
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Text,
Input,
Avatar,
Badge,
Spinner,
Tooltip,
IconButton,
HStack,
VStack,
Flex,
} from '@chakra-ui/react';
import { MessageSquare, Plus, Search, ChevronLeft } from 'lucide-react';
import { animations } from '../../constants/animations';
import { groupSessionsByDate } from '../../utils/sessionUtils';
import SessionCard from './SessionCard';
/**
* LeftSidebar - 左侧栏组件
*
* @param {Object} props
* @param {boolean} props.isOpen - 侧边栏是否展开
* @param {Function} props.onClose - 关闭侧边栏回调
* @param {Array} props.sessions - 会话列表
* @param {string|null} props.currentSessionId - 当前选中的会话 ID
* @param {Function} props.onSessionSwitch - 切换会话回调
* @param {Function} props.onNewSession - 新建会话回调
* @param {boolean} props.isLoadingSessions - 会话加载中状态
* @param {Object} props.user - 用户信息
* @returns {JSX.Element|null}
*/
const LeftSidebar = ({
isOpen,
onClose,
sessions,
currentSessionId,
onSessionSwitch,
onNewSession,
isLoadingSessions,
user,
}) => {
const [searchQuery, setSearchQuery] = useState('');
// 按日期分组会话
const sessionGroups = groupSessionsByDate(sessions);
// 搜索过滤
const filteredSessions = searchQuery
? sessions.filter(
(s) =>
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.session_id?.toLowerCase().includes(searchQuery.toLowerCase())
)
: sessions;
return (
<AnimatePresence>
{isOpen && (
<motion.div
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInLeft}
>
<Box
w="320px"
display="flex"
flexDirection="column"
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderRight="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="4px 0 24px rgba(0, 0, 0, 0.3)"
>
{/* 标题栏 */}
<Box p={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<HStack justify="space-between" mb={3}>
<HStack spacing={2}>
<MessageSquare className="w-5 h-5" color="#60A5FA" />
<Text
fontWeight="semibold"
bgGradient="linear(to-r, blue.300, purple.300)"
bgClip="text"
fontSize="md"
>
对话历史
</Text>
</HStack>
<HStack spacing={2}>
<Tooltip label="新建对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Plus className="w-4 h-4" />}
onClick={onNewSession}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(59, 130, 246, 0.2)',
borderColor: 'blue.400',
color: 'blue.300',
boxShadow: '0 0 12px rgba(59, 130, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="收起侧边栏">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ChevronLeft className="w-4 h-4" />}
onClick={onClose}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'purple.400',
color: 'white',
}}
/>
</motion.div>
</Tooltip>
</HStack>
</HStack>
{/* 搜索框 */}
<Box position="relative">
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)" zIndex={1}>
<Search className="w-4 h-4" color="#9CA3AF" />
</Box>
<Input
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="outline"
pl={10}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_placeholder={{ color: 'gray.500' }}
_hover={{
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
_focus={{
borderColor: 'purple.400',
boxShadow:
'0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
</Box>
</Box>
{/* 会话列表 */}
<Box flex={1} p={3} overflowY="auto">
{/* 按日期分组显示会话 */}
{sessionGroups.today.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
今天
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.today.map((session, idx) => (
<motion.div
key={session.session_id}
custom={idx}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<SessionCard
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
</motion.div>
))}
</VStack>
</Box>
)}
{sessionGroups.yesterday.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
昨天
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.yesterday.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{sessionGroups.thisWeek.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
本周
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.thisWeek.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{sessionGroups.older.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
更早
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.older.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{/* 加载状态 */}
{isLoadingSessions && (
<Flex justify="center" p={4}>
<Spinner
size="md"
color="purple.500"
emptyColor="gray.700"
thickness="3px"
speed="0.65s"
/>
</Flex>
)}
{/* 空状态 */}
{sessions.length === 0 && !isLoadingSessions && (
<VStack textAlign="center" py={8} color="gray.500" fontSize="sm" spacing={2}>
<MessageSquare className="w-8 h-8" style={{ opacity: 0.5, margin: '0 auto' }} />
<Text>还没有对话历史</Text>
<Text fontSize="xs">开始一个新对话吧</Text>
</VStack>
)}
</Box>
{/* 用户信息卡片 */}
<Box p={4} borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<HStack spacing={3}>
<Avatar
src={user?.avatar}
name={user?.nickname}
size="sm"
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
/>
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
{user?.nickname || '未登录'}
</Text>
<Badge
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
px={2}
py={0.5}
borderRadius="full"
fontSize="xs"
fontWeight="semibold"
textTransform="none"
>
{user?.subscription_type || 'free'}
</Badge>
</Box>
</HStack>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
);
};
export default LeftSidebar;