mirror of
https://github.com/Funkoala14/CreatorCenter_OOIN.git
synced 2025-06-07 20:38:14 +08:00
[dev]chats
This commit is contained in:
parent
4653afdefe
commit
0bd25c4f2a
1792
package-lock.json
generated
1792
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -30,9 +30,13 @@
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"redux-persist": "^6.0.0"
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
|
@ -1,23 +1,45 @@
|
||||
import { Send, X } from 'lucide-react';
|
||||
import { LoaderCircle, Send, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchCreatorDetail } from '../store/slices/creatorsSlice';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import { fetchConversationSummary, getRecommendedReply } from '../store/slices/chatSlice';
|
||||
|
||||
export default function ChatDetails({ onCloseChatDetails }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const { selectedChat } = useSelector((state) => state.inbox);
|
||||
const { currentChat } = useSelector((state) => state.chat);
|
||||
const { selectedCreator } = useSelector((state) => state.creators);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
console.log('Form submitted');
|
||||
const query = {
|
||||
conversation_id: currentChat?.conversation_id,
|
||||
conversation_summary: currentChat?.summary?.summary || '',
|
||||
last_message: currentChat?.last_message || '',
|
||||
goal_id: '',
|
||||
};
|
||||
dispatch(getRecommendedReply(query));
|
||||
};
|
||||
|
||||
const handleSendEmail = () => {
|
||||
console.log('Send email');
|
||||
if (!currentChat?.recommended_reply?.reply) return;
|
||||
|
||||
const query = {
|
||||
conversation_id: currentChat?.conversation_id,
|
||||
body: currentChat?.recommended_reply?.reply,
|
||||
from_email: '',
|
||||
subject: '',
|
||||
};
|
||||
dispatch(getRecommendedReply(query));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCreatorDetail({ creatorId: selectedChat.id }));
|
||||
}, [dispatch, selectedChat]);
|
||||
dispatch(fetchCreatorDetail({ creatorId: currentChat?.negotiation?.creator?.id }));
|
||||
dispatch(fetchConversationSummary(currentChat?.conversation_id));
|
||||
}, [dispatch, currentChat]);
|
||||
|
||||
return (
|
||||
<div className='chat-details'>
|
||||
@ -30,42 +52,63 @@ export default function ChatDetails({ onCloseChatDetails }) {
|
||||
</div>
|
||||
<div className='chat-detail-header-creator'>
|
||||
<div className='chat-detail-header-creator-avatar'>
|
||||
<img src={selectedChat?.avatar} alt='avatar' />
|
||||
<img src={selectedCreator?.creator?.avatar} alt={selectedCreator?.creator?.name} />
|
||||
</div>
|
||||
<div className='chat-detail-header-creator-name'>{selectedChat?.name}</div>
|
||||
<div className='chat-detail-header-creator-name'>{selectedCreator?.creator?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='chat-detail-header-infos'>
|
||||
<div className='chat-detail-header-info-item'>
|
||||
<div className='chat-detail-header-info-item-label'>Category</div>
|
||||
<div className='chat-detail-header-info-item-value'>{selectedCreator?.category}</div>
|
||||
<div className='chat-detail-header-info-item-value'>{selectedCreator?.business?.category}</div>
|
||||
</div>
|
||||
<div className='chat-detail-header-info-item'>
|
||||
<div className='chat-detail-header-info-item-label'>MCN</div>
|
||||
<div className='chat-detail-header-info-item-value'>{selectedCreator?.mcn || '--'}</div>
|
||||
<div className='chat-detail-header-info-item-value'>{selectedCreator?.business?.mcn || '--'}</div>
|
||||
</div>
|
||||
<div className='chat-detail-header-info-item'>
|
||||
<div className='chat-detail-header-info-item-label'>Pricing</div>
|
||||
<div className='chat-detail-header-info-item-value'>{selectedCreator?.pricing || '--'}</div>
|
||||
<div className='chat-detail-header-info-item-value'>
|
||||
{selectedCreator?.business?.pricing?.price || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='chat-detail-header-info-item'>
|
||||
<div className='chat-detail-header-info-item-label'>Collab.</div>
|
||||
<div className='chat-detail-header-info-item-value'>{selectedCreator?.collab || '--'}</div>
|
||||
<div className='chat-detail-header-info-item-value'>
|
||||
{selectedCreator?.business?.latest_collab || '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='chat-detail-summary'>
|
||||
<div className='chat-detail-summary-title'>Chat Summary</div>
|
||||
<Form.Control as='textarea' rows={6} className='chat-detail-summary-input' />
|
||||
<Form.Control
|
||||
as='textarea'
|
||||
disabled
|
||||
rows={6}
|
||||
className='chat-detail-summary-input'
|
||||
value={currentChat?.summary?.summary || currentChat?.summary_error || 'No summary available'}
|
||||
/>
|
||||
</div>
|
||||
<div className='chat-detail-generate'>
|
||||
<div className='chat-detail-generate-title'>Chat Generate</div>
|
||||
<Form className='generate-form' onSubmit={handleSubmit}>
|
||||
<Form.Control as='textarea' rows={4} className='generate-input' />
|
||||
<Form.Control
|
||||
disabled
|
||||
as='textarea'
|
||||
rows={4}
|
||||
className='generate-input'
|
||||
value={currentChat?.recommended_reply?.reply || currentChat?.recommended_reply_error}
|
||||
/>
|
||||
<Button className='rounded-pill submit-btn btn-sm' type='submit'>
|
||||
<Send size={16} />
|
||||
<LoaderCircle size={16} />
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
<div className='chat-detail-send-btn'>
|
||||
<Button className='rounded-pill btn-sm' type='submit' onClick={handleSendEmail}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Form } from 'react-bootstrap';
|
||||
|
||||
export default function ChatInput({ value, onChange }) {
|
||||
export default function ChatInput({ value, onChange, onSubmit }) {
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -12,6 +12,13 @@ export default function ChatInput({ value, onChange }) {
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Control
|
||||
as='textarea'
|
||||
@ -21,6 +28,7 @@ export default function ChatInput({ value, onChange }) {
|
||||
onChange={onChange}
|
||||
placeholder='Send a message...'
|
||||
className='chat-textarea'
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
57
src/components/ChatMessage.jsx
Normal file
57
src/components/ChatMessage.jsx
Normal file
@ -0,0 +1,57 @@
|
||||
import StreamingMarkdown from './StreamingMarkdown';
|
||||
import { Copy, RotateCcw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ChatMessage({ message, isStreaming = false }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
// 这里可以触发重新生成消息的逻辑
|
||||
console.log('重新生成消息:', message.id);
|
||||
};
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className='message user mb-3'>
|
||||
<div className='d-flex justify-content-end'>
|
||||
<div className='message-content user-message'>
|
||||
<div className='message-text'>{message.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='message assistant mb-3'>
|
||||
<StreamingMarkdown content={message.content} isStreaming={isStreaming} typewriterSpeed={20} />
|
||||
|
||||
{/* 消息操作按钮 */}
|
||||
{!isStreaming && (
|
||||
<div className='message-actions mt-2'>
|
||||
<button className='btn btn-sm btn-outline-primary me-2' onClick={handleCopy} title='复制内容'>
|
||||
<Copy size={14} />
|
||||
{copied && <span className='ms-1'>已复制</span>}
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-sm btn-outline-secondary d-none'
|
||||
onClick={handleRegenerate}
|
||||
title='重新生成'
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
360
src/components/ChatTest.jsx
Normal file
360
src/components/ChatTest.jsx
Normal file
@ -0,0 +1,360 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Container, Row, Col } from 'react-bootstrap';
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
const testMessages = [
|
||||
{
|
||||
id: 1,
|
||||
role: 'user',
|
||||
content: '你好,可以帮我介绍一下React Hooks吗?',
|
||||
created_at: '2024-01-01 10:00:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
role: 'assistant',
|
||||
content: `# React Hooks 详解
|
||||
|
||||
React Hooks 是 React 16.8 版本引入的新特性,它允许你在**不编写 class 组件**的情况下使用 state 以及其他的 React 特性。
|
||||
|
||||
## 主要的 Hooks
|
||||
|
||||
### 1. useState
|
||||
\`useState\` 是最常用的 Hook,用于在函数组件中添加状态:
|
||||
|
||||
\`\`\`javascript
|
||||
import { useState } from 'react';
|
||||
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>你点击了 {count} 次</p>
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
点击我
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 2. useEffect
|
||||
\`useEffect\` 用于处理副作用,如数据获取、订阅或手动更改 DOM:
|
||||
|
||||
\`\`\`javascript
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function Example() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
// 相当于 componentDidMount 和 componentDidUpdate:
|
||||
useEffect(() => {
|
||||
document.title = \`你点击了 \${count} 次\`;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>你点击了 {count} 次</p>
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
点击我
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 优势
|
||||
|
||||
| 特性 | Class 组件 | Hooks |
|
||||
|------|-----------|-------|
|
||||
| 状态管理 | this.state | useState |
|
||||
| 生命周期 | 复杂的生命周期方法 | useEffect |
|
||||
| 逻辑复用 | 高阶组件/渲染属性 | 自定义Hooks |
|
||||
| 代码量 | 较多 | 较少 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
> **重要**: 只在最顶层使用 Hook,不要在循环、条件或嵌套函数中调用 Hook。
|
||||
|
||||
### 自定义 Hook
|
||||
你可以创建自定义 Hook 来复用状态逻辑:
|
||||
|
||||
\`\`\`javascript
|
||||
function useCounter(initialValue = 0) {
|
||||
const [count, setCount] = useState(initialValue);
|
||||
|
||||
const increment = () => setCount(count + 1);
|
||||
const decrement = () => setCount(count - 1);
|
||||
const reset = () => setCount(initialValue);
|
||||
|
||||
return { count, increment, decrement, reset };
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
希望这个介绍对你有帮助!如果有任何问题,随时问我。`,
|
||||
created_at: '2024-01-01 10:01:00',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
role: 'user',
|
||||
content: '这个示例很清楚!能再给我一个复杂的代码示例吗?',
|
||||
created_at: '2024-01-01 10:02:00',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
role: 'assistant',
|
||||
content: `# 复杂的 React Hooks 示例
|
||||
|
||||
下面是一个更复杂的示例,展示了多个 Hooks 的组合使用:
|
||||
|
||||
## 用户列表组件
|
||||
|
||||
\`\`\`javascript
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
function UserList() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
|
||||
// 获取用户数据
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/users');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
const userData = await response.json();
|
||||
setUsers(userData);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件挂载时获取数据
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
// 过滤和排序用户
|
||||
const filteredAndSortedUsers = useMemo(() => {
|
||||
let filtered = users.filter(user =>
|
||||
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (sortBy === 'name') {
|
||||
return a.name.localeCompare(b.name);
|
||||
} else if (sortBy === 'email') {
|
||||
return a.email.localeCompare(b.email);
|
||||
} else if (sortBy === 'joinDate') {
|
||||
return new Date(b.joinDate) - new Date(a.joinDate);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [users, searchTerm, sortBy]);
|
||||
|
||||
// 删除用户
|
||||
const deleteUser = useCallback(async (userId) => {
|
||||
if (!window.confirm('确定要删除这个用户吗?')) return;
|
||||
|
||||
try {
|
||||
await fetch(\`/api/users/\${userId}\`, { method: 'DELETE' });
|
||||
setUsers(prev => prev.filter(user => user.id !== userId));
|
||||
} catch (err) {
|
||||
alert('删除失败:' + err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="spinner">加载中...</div>;
|
||||
if (error) return <div className="error">错误: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="user-list">
|
||||
<h2>用户列表 ({filteredAndSortedUsers.length})</h2>
|
||||
|
||||
{/* 搜索和排序控件 */}
|
||||
<div className="controls">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="sort-select"
|
||||
>
|
||||
<option value="name">按姓名排序</option>
|
||||
<option value="email">按邮箱排序</option>
|
||||
<option value="joinDate">按加入日期排序</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<div className="users">
|
||||
{filteredAndSortedUsers.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
onDelete={() => deleteUser(user.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 用户卡片组件
|
||||
const UserCard = React.memo(({ user, onDelete }) => {
|
||||
return (
|
||||
<div className="user-card">
|
||||
<img src={user.avatar} alt={user.name} className="avatar" />
|
||||
<div className="user-info">
|
||||
<h3>{user.name}</h3>
|
||||
<p>{user.email}</p>
|
||||
<small>加入时间: {new Date(user.joinDate).toLocaleDateString()}</small>
|
||||
</div>
|
||||
<button onClick={onDelete} className="delete-btn">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserList;
|
||||
\`\`\`
|
||||
|
||||
## 关键特性解析
|
||||
|
||||
### 🔥 性能优化
|
||||
- \`useCallback\`: 缓存函数,避免不必要的重新创建
|
||||
- \`useMemo\`: 缓存计算结果,避免重复计算
|
||||
- \`React.memo\`: 防止子组件不必要的重渲染
|
||||
|
||||
### 📊 状态管理
|
||||
- 多个 \`useState\` 管理不同的状态
|
||||
- 复杂的状态更新逻辑
|
||||
|
||||
### 🎯 副作用处理
|
||||
- \`useEffect\` 处理数据获取
|
||||
- 清理函数处理内存泄漏
|
||||
|
||||
这个示例展示了在实际项目中如何组合使用多个 Hooks 来构建功能完整的组件!`,
|
||||
created_at: '2024-01-01 10:03:00',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChatTest() {
|
||||
const [messages, setMessages] = useState(testMessages);
|
||||
const [streamingMessageId, setStreamingMessageId] = useState(null);
|
||||
|
||||
const simulateStreaming = () => {
|
||||
const newMessage = {
|
||||
id: messages.length + 1,
|
||||
role: 'assistant',
|
||||
content: `# 实时流式渲染演示
|
||||
|
||||
这是一个**实时流式渲染**的演示。文本会逐字显示,模拟真实的AI对话体验。
|
||||
|
||||
## 特性展示
|
||||
|
||||
### 1. 打字机效果
|
||||
- 文字逐个显示
|
||||
- 可以跳过动画
|
||||
- 流畅的视觉体验
|
||||
|
||||
### 2. Markdown 支持
|
||||
支持完整的Markdown语法:
|
||||
|
||||
\`\`\`javascript
|
||||
// 代码高亮示例
|
||||
function hello() {
|
||||
console.log("Hello, World!");
|
||||
return "streaming";
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 3. 表格支持
|
||||
|
||||
| 功能 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 打字机效果 | ✅ | 完成 |
|
||||
| 代码高亮 | ✅ | 完成 |
|
||||
| 表格渲染 | ✅ | 完成 |
|
||||
|
||||
> 这是一个引用块的示例,用来展示特殊内容。
|
||||
|
||||
### 4. 列表支持
|
||||
|
||||
1. 有序列表项1
|
||||
2. 有序列表项2
|
||||
3. 有序列表项3
|
||||
|
||||
- 无序列表项A
|
||||
- 无序列表项B
|
||||
- 无序列表项C
|
||||
|
||||
**粗体文本** 和 *斜体文本* 也完全支持!
|
||||
|
||||
[链接示例](https://react.dev) 可以正常工作。
|
||||
|
||||
---
|
||||
|
||||
这就是流式渲染的完整演示!`,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
setStreamingMessageId(newMessage.id);
|
||||
|
||||
// 模拟流式完成后清除streaming状态
|
||||
setTimeout(() => {
|
||||
setStreamingMessageId(null);
|
||||
}, newMessage.content.length * 20 + 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container fluid className='mt-4'>
|
||||
<Row>
|
||||
<Col md={8} className='mx-auto'>
|
||||
<div className='d-flex justify-content-between align-items-center mb-4'>
|
||||
<h2>聊天组件测试</h2>
|
||||
<Button onClick={simulateStreaming} variant='primary'>
|
||||
开始流式演示
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='chat-test-container'
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
|
||||
maxHeight: '70vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={message.id === streamingMessageId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -4,12 +4,14 @@ import { fetchChatHistory } from '../store/slices/inboxSlice';
|
||||
import { Ellipsis, Send } from 'lucide-react';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import ChatInput from './ChatInput';
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
export default function ChatWindow({ onOpenChatDetails }) {
|
||||
const { selectedChat, chatStatus } = useSelector((state) => state.inbox);
|
||||
const { currentChat, status } = useSelector((state) => state.chat);
|
||||
const [activePlatform, setActivePlatform] = useState('email');
|
||||
const dispatch = useDispatch();
|
||||
const [message, setMessage] = useState('');
|
||||
const [streamingMessageId, setStreamingMessageId] = useState(null);
|
||||
|
||||
const platformOptions = [
|
||||
{
|
||||
@ -27,14 +29,13 @@ export default function ChatWindow({ onOpenChatDetails }) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChat) {
|
||||
console.log(selectedChat);
|
||||
if (currentChat) {
|
||||
console.log(currentChat);
|
||||
}
|
||||
}, [selectedChat]);
|
||||
}, [currentChat]);
|
||||
|
||||
const handleSendMessage = (e) => {
|
||||
e.preventDefault();
|
||||
console.log(e.target.message.value);
|
||||
const handleSendMessage = () => {
|
||||
console.log(message);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -42,14 +43,14 @@ export default function ChatWindow({ onOpenChatDetails }) {
|
||||
<div className='chat-window-header'>
|
||||
<div className='chat-window-header-left'>
|
||||
<div className='chat-window-header-left-avatar'>
|
||||
<img src={selectedChat.avatar} alt='avatar' />
|
||||
<img src={currentChat?.negotiation?.creator?.avatar} alt={currentChat?.negotiation?.creator?.name} />
|
||||
</div>
|
||||
<div className='chat-window-header-left-info'>
|
||||
<div
|
||||
className='chat-window-header-left-info-name fw-bold'
|
||||
onClick={onOpenChatDetails}
|
||||
>
|
||||
{selectedChat.name}
|
||||
<div className='chat-window-header-left-info-name fw-bold' onClick={onOpenChatDetails}>
|
||||
{currentChat?.negotiation?.creator?.name}
|
||||
</div>
|
||||
<div className='chat-window-header-left-info-product rounded-pill'>
|
||||
{currentChat?.negotiation?.product?.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -72,17 +73,15 @@ export default function ChatWindow({ onOpenChatDetails }) {
|
||||
</div>
|
||||
<div className='chat-window-body'>
|
||||
<div className='chat-body'>
|
||||
{selectedChat?.chatHistory?.length > 0 &&
|
||||
selectedChat?.chatHistory?.map((msg) => (
|
||||
<div key={msg.id} className={`message ${msg.role === 'user' ? 'user' : 'assistant'}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
{currentChat?.messages?.length > 0 &&
|
||||
currentChat?.messages?.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} isStreaming={msg.id === streamingMessageId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='chat-window-footer'>
|
||||
<Form className='footer-input' onSubmit={handleSendMessage}>
|
||||
<ChatInput value={message} onChange={(e) => setMessage(e.target.value)} />
|
||||
<ChatInput value={message} onChange={(e) => setMessage(e.target.value)} onSubmit={handleSendMessage} />
|
||||
<Button variant='outline-primary' className='border-0' type='submit'>
|
||||
<Send />
|
||||
</Button>
|
||||
|
@ -1,22 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { fetchChatHistory, fetchInboxList, selectChat } from '../store/slices/inboxSlice';
|
||||
import { fetchInboxList } from '../store/slices/inboxSlice';
|
||||
import { fetchChatDetails, selectChat } from '../store/slices/chatSlice';
|
||||
import { chatDateFormat } from '../lib/utils';
|
||||
|
||||
export default function InboxList() {
|
||||
const dispatch = useDispatch();
|
||||
const { inboxList, inboxStatus: status, error, selectedChat } = useSelector((state) => state.inbox);
|
||||
const [activeToggle, setActiveToggle] = useState('all');
|
||||
const [activeSelect, setActiveSelect] = useState('all');
|
||||
const { inboxList, inboxStatus: status, error } = useSelector((state) => state.inbox);
|
||||
const { currentChat } = useSelector((state) => state.chat);
|
||||
const [activeToggle, setActiveToggle] = useState('all'); // all or initial_contact or negotiation or follow_up
|
||||
const [activeSelect, setActiveSelect] = useState('all'); // all or unread
|
||||
const toggleOptions = [
|
||||
{ name: '全部', value: 'all' },
|
||||
{ name: '首次建联', value: 'initial' },
|
||||
{ name: '砍价邮件', value: 'bargain' },
|
||||
{ name: '合作追踪', value: 'coop' },
|
||||
{ name: '首次建联', value: 'initial_contact' },
|
||||
{ name: '砍价邮件', value: 'negotiation' },
|
||||
{ name: '合作追踪', value: 'follow_up' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchInboxList());
|
||||
}, [dispatch]);
|
||||
if (activeToggle === 'all') {
|
||||
dispatch(fetchInboxList({}));
|
||||
} else {
|
||||
dispatch(fetchInboxList({ status: activeToggle }));
|
||||
}
|
||||
}, [dispatch, activeToggle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inboxList.length > 0) {
|
||||
@ -27,7 +34,7 @@ export default function InboxList() {
|
||||
|
||||
const handleSelectChat = (item) => {
|
||||
dispatch(selectChat(item));
|
||||
dispatch(fetchChatHistory(item.id));
|
||||
dispatch(fetchChatDetails({ conversation_id: item.conversation_id }));
|
||||
};
|
||||
|
||||
return (
|
||||
@ -68,30 +75,36 @@ export default function InboxList() {
|
||||
<div className='list-content'>
|
||||
{status === 'loading' && <div>Loading...</div>}
|
||||
{status === 'failed' && <div>Error: {error}</div>}
|
||||
{status === 'succeeded' && (inboxList.length > 0 ?
|
||||
inboxList.map((item) => (
|
||||
{status === 'succeeded' &&
|
||||
(inboxList.length > 0 ? (
|
||||
inboxList.map((chat) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`list-item ${selectedChat.id === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleSelectChat(item)}
|
||||
key={chat.conversation_id}
|
||||
className={`list-item ${
|
||||
currentChat?.conversation_id === chat.conversation_id ? 'active' : ''
|
||||
}`}
|
||||
onClick={() => handleSelectChat(chat)}
|
||||
>
|
||||
<div className='list-item-left'>
|
||||
<div className='list-item-left-avatar'>
|
||||
<img src={item.avatar} alt={item.name} />
|
||||
<img src={chat.creator.avatar} alt={chat.creator.name} />
|
||||
</div>
|
||||
<div className='list-item-info'>
|
||||
<div className='list-name fw-bold'>{item.name}</div>
|
||||
<div className='list-message'>{item.message}</div>
|
||||
<div className='list-name fw-bold'>{chat.creator.name}</div>
|
||||
<div className='list-message'>{chat.last_message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='list-item-right'>
|
||||
<div className='list-item-right-time'>{item.date}</div>
|
||||
{item.unreadMessageCount > 0 && (
|
||||
<div className='list-item-right-badge'>{item.unreadMessageCount}</div>
|
||||
<div className='list-item-right-time'>{chatDateFormat(chat.last_time)}</div>
|
||||
{chat.unread_count > 0 && (
|
||||
<div className='list-item-right-badge'>{chat.unread_count}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className='list-item-empty'>No Chats Found</div>)}
|
||||
))
|
||||
) : (
|
||||
<div className='list-item-empty'>No Chats Found</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
61
src/components/StreamingMarkdown.jsx
Normal file
61
src/components/StreamingMarkdown.jsx
Normal file
@ -0,0 +1,61 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useTypewriter } from '../hooks/useTypewriter';
|
||||
|
||||
export default function StreamingMarkdown({ content, isStreaming = z, typewriterSpeed = 30 }) {
|
||||
const { displayText, isTyping, skipAnimation } = useTypewriter(isStreaming ? content : '', typewriterSpeed);
|
||||
|
||||
// 如果不是流式模式,直接显示完整内容
|
||||
const textToRender = isStreaming ? displayText : content;
|
||||
|
||||
return (
|
||||
<div className='streaming-markdown'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter style={tomorrow} language={match[1]} PreTag='div' {...props}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
// 自定义表格样式
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className='table-responsive'>
|
||||
<table className='table table-striped table-hover'>{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// 自定义链接样式
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' className='text-decoration-none'>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{textToRender}
|
||||
</ReactMarkdown>
|
||||
|
||||
{/* 打字机效果控制 */}
|
||||
{isStreaming && isTyping && (
|
||||
<div className='typewriter-controls mt-2'>
|
||||
<button className='btn btn-sm btn-outline-secondary' onClick={skipAnimation}>
|
||||
跳过动画
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
55
src/hooks/useTypewriter.js
Normal file
55
src/hooks/useTypewriter.js
Normal file
@ -0,0 +1,55 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export const useTypewriter = (text, speed = 30) => {
|
||||
const [displayText, setDisplayText] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const intervalRef = useRef(null);
|
||||
const indexRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!text) return;
|
||||
|
||||
setDisplayText('');
|
||||
setIsTyping(true);
|
||||
setIsCompleted(false);
|
||||
indexRef.current = 0;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setDisplayText((prev) => {
|
||||
const newText = text.slice(0, indexRef.current + 1);
|
||||
indexRef.current++;
|
||||
|
||||
if (indexRef.current >= text.length) {
|
||||
setIsTyping(false);
|
||||
setIsCompleted(true);
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
return newText;
|
||||
});
|
||||
}, speed);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [text, speed]);
|
||||
|
||||
const skipAnimation = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
setDisplayText(text);
|
||||
setIsTyping(false);
|
||||
setIsCompleted(true);
|
||||
};
|
||||
|
||||
return {
|
||||
displayText,
|
||||
isTyping,
|
||||
isCompleted,
|
||||
skipAnimation,
|
||||
};
|
||||
};
|
@ -1,3 +1,5 @@
|
||||
@import './styles/ChatMessage.scss';
|
||||
|
||||
:root {
|
||||
--bs-primary-rgb: 99, 102, 241; /* 必须匹配custom-theme.scss中的$primary */
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { format, isToday, parseISO } from 'date-fns';
|
||||
import { BRAND_SOURCES } from "./constant";
|
||||
|
||||
/**
|
||||
@ -61,3 +62,12 @@ export const getBrandSourceName = (source) => {
|
||||
export const getTemplateMissionName = (mission) => {
|
||||
return TEMPLATE_MISSIONS.find(item => item.value === mission)?.name || mission;
|
||||
};
|
||||
|
||||
export const chatDateFormat = (date) => {
|
||||
const now = typeof date === 'string' ? parseISO(date) : date;
|
||||
if (isToday(now)) {
|
||||
return format(now, 'HH:mm');
|
||||
} else {
|
||||
return format(now, 'MMM do');
|
||||
}
|
||||
};
|
@ -3,11 +3,11 @@ import InboxList from '@/components/InboxList';
|
||||
import ChatWindow from '@/components/ChatWindow';
|
||||
import '@/styles/Inbox.scss';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { resetSelectedChat } from '@/store/slices/inboxSlice';
|
||||
import { resetSelectedChat } from '@/store/slices/chatSlice';
|
||||
import ChatDetails from '@/components/ChatDetails';
|
||||
|
||||
export default function CreatorInbox() {
|
||||
const { selectedChat } = useSelector((state) => state.inbox);
|
||||
const { currentChat } = useSelector((state) => state.chat);
|
||||
const [openChatDetails, setOpenChatDetails] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
@ -17,12 +17,12 @@ export default function CreatorInbox() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {}, [selectedChat]);
|
||||
useEffect(() => {}, [currentChat]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<InboxList />
|
||||
{selectedChat?.id && <ChatWindow onOpenChatDetails={() => setOpenChatDetails(true)} />}
|
||||
{currentChat?.conversation_id && <ChatWindow onOpenChatDetails={() => setOpenChatDetails(true)} />}
|
||||
{openChatDetails && <ChatDetails onCloseChatDetails={() => setOpenChatDetails(false)} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
@ -12,6 +12,7 @@ import CreatorDiscovery from '@/pages/CreatorDiscovery';
|
||||
import PrivateCreator from '@/pages/PrivateCreator';
|
||||
import CreatorDetail from '@/pages/CreatorDetail';
|
||||
import InboxTemplate from '@/pages/InboxTemplate';
|
||||
import ChatTest from '@/components/ChatTest';
|
||||
|
||||
// Routes configuration object
|
||||
const routes = [
|
||||
@ -93,6 +94,10 @@ const routes = [
|
||||
path: '/inbox-templates',
|
||||
element: <InboxTemplate />,
|
||||
},
|
||||
{
|
||||
path: '/chat-test',
|
||||
element: <ChatTest />,
|
||||
},
|
||||
];
|
||||
|
||||
// Create router with routes wrapped in the layout
|
||||
|
@ -30,7 +30,8 @@ api.interceptors.response.use(
|
||||
window.location.href = '/login';
|
||||
dispatch(clearUser());
|
||||
}
|
||||
return Promise.reject(error);
|
||||
console.log(error);
|
||||
return Promise.reject(error.response.data || error);
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -50,6 +50,41 @@ export const createConversation = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchConversationSummary = createAsyncThunk(
|
||||
'chat/fetchConversationSummary',
|
||||
async (conversation_id, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
const response = await api.get(`/gmail/conversations/summary/${conversation_id}/`);
|
||||
console.log(response);
|
||||
if (response.code === 200) {
|
||||
return response.data;
|
||||
}
|
||||
console.log(response);
|
||||
throw new Error(response.message);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getRecommendedReply = createAsyncThunk(
|
||||
'chat/getRecommendedReply',
|
||||
async (query, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
const response = await api.post('/gmail/recommended-reply/', query);
|
||||
if (response.code === 200) {
|
||||
return response.data;
|
||||
}
|
||||
throw new Error(response.message);
|
||||
} catch (error) {
|
||||
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
currentChat: null,
|
||||
status: 'idle',
|
||||
@ -59,20 +94,51 @@ const initialState = {
|
||||
const chatSlice = createSlice({
|
||||
name: 'chat',
|
||||
initialState,
|
||||
reducers: {},
|
||||
reducers: {
|
||||
selectChat: (state, action) => {
|
||||
state.currentChat = action.payload;
|
||||
console.log(action.payload);
|
||||
},
|
||||
resetSelectedChat: (state) => {
|
||||
state.currentChat = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(fetchChatDetails.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
});
|
||||
builder.addCase(fetchChatDetails.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.currentChat = action.payload;
|
||||
const { messages, negotiation } = action.payload;
|
||||
state.currentChat.messages = messages;
|
||||
state.currentChat.negotiation = negotiation;
|
||||
});
|
||||
builder.addCase(fetchChatDetails.rejected, (state, action) => {
|
||||
state.status = 'failed';
|
||||
state.error = action.payload;
|
||||
});
|
||||
builder.addCase(fetchConversationSummary.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
});
|
||||
builder.addCase(fetchConversationSummary.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.currentChat.summary = action.payload;
|
||||
});
|
||||
builder.addCase(fetchConversationSummary.rejected, (state, action) => {
|
||||
state.currentChat.summary_error = action.payload;
|
||||
});
|
||||
builder.addCase(getRecommendedReply.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
});
|
||||
builder.addCase(getRecommendedReply.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.currentChat.recommended_reply = action.payload;
|
||||
});
|
||||
builder.addCase(getRecommendedReply.rejected, (state, action) => {
|
||||
state.currentChat.recommended_reply_error = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { selectChat, resetSelectedChat } = chatSlice.actions;
|
||||
export default chatSlice.reducer;
|
||||
|
@ -248,7 +248,7 @@ export const fetchPrivateCreators = createAsyncThunk(
|
||||
|
||||
const { code, data, message, pagination } = await api.post(
|
||||
`/daren_detail/private/pools/creators/filter/?page=${page}`,
|
||||
{ pool_id: 1, filter }
|
||||
{ filter }
|
||||
);
|
||||
if (code === 200) {
|
||||
return { data, pagination };
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { format, isToday, parseISO } from 'date-fns';
|
||||
import api from '@/services/api';
|
||||
import { setNotificationBarMessage } from './notificationBarSlice';
|
||||
|
||||
@ -164,28 +163,39 @@ const mockChatHistory = [
|
||||
},
|
||||
];
|
||||
|
||||
const chatDateFormat = (date) => {
|
||||
const now = typeof date === 'string' ? parseISO(date) : date;
|
||||
if (isToday(now)) {
|
||||
return format(now, 'HH:mm');
|
||||
} else {
|
||||
return format(now, 'MMM do');
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchInboxList = createAsyncThunk('inbox/fetchInboxList', async (_, { rejectWithValue }) => {
|
||||
export const fetchInboxList = createAsyncThunk('inbox/fetchInboxList', async (query, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await api.get('/chat-history/');
|
||||
const response = await api.get('/chat-history/get_negotiation_chats/', { params: query });
|
||||
if (response.code === 200) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.response.data.message);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteConversation = createAsyncThunk(
|
||||
'inbox/deleteConversation',
|
||||
async (id, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
const response = await api.delete('/chat-history/delete_conversation/', {
|
||||
params: { conversation_id: id },
|
||||
});
|
||||
if (response.code === 200) {
|
||||
dispatch(setNotificationBarMessage({ message: response.message, type: 'success' }));
|
||||
dispatch(fetchInboxList({}));
|
||||
return response.data;
|
||||
}
|
||||
throw new Error(response.message);
|
||||
} catch (error) {
|
||||
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchChatHistory = createAsyncThunk('inbox/fetchChatHistory', async (id) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return { chatHistory: mockChatHistory, chatId: id };
|
||||
@ -200,7 +210,7 @@ export const fetchTemplates = createAsyncThunk('inbox/fetchTemplates', async (qu
|
||||
throw new Error(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.response.data.message);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
@ -216,7 +226,7 @@ export const editTemplateThunk = createAsyncThunk(
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
|
||||
return rejectWithValue(error.response.data.message);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -233,7 +243,7 @@ export const addTemplateThunk = createAsyncThunk(
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
|
||||
return rejectWithValue(error.response.data.message);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -247,7 +257,7 @@ export const getTemplateDetail = createAsyncThunk('inbox/getTemplateDetail', asy
|
||||
throw new Error(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.response.data.message);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
@ -264,14 +274,7 @@ const initialState = {
|
||||
const inboxSlice = createSlice({
|
||||
name: 'inbox',
|
||||
initialState,
|
||||
reducers: {
|
||||
selectChat: (state, action) => {
|
||||
state.selectedChat = action.payload;
|
||||
},
|
||||
resetSelectedChat: (state) => {
|
||||
state.selectedChat = {};
|
||||
},
|
||||
},
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchInboxList.pending, (state) => {
|
||||
@ -279,7 +282,7 @@ const inboxSlice = createSlice({
|
||||
})
|
||||
.addCase(fetchInboxList.fulfilled, (state, action) => {
|
||||
state.inboxStatus = 'succeeded';
|
||||
state.inboxList = action.payload;
|
||||
state.inboxList = action.payload.results;
|
||||
})
|
||||
.addCase(fetchInboxList.rejected, (state, action) => {
|
||||
state.inboxStatus = 'failed';
|
||||
@ -336,11 +339,11 @@ const inboxSlice = createSlice({
|
||||
state.error = action.payload;
|
||||
})
|
||||
.addCase(getTemplateDetail.fulfilled, (state, action) => {
|
||||
const template = state.templates.find(item => item.id === action.payload.id);
|
||||
const template = state.templates.find((item) => item.id === action.payload.id);
|
||||
if (template) {
|
||||
Object.assign(template, action.payload);
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
190
src/styles/ChatMessage.scss
Normal file
190
src/styles/ChatMessage.scss
Normal file
@ -0,0 +1,190 @@
|
||||
// ChatMessage 样式
|
||||
.message {
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
word-wrap: break-word;
|
||||
|
||||
&.user-message {
|
||||
background: var(--bs-primary);
|
||||
color: white;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
padding: 12px 16px;
|
||||
|
||||
.message-text {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant-message {
|
||||
background: var(--bs-light);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
padding: 12px 16px;
|
||||
|
||||
.streaming-markdown {
|
||||
// Markdown 内容样式
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 代码块样式
|
||||
pre {
|
||||
background: #2d3748 !important;
|
||||
border-radius: 8px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
background: transparent !important;
|
||||
color: #e2e8f0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 内联代码样式
|
||||
code {
|
||||
background: var(--bs-secondary-bg);
|
||||
color: var(--bs-danger);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
// 引用块样式
|
||||
blockquote {
|
||||
border-left: 4px solid var(--bs-primary);
|
||||
margin-left: 0;
|
||||
padding-left: 16px;
|
||||
color: var(--bs-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// 列表样式
|
||||
ul, ol {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 链接样式
|
||||
a {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--bs-primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式
|
||||
.table-responsive {
|
||||
margin: 12px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
font-size: 0.9em;
|
||||
|
||||
th {
|
||||
background: var(--bs-secondary-bg);
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消息操作按钮
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 0.8em;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
svg {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打字机控制按钮
|
||||
.typewriter-controls {
|
||||
opacity: 0.8;
|
||||
|
||||
.btn {
|
||||
font-size: 0.8em;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天窗口body的滚动样式
|
||||
.chat-window-body {
|
||||
.chat-body {
|
||||
padding: 16px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
|
||||
// 自定义滚动条
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bs-border-color);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bs-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式调整
|
||||
@media (max-width: 768px) {
|
||||
.message .message-content {
|
||||
max-width: 85%;
|
||||
|
||||
&.user-message, &.assistant-message {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
@ -105,15 +105,28 @@
|
||||
flex-flow: row nowrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.list-item-left-avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: $primary-150;
|
||||
}
|
||||
.list-item-info {
|
||||
.list-message {
|
||||
color: $neutral-700;
|
||||
color: $neutral-900;
|
||||
width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -172,6 +185,18 @@
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-window-header-left-info {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
.chat-window-header-left-info-product {
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
padding: 6px 10px;
|
||||
background-color: $neutral-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window-header-right {
|
||||
@ -267,6 +292,7 @@
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
align-items: flex-end;
|
||||
|
||||
.chat-details-header {
|
||||
display: flex;
|
||||
@ -275,6 +301,7 @@
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid $neutral-200;
|
||||
padding-bottom: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.chat-header-title {
|
||||
display: flex;
|
||||
@ -296,10 +323,16 @@
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: $primary-150;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-detail-header-infos {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 0.5rem;
|
||||
@ -318,6 +351,7 @@
|
||||
}
|
||||
}
|
||||
.chat-detail-summary {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 0.5rem;
|
||||
@ -334,6 +368,7 @@
|
||||
}
|
||||
}
|
||||
.chat-detail-generate {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
padding: 1rem 0;
|
||||
@ -372,8 +407,8 @@
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: .875rem;
|
||||
box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1F;
|
||||
gap: 0.875rem;
|
||||
box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1f;
|
||||
max-height: 380px;
|
||||
height: fit-content;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user