mirror of
https://github.com/Funkoala14/CreatorCenter_OOIN.git
synced 2025-06-08 02:39:42 +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-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.6.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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@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 { useEffect, useState } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { fetchCreatorDetail } from '../store/slices/creatorsSlice';
|
import { fetchCreatorDetail } from '../store/slices/creatorsSlice';
|
||||||
import { Button, Form } from 'react-bootstrap';
|
import { Button, Form } from 'react-bootstrap';
|
||||||
|
import { fetchConversationSummary, getRecommendedReply } from '../store/slices/chatSlice';
|
||||||
|
|
||||||
export default function ChatDetails({ onCloseChatDetails }) {
|
export default function ChatDetails({ onCloseChatDetails }) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const { selectedChat } = useSelector((state) => state.inbox);
|
const { currentChat } = useSelector((state) => state.chat);
|
||||||
const { selectedCreator } = useSelector((state) => state.creators);
|
const { selectedCreator } = useSelector((state) => state.creators);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log('Form submitted');
|
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(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchCreatorDetail({ creatorId: selectedChat.id }));
|
dispatch(fetchCreatorDetail({ creatorId: currentChat?.negotiation?.creator?.id }));
|
||||||
}, [dispatch, selectedChat]);
|
dispatch(fetchConversationSummary(currentChat?.conversation_id));
|
||||||
|
}, [dispatch, currentChat]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='chat-details'>
|
<div className='chat-details'>
|
||||||
@ -30,42 +52,63 @@ export default function ChatDetails({ onCloseChatDetails }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className='chat-detail-header-creator'>
|
<div className='chat-detail-header-creator'>
|
||||||
<div className='chat-detail-header-creator-avatar'>
|
<div className='chat-detail-header-creator-avatar'>
|
||||||
<img src={selectedChat?.avatar} alt='avatar' />
|
<img src={selectedCreator?.creator?.avatar} alt={selectedCreator?.creator?.name} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className='chat-detail-header-infos'>
|
<div className='chat-detail-header-infos'>
|
||||||
<div className='chat-detail-header-info-item'>
|
<div className='chat-detail-header-info-item'>
|
||||||
<div className='chat-detail-header-info-item-label'>Category</div>
|
<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>
|
||||||
<div className='chat-detail-header-info-item'>
|
<div className='chat-detail-header-info-item'>
|
||||||
<div className='chat-detail-header-info-item-label'>MCN</div>
|
<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>
|
||||||
<div className='chat-detail-header-info-item'>
|
<div className='chat-detail-header-info-item'>
|
||||||
<div className='chat-detail-header-info-item-label'>Pricing</div>
|
<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>
|
||||||
<div className='chat-detail-header-info-item'>
|
<div className='chat-detail-header-info-item'>
|
||||||
<div className='chat-detail-header-info-item-label'>Collab.</div>
|
<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>
|
</div>
|
||||||
<div className='chat-detail-summary'>
|
<div className='chat-detail-summary'>
|
||||||
<div className='chat-detail-summary-title'>Chat Summary</div>
|
<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>
|
||||||
<div className='chat-detail-generate'>
|
<div className='chat-detail-generate'>
|
||||||
<div className='chat-detail-generate-title'>Chat Generate</div>
|
<div className='chat-detail-generate-title'>Chat Generate</div>
|
||||||
<Form className='generate-form' onSubmit={handleSubmit}>
|
<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'>
|
<Button className='rounded-pill submit-btn btn-sm' type='submit'>
|
||||||
<Send size={16} />
|
<LoaderCircle size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='chat-detail-send-btn'>
|
||||||
|
<Button className='rounded-pill btn-sm' type='submit' onClick={handleSendEmail}>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { Form } from 'react-bootstrap';
|
import { Form } from 'react-bootstrap';
|
||||||
|
|
||||||
export default function ChatInput({ value, onChange }) {
|
export default function ChatInput({ value, onChange, onSubmit }) {
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -12,6 +12,13 @@ export default function ChatInput({ value, onChange }) {
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as='textarea'
|
as='textarea'
|
||||||
@ -21,6 +28,7 @@ export default function ChatInput({ value, onChange }) {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder='Send a message...'
|
placeholder='Send a message...'
|
||||||
className='chat-textarea'
|
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 { Ellipsis, Send } from 'lucide-react';
|
||||||
import { Button, Form } from 'react-bootstrap';
|
import { Button, Form } from 'react-bootstrap';
|
||||||
import ChatInput from './ChatInput';
|
import ChatInput from './ChatInput';
|
||||||
|
import ChatMessage from './ChatMessage';
|
||||||
|
|
||||||
export default function ChatWindow({ onOpenChatDetails }) {
|
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 [activePlatform, setActivePlatform] = useState('email');
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [streamingMessageId, setStreamingMessageId] = useState(null);
|
||||||
|
|
||||||
const platformOptions = [
|
const platformOptions = [
|
||||||
{
|
{
|
||||||
@ -27,14 +29,13 @@ export default function ChatWindow({ onOpenChatDetails }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedChat) {
|
if (currentChat) {
|
||||||
console.log(selectedChat);
|
console.log(currentChat);
|
||||||
}
|
}
|
||||||
}, [selectedChat]);
|
}, [currentChat]);
|
||||||
|
|
||||||
const handleSendMessage = (e) => {
|
const handleSendMessage = () => {
|
||||||
e.preventDefault();
|
console.log(message);
|
||||||
console.log(e.target.message.value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -42,14 +43,14 @@ export default function ChatWindow({ onOpenChatDetails }) {
|
|||||||
<div className='chat-window-header'>
|
<div className='chat-window-header'>
|
||||||
<div className='chat-window-header-left'>
|
<div className='chat-window-header-left'>
|
||||||
<div className='chat-window-header-left-avatar'>
|
<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>
|
||||||
<div className='chat-window-header-left-info'>
|
<div className='chat-window-header-left-info'>
|
||||||
<div
|
<div className='chat-window-header-left-info-name fw-bold' onClick={onOpenChatDetails}>
|
||||||
className='chat-window-header-left-info-name fw-bold'
|
{currentChat?.negotiation?.creator?.name}
|
||||||
onClick={onOpenChatDetails}
|
</div>
|
||||||
>
|
<div className='chat-window-header-left-info-product rounded-pill'>
|
||||||
{selectedChat.name}
|
{currentChat?.negotiation?.product?.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -72,17 +73,15 @@ export default function ChatWindow({ onOpenChatDetails }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className='chat-window-body'>
|
<div className='chat-window-body'>
|
||||||
<div className='chat-body'>
|
<div className='chat-body'>
|
||||||
{selectedChat?.chatHistory?.length > 0 &&
|
{currentChat?.messages?.length > 0 &&
|
||||||
selectedChat?.chatHistory?.map((msg) => (
|
currentChat?.messages?.map((msg) => (
|
||||||
<div key={msg.id} className={`message ${msg.role === 'user' ? 'user' : 'assistant'}`}>
|
<ChatMessage key={msg.id} message={msg} isStreaming={msg.id === streamingMessageId} />
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='chat-window-footer'>
|
<div className='chat-window-footer'>
|
||||||
<Form className='footer-input' onSubmit={handleSendMessage}>
|
<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'>
|
<Button variant='outline-primary' className='border-0' type='submit'>
|
||||||
<Send />
|
<Send />
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,22 +1,29 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
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() {
|
export default function InboxList() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { inboxList, inboxStatus: status, error, selectedChat } = useSelector((state) => state.inbox);
|
const { inboxList, inboxStatus: status, error } = useSelector((state) => state.inbox);
|
||||||
const [activeToggle, setActiveToggle] = useState('all');
|
const { currentChat } = useSelector((state) => state.chat);
|
||||||
const [activeSelect, setActiveSelect] = useState('all');
|
const [activeToggle, setActiveToggle] = useState('all'); // all or initial_contact or negotiation or follow_up
|
||||||
|
const [activeSelect, setActiveSelect] = useState('all'); // all or unread
|
||||||
const toggleOptions = [
|
const toggleOptions = [
|
||||||
{ name: '全部', value: 'all' },
|
{ name: '全部', value: 'all' },
|
||||||
{ name: '首次建联', value: 'initial' },
|
{ name: '首次建联', value: 'initial_contact' },
|
||||||
{ name: '砍价邮件', value: 'bargain' },
|
{ name: '砍价邮件', value: 'negotiation' },
|
||||||
{ name: '合作追踪', value: 'coop' },
|
{ name: '合作追踪', value: 'follow_up' },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchInboxList());
|
if (activeToggle === 'all') {
|
||||||
}, [dispatch]);
|
dispatch(fetchInboxList({}));
|
||||||
|
} else {
|
||||||
|
dispatch(fetchInboxList({ status: activeToggle }));
|
||||||
|
}
|
||||||
|
}, [dispatch, activeToggle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inboxList.length > 0) {
|
if (inboxList.length > 0) {
|
||||||
@ -27,7 +34,7 @@ export default function InboxList() {
|
|||||||
|
|
||||||
const handleSelectChat = (item) => {
|
const handleSelectChat = (item) => {
|
||||||
dispatch(selectChat(item));
|
dispatch(selectChat(item));
|
||||||
dispatch(fetchChatHistory(item.id));
|
dispatch(fetchChatDetails({ conversation_id: item.conversation_id }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -68,30 +75,36 @@ export default function InboxList() {
|
|||||||
<div className='list-content'>
|
<div className='list-content'>
|
||||||
{status === 'loading' && <div>Loading...</div>}
|
{status === 'loading' && <div>Loading...</div>}
|
||||||
{status === 'failed' && <div>Error: {error}</div>}
|
{status === 'failed' && <div>Error: {error}</div>}
|
||||||
{status === 'succeeded' && (inboxList.length > 0 ?
|
{status === 'succeeded' &&
|
||||||
inboxList.map((item) => (
|
(inboxList.length > 0 ? (
|
||||||
|
inboxList.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={chat.conversation_id}
|
||||||
className={`list-item ${selectedChat.id === item.id ? 'active' : ''}`}
|
className={`list-item ${
|
||||||
onClick={() => handleSelectChat(item)}
|
currentChat?.conversation_id === chat.conversation_id ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectChat(chat)}
|
||||||
>
|
>
|
||||||
<div className='list-item-left'>
|
<div className='list-item-left'>
|
||||||
<div className='list-item-left-avatar'>
|
<div className='list-item-left-avatar'>
|
||||||
<img src={item.avatar} alt={item.name} />
|
<img src={chat.creator.avatar} alt={chat.creator.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className='list-item-info'>
|
<div className='list-item-info'>
|
||||||
<div className='list-name fw-bold'>{item.name}</div>
|
<div className='list-name fw-bold'>{chat.creator.name}</div>
|
||||||
<div className='list-message'>{item.message}</div>
|
<div className='list-message'>{chat.last_message}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='list-item-right'>
|
<div className='list-item-right'>
|
||||||
<div className='list-item-right-time'>{item.date}</div>
|
<div className='list-item-right-time'>{chatDateFormat(chat.last_time)}</div>
|
||||||
{item.unreadMessageCount > 0 && (
|
{chat.unread_count > 0 && (
|
||||||
<div className='list-item-right-badge'>{item.unreadMessageCount}</div>
|
<div className='list-item-right-badge'>{chat.unread_count}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)) : <div className='list-item-empty'>No Chats Found</div>)}
|
))
|
||||||
|
) : (
|
||||||
|
<div className='list-item-empty'>No Chats Found</div>
|
||||||
|
))}
|
||||||
</div>
|
</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 {
|
:root {
|
||||||
--bs-primary-rgb: 99, 102, 241; /* 必须匹配custom-theme.scss中的$primary */
|
--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";
|
import { BRAND_SOURCES } from "./constant";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -61,3 +62,12 @@ export const getBrandSourceName = (source) => {
|
|||||||
export const getTemplateMissionName = (mission) => {
|
export const getTemplateMissionName = (mission) => {
|
||||||
return TEMPLATE_MISSIONS.find(item => item.value === mission)?.name || 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 ChatWindow from '@/components/ChatWindow';
|
||||||
import '@/styles/Inbox.scss';
|
import '@/styles/Inbox.scss';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { resetSelectedChat } from '@/store/slices/inboxSlice';
|
import { resetSelectedChat } from '@/store/slices/chatSlice';
|
||||||
import ChatDetails from '@/components/ChatDetails';
|
import ChatDetails from '@/components/ChatDetails';
|
||||||
|
|
||||||
export default function CreatorInbox() {
|
export default function CreatorInbox() {
|
||||||
const { selectedChat } = useSelector((state) => state.inbox);
|
const { currentChat } = useSelector((state) => state.chat);
|
||||||
const [openChatDetails, setOpenChatDetails] = useState(false);
|
const [openChatDetails, setOpenChatDetails] = useState(false);
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@ -17,12 +17,12 @@ export default function CreatorInbox() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {}, [selectedChat]);
|
useEffect(() => {}, [currentChat]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<InboxList />
|
<InboxList />
|
||||||
{selectedChat?.id && <ChatWindow onOpenChatDetails={() => setOpenChatDetails(true)} />}
|
{currentChat?.conversation_id && <ChatWindow onOpenChatDetails={() => setOpenChatDetails(true)} />}
|
||||||
{openChatDetails && <ChatDetails onCloseChatDetails={() => setOpenChatDetails(false)} />}
|
{openChatDetails && <ChatDetails onCloseChatDetails={() => setOpenChatDetails(false)} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
@ -12,6 +12,7 @@ import CreatorDiscovery from '@/pages/CreatorDiscovery';
|
|||||||
import PrivateCreator from '@/pages/PrivateCreator';
|
import PrivateCreator from '@/pages/PrivateCreator';
|
||||||
import CreatorDetail from '@/pages/CreatorDetail';
|
import CreatorDetail from '@/pages/CreatorDetail';
|
||||||
import InboxTemplate from '@/pages/InboxTemplate';
|
import InboxTemplate from '@/pages/InboxTemplate';
|
||||||
|
import ChatTest from '@/components/ChatTest';
|
||||||
|
|
||||||
// Routes configuration object
|
// Routes configuration object
|
||||||
const routes = [
|
const routes = [
|
||||||
@ -28,7 +29,7 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
element: <Database path='tiktok'/>,
|
element: <Database path='tiktok' />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'tiktok',
|
path: 'tiktok',
|
||||||
@ -93,6 +94,10 @@ const routes = [
|
|||||||
path: '/inbox-templates',
|
path: '/inbox-templates',
|
||||||
element: <InboxTemplate />,
|
element: <InboxTemplate />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/chat-test',
|
||||||
|
element: <ChatTest />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create router with routes wrapped in the layout
|
// Create router with routes wrapped in the layout
|
||||||
|
@ -30,7 +30,8 @@ api.interceptors.response.use(
|
|||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
dispatch(clearUser());
|
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 = {
|
const initialState = {
|
||||||
currentChat: null,
|
currentChat: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
@ -59,20 +94,51 @@ const initialState = {
|
|||||||
const chatSlice = createSlice({
|
const chatSlice = createSlice({
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {},
|
reducers: {
|
||||||
|
selectChat: (state, action) => {
|
||||||
|
state.currentChat = action.payload;
|
||||||
|
console.log(action.payload);
|
||||||
|
},
|
||||||
|
resetSelectedChat: (state) => {
|
||||||
|
state.currentChat = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder.addCase(fetchChatDetails.pending, (state) => {
|
builder.addCase(fetchChatDetails.pending, (state) => {
|
||||||
state.status = 'loading';
|
state.status = 'loading';
|
||||||
});
|
});
|
||||||
builder.addCase(fetchChatDetails.fulfilled, (state, action) => {
|
builder.addCase(fetchChatDetails.fulfilled, (state, action) => {
|
||||||
state.status = 'succeeded';
|
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) => {
|
builder.addCase(fetchChatDetails.rejected, (state, action) => {
|
||||||
state.status = 'failed';
|
state.status = 'failed';
|
||||||
state.error = action.payload;
|
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;
|
export default chatSlice.reducer;
|
||||||
|
@ -248,7 +248,7 @@ export const fetchPrivateCreators = createAsyncThunk(
|
|||||||
|
|
||||||
const { code, data, message, pagination } = await api.post(
|
const { code, data, message, pagination } = await api.post(
|
||||||
`/daren_detail/private/pools/creators/filter/?page=${page}`,
|
`/daren_detail/private/pools/creators/filter/?page=${page}`,
|
||||||
{ pool_id: 1, filter }
|
{ filter }
|
||||||
);
|
);
|
||||||
if (code === 200) {
|
if (code === 200) {
|
||||||
return { data, pagination };
|
return { data, pagination };
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||||
import { format, isToday, parseISO } from 'date-fns';
|
|
||||||
import api from '@/services/api';
|
import api from '@/services/api';
|
||||||
import { setNotificationBarMessage } from './notificationBarSlice';
|
import { setNotificationBarMessage } from './notificationBarSlice';
|
||||||
|
|
||||||
@ -164,28 +163,39 @@ const mockChatHistory = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const chatDateFormat = (date) => {
|
export const fetchInboxList = createAsyncThunk('inbox/fetchInboxList', async (query, { rejectWithValue }) => {
|
||||||
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 }) => {
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/chat-history/');
|
const response = await api.get('/chat-history/get_negotiation_chats/', { params: query });
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message);
|
throw new Error(response.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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) => {
|
export const fetchChatHistory = createAsyncThunk('inbox/fetchChatHistory', async (id) => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
return { chatHistory: mockChatHistory, chatId: id };
|
return { chatHistory: mockChatHistory, chatId: id };
|
||||||
@ -200,7 +210,7 @@ export const fetchTemplates = createAsyncThunk('inbox/fetchTemplates', async (qu
|
|||||||
throw new Error(response.message);
|
throw new Error(response.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return rejectWithValue(error.response.data.message);
|
return rejectWithValue(error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -216,7 +226,7 @@ export const editTemplateThunk = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch(setNotificationBarMessage({ message: error.message, type: '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) {
|
} catch (error) {
|
||||||
dispatch(setNotificationBarMessage({ message: error.message, type: '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);
|
throw new Error(response.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return rejectWithValue(error.response.data.message);
|
return rejectWithValue(error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -264,14 +274,7 @@ const initialState = {
|
|||||||
const inboxSlice = createSlice({
|
const inboxSlice = createSlice({
|
||||||
name: 'inbox',
|
name: 'inbox',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {},
|
||||||
selectChat: (state, action) => {
|
|
||||||
state.selectedChat = action.payload;
|
|
||||||
},
|
|
||||||
resetSelectedChat: (state) => {
|
|
||||||
state.selectedChat = {};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
.addCase(fetchInboxList.pending, (state) => {
|
.addCase(fetchInboxList.pending, (state) => {
|
||||||
@ -279,7 +282,7 @@ const inboxSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchInboxList.fulfilled, (state, action) => {
|
.addCase(fetchInboxList.fulfilled, (state, action) => {
|
||||||
state.inboxStatus = 'succeeded';
|
state.inboxStatus = 'succeeded';
|
||||||
state.inboxList = action.payload;
|
state.inboxList = action.payload.results;
|
||||||
})
|
})
|
||||||
.addCase(fetchInboxList.rejected, (state, action) => {
|
.addCase(fetchInboxList.rejected, (state, action) => {
|
||||||
state.inboxStatus = 'failed';
|
state.inboxStatus = 'failed';
|
||||||
@ -336,11 +339,11 @@ const inboxSlice = createSlice({
|
|||||||
state.error = action.payload;
|
state.error = action.payload;
|
||||||
})
|
})
|
||||||
.addCase(getTemplateDetail.fulfilled, (state, action) => {
|
.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) {
|
if (template) {
|
||||||
Object.assign(template, action.payload);
|
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;
|
flex-flow: row nowrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
.list-item-left-avatar {
|
.list-item-left-avatar {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: $primary-150;
|
||||||
}
|
}
|
||||||
.list-item-info {
|
.list-item-info {
|
||||||
.list-message {
|
.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%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
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 {
|
.chat-window-header-right {
|
||||||
@ -267,6 +292,7 @@
|
|||||||
width: 300px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
.chat-details-header {
|
.chat-details-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -275,6 +301,7 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
border-bottom: 1px solid $neutral-200;
|
border-bottom: 1px solid $neutral-200;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
.chat-header-title {
|
.chat-header-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -296,10 +323,16 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: $primary-150;
|
background: $primary-150;
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chat-detail-header-infos {
|
.chat-detail-header-infos {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column nowrap;
|
flex-flow: column nowrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@ -318,6 +351,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chat-detail-summary {
|
.chat-detail-summary {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column nowrap;
|
flex-flow: column nowrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@ -334,6 +368,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chat-detail-generate {
|
.chat-detail-generate {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column nowrap;
|
flex-flow: column nowrap;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
@ -372,8 +407,8 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column nowrap;
|
flex-flow: column nowrap;
|
||||||
gap: .875rem;
|
gap: 0.875rem;
|
||||||
box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1F;
|
box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1f;
|
||||||
max-height: 380px;
|
max-height: 380px;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user