Compare commits

...

7 Commits

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>聊天式销售平台</title>
<title>销售平台</title>
</head>
<body>
<div id="root"></div>

@ -229,8 +229,8 @@ const whatsappMsgMapped = {
},
'whatsapp.message.updated': {
getMsg: (result) => {
console.log('getMsg', result);
return isEmpty(result?.whatsappMessage) ? null : { ...result.whatsappMessage, conversationid: result.conversationid };
// console.log('getMsg', result);
return isEmpty(result?.whatsappMessage) ? null : { ...result.whatsappMessage, conversationid: result.conversationid, messageorigin: result.messageorigin };
},
contentToRender: (contentObj) => {
if ((contentObj?.status === 'failed' )) {
@ -290,7 +290,16 @@ export const receivedMsgTypeMapped = {
// 发送消息的同步返回: 发送失败时
getMsg: (result) => result,
contentToRender: () => null,
contentToUpdate: (msgcontent) => ({ ...msgcontent, id: msgcontent.actionId, status: msgcontent?.status || 'failed', dateString: `发送失败 ${msgcontent.error.message}`, conversationid: msgcontent.actionId.split('.')[0], }),
contentToUpdate: (msgcontent) => {
const waCode = msgcontent.error.message.match(/\(#(\d+)\)/);
const waError = whatsappError?.[waCode?.[1]] || msgcontent.error.message;
return {
...msgcontent,
id: msgcontent.actionId,
status: msgcontent?.status || 'failed',
dateString: `发送失败 ${waError} \n[${msgcontent.error.code}] ${whatsappError[msgcontent.error.code]}`,
conversationid: msgcontent.actionId.split('.')[0],
};},
},
};
export const whatsappMsgTypeMapped = {
@ -568,8 +577,9 @@ export const parseRenderMessageList = (messages) => {
});
};
export const whatsappError = {
'131026': '[131026] 消息无法投递(未注册/使用旧版/未同意政策).',
'131047': '[131047] 会话超过24小时.',
'BAD_REQUEST': '无法发送, 请使用邮件联系.',
'131026': '[131026] 消息无法投递(未注册/使用旧版/未同意政策).\n请稍后重试或使用邮件联系',
'131047': '[131047] 会话超过24小时或未激活.',
'131053': '[131053] 文件上传失败.',
'131048': '[131048] 账户被风控.', // 消息发送太多, 达到垃圾数量限制
'131031': '[131031] 账户已锁定.',

@ -16,6 +16,7 @@ import ErrorPage from '@/components/ErrorPage'
import Conversations from '@/views/Conversations/ChatWindow'
import MobileConversation from '@/views/mobile/Conversation'
import MobileChat from '@/views/mobile/Chat'
import MobileHistory from '@/views/mobile/History'
import MobileSecondHeader from '@/views/mobile/SecondHeaderWrapper';
import CustomerProfile from '@/views/Conversations/Components/CustomerProfile';
@ -47,6 +48,7 @@ const router = createBrowserRouter([
element: <MobileSecondHeader />,
children: [
{ path: 'm/order', element: <CustomerProfile /> },
{ path: 'm/history', element: <MobileHistory /> },
],
},
]

@ -106,7 +106,7 @@ const websocketSlice = (set, get) => ({
},
handleMessage: (data) => {
olog('handleMessage------------------');
console.log(data);
// console.log(data);
const { updateMessageItem, sentOrReceivedNewMessage } = get();
const { errcode, errmsg, result } = data;
@ -232,7 +232,7 @@ const messageSlice = (set, get) => ({
})),
updateMessageItem: (message) => {
// msgUpdate
console.log('UPDATE_SENT_MESSAGE_ITEM-----------------------------------------------------------------');
// console.log('UPDATE_SENT_MESSAGE_ITEM-----------------------------------------------------------------', message);
// 更新会话中的消息
const { activeConversations, conversationsList, currentConversation } = get();
const targetId = message.conversationid;
@ -263,7 +263,8 @@ const messageSlice = (set, get) => ({
conversation_expiretime: message?.conversation?.expireTime || conversationsList[targetIndex].conversation_expiretime || '', // 保留使用UTC时间
});
} else if (targetIndex === -1) {
// 当前客户端不存在的会话 todo: 设置为当前(在WhatsApp返回号码不一致时)
// 当前客户端不存在的会话
// todo: 设置为当前(在WhatsApp返回号码不一致时)
newConversations = [{
...conversationRow,
...message,
@ -271,9 +272,9 @@ const messageSlice = (set, get) => ({
opi_sn: currentConversation.opi_sn, // todo: coli sn
last_received_time: message.date,
unread_msg_count: 0,
whatsapp_name: message?.senderName || message?.sender || '',
customer_name: message?.senderName || message?.sender || '',
whatsapp_phone_number: message.from,
whatsapp_name: message.to, //message?.senderName || message?.sender || '',
customer_name: message.to, // message?.senderName || message?.sender || '',
whatsapp_phone_number: message.to,
conversation_expiretime: message?.conversation?.expireTime || '', // 保留使用UTC时间
}];
}

@ -12,6 +12,10 @@ export const useFormStore = create(
setMsgHistorySelectMatch: (msgHistorySelectMatch) => set({ msgHistorySelectMatch }),
msgListParams: {},
setMsgListParams: (msgListParams) => set(state => ({ msgListParams: {...state.msgListParams, ...msgListParams} })),
ImageAlbum: [],
setImageAlbum: (ImageAlbum) => set({ ImageAlbum }),
ImagePreviewSrc: '',
setImagePreviewSrc: (ImagePreviewSrc) => set({ ImagePreviewSrc }),
// 订单跟踪页面
orderFollowForm: {

@ -1,428 +1,38 @@
import { memo, useCallback, useEffect, useRef, useState, forwardRef } from 'react';
import { App, Divider, Button, Input, Layout, DatePicker, Form, List, Spin, Flex, Image } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { ChatItem, MessageBox } from 'react-chat-elements';
import { MESSAGE_PAGE_SIZE, fetchConversationsSearch, fetchMessagesHistory } from '@/actions/ConversationActions';
import { cloneDeep, flush, isEmpty, pick, stringToColour } from '@/utils/utils';
import { useCallback, useState } from 'react';
import { Divider, Layout, Flex, Image } from 'antd';
import useFormStore from '@/stores/FormStore';
import SearchForm from './Conversations/History/SearchForm';
import ConversationsList from './Conversations/History/ConversationsList';
import MessagesMatchList from './Conversations/History/MessagesMatchList';
import MessagesList from './Conversations/History/MessagesList';
import ImageAlbumPreview from './Conversations/History/ImageAlumPreview';
import { fetchSalesAgent, fetchCustomerList } from '@/actions/CommonActions';
import SearchInput from '@/components/SearchInput';
import { isNotEmpty } from '@/utils/commons';
const { Sider, Content } = Layout;
const { Sider, Content, Header, Footer } = Layout;
const { Search } = Input;
const { RangePicker } = DatePicker;
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 100;
// https://media-xsp2-1.cdn.whatsapp.net/v/t61.24694-24/424735646_380563021285029_2962758854250800176_n.jpg?ccb=11-4&oh=01_AdTogiVdUE-ToI9uH-VQKTTLyDbP7bocXUQe1OETOeCgcg&oe=65F7C6AB&_nc_sid=e6ed6c&_nc_cat=104
// eslint-disable-next-line react/display-name
const SearchForm = memo(function ({ initialValues, onSubmit }) {
const [form] = Form.useForm();
function handleSubmit(values) {
const multiAgents = (values?.agent || []).map(ele => ele.value).join(',');
const multiCustomers = (values?.customer || []).map(ele => ele.value).join(',');
onSubmit?.({
...values,
opisn: multiAgents,
whatsapp_id: multiCustomers,
...(isNotEmpty(values.msgDateRange) ? {
from_date: values.msgDateRange[0].format('YYYY-MM-DD'),
end_date: values.msgDateRange[1].format('YYYY-MM-DD'),
} : {}),
});
}
return (
<Form layout={'inline'} form={form} initialValues={initialValues} onFinish={handleSubmit} style={{}}>
<Flex className='w-full'>
<Flex flex={'auto'} wrap='wrap' gap={4}>
<Form.Item label='发送人' name='agent' style={{ width: '200px' }} rules={[{ required: false, message: '请选择发送人' }]}>
<SearchInput placeholder='搜索发送人' fetchOptions={fetchSalesAgent} mode={'tags'} maxTagCount={0} />
</Form.Item>
<Form.Item label='客人' name='customer' style={{ width: '200px' }}>
<SearchInput placeholder='搜索客人' fetchOptions={fetchCustomerList} mode={'tags'} maxTagCount={0} />
</Form.Item>
<Form.Item label='订单号' name='coli_id'>
<Input placeholder='订单号' allowClear />
</Form.Item>
<Form.Item label='关键词' name='search'>
<Input placeholder='关键词' allowClear />
</Form.Item>
<Form.Item label='日期' name='msgDateRange'>
<RangePicker format={'YYYY-MM-DD'} />
</Form.Item>
</Flex>
<div style={{flex: '0 1 64px'}}>
<Form.Item>
<Button type='primary' htmlType='submit'>
搜索
</Button>
</Form.Item>
</div>
</Flex>
</Form>
);
});
function ChatHistory() {
const { message: appMessage } = App.useApp()
// const [formValues, setFormValues] = useState({});
const [formValues, setFormValues] = useFormStore(((state) => [state.chatHistoryForm, state.setChatHistoryForm]));
const [selectedConversation, setSelectedConversation] = useFormStore(((state) => [state.chatHistorySelectChat, state.setChatHistorySelectChat]));
const [selectMatch, setSelectedMatch] = useFormStore(((state) => [state.msgHistorySelectMatch, state.setMsgHistorySelectMatch]));
const [paramsForMsgList, setParamsForMsgList] = useFormStore(((state) => [state.msgListParams, state.setMsgListParams]));
// { opisn, whatsappid, lasttime, pagesize, pagedir }
const Index = (props) => {
const [formValues, setFormValues] = useFormStore((state) => [state.chatHistoryForm, state.setChatHistoryForm]);
const handleSubmit = useCallback((values) => {
setFormValues({ ...values });
}, []);
const [conversationsListLoading, setConversationsListLoading] = useState(false);
const [messageListPreLoading, setMessageListPreLoading] = useState(false);
const [messageListLoading, setMessageListLoading] = useState(false);
const [conversationsList, setConversationsList] = useState([]);
const [chatItemMessages, setChatItemMessages] = useState([]);
const [imageAlbum, setImageAlbum] = useState([]);
// const [paramsForMsgList, setParamsForMsgList] = useState({ loadNextPage: true, loadPrePage: true, }); // { opisn, whatsappid, lasttime, pagesize, pagedir }
// const [selectMatch, setSelectedMatch] = useState({});
const getConversationsList = async () => {
// const allEmpty = Object.values(cloneDeep(formValues)).every((val) => {
// return val === null || val === '' || val === undefined;
// });
// if (allEmpty) return;
setConversationsListLoading(true);
setChatItemMessages([]);
setParamsForMsgList({});
setSelectedMatch({});
const params = flush(pick(formValues, ['opisn', 'whatsapp_id', 'search', 'from_date', 'end_date', 'coli_id']));
const data = await fetchConversationsSearch(params);
setConversationsListLoading(false);
setConversationsList(data);
if (data.length === 1) {
setSelectedConversation(data[0]);
}
};
const getMessagesPre = async (chatItem) => {
setMessageListPreLoading(true);
const data = await fetchMessagesHistory({ ...chatItem, lasttime: chatItem.pretime, pagedir: 'pre', pagesize: BIG_PAGE_SIZE });
setMessageListPreLoading(false);
setChatItemMessages(prevValue => data.concat(prevValue));
const loadPrePage = !(data.length === 0 || data.length < BIG_PAGE_SIZE);
setParamsForMsgList({ loadPrePage });
};
const getMessagesNext = async (chatItem) => {
setMessageListLoading(true);
const data = await fetchMessagesHistory({...chatItem, pagedir: 'next', pagesize: BIG_PAGE_SIZE });
setMessageListLoading(false);
setChatItemMessages(prevValue => prevValue.concat(data));
const loadNextPage = !(data.length === 0 || data.length < BIG_PAGE_SIZE);
setParamsForMsgList({loadNextPage});
};
// ,
const scrollToSelectedMessage = (selected) => {
const findIndex = chatItemMessages.findIndex(item => item.id === selected.id);
if (findIndex !== -1) {
scrollToMessage(selected.id, findIndex);
}
}
const handleMatchMsgClick = async (selected) => {
const findIndex = chatItemMessages.findIndex(item => item.id === selected.id);
if (findIndex === -1) {
setMessageListLoading(true);
await getMessagesPre({...paramsForMsgList });
setMessageListLoading(false);
}
setSelectedMatch(selected);
}
// ,
useEffect(() => {
if (selectMatch.sn) {
scrollToSelectedMessage(selectMatch);
}
return () => {};
}, [selectMatch.sn]);
// ,
useEffect(() => {
setChatItemMessages([]);
setParamsForMsgList({});
setSelectedMatch({});
if (isEmpty(selectedConversation.conversationid) || isEmpty(selectedConversation.opi_sn)) {
return () => {};
}
const firstActionPageParams = { opisn: selectedConversation.opi_sn, whatsappid: selectedConversation.whatsapp_phone_number };
if (isEmpty(selectedConversation.matchMsgList)) {
firstActionPageParams.loadPrePage = false;
firstActionPageParams.loadNextPage = true;
} else {
firstActionPageParams.pretime = selectedConversation.matchMsgList[0].orgmsgtime;
firstActionPageParams.lasttime = selectedConversation.matchMsgList[0].orgmsgtime;
firstActionPageParams.loadPrePage = true;
firstActionPageParams.loadNextPage = true;
}
setParamsForMsgList(firstActionPageParams);
async function getFirstNext() {
await getMessagesNext(firstActionPageParams);
}
async function getFirstPre() {
await getMessagesPre(firstActionPageParams);
}
// getFirstPre();
getFirstNext();
return () => {};
}, [selectedConversation.conversationid]);
// ,
useEffect(() => {
if (chatItemMessages.length > 0) {
setParamsForMsgList({pretime: chatItemMessages[0].orgmsgtime, lasttime: chatItemMessages[chatItemMessages.length - 1].orgmsgtime });
setImageAlbum(chatItemMessages.filter(ele => ele.whatsapp_msg_type === 'image').map(ele => ele.data.uri));
}
return () => {};
}, [chatItemMessages])
const onLoadMore = () => {
getMessagesNext(paramsForMsgList);
// window.dispatchEvent(new Event('resize'));
};
const loadMore = !messageListLoading && paramsForMsgList.loadNextPage ? (
<div className='text-center pt-3 mb-3 h-8 leading-8 border-dotted border-0 border-t border-slate-300'>
<Button onClick={onLoadMore}>load more</Button>
</div>
) : null;
const onLoadMorePre = () => {
getMessagesPre(paramsForMsgList);
// window.dispatchEvent(new Event('resize'));
};
const loadMorePre =
paramsForMsgList.loadPrePage && chatItemMessages.length > 0 ? (
<div className='text-center h-8 leading-8 '>
{messageListPreLoading ? <LoadingOutlined className='text-primary' /> : <Button onClick={onLoadMorePre}>load more previous </Button>}
</div>
) : null;
useEffect(() => {
getConversationsList();
return () => {};
}, [formValues]);
const RenderText = memo(function renderText({ str, className }) {
const parts = str.split(/(https?:\/\/[^\s]+|\p{Emoji_Presentation})/gmu).filter((s) => s !== '');
const links = str.match(/https?:\/\/[\S]+/gi) || [];
const emojis = str.match(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g) || [];
const extraClass = isEmpty(emojis) ? '' : '';
const objArr = parts.reduce((prev, curr, index) => {
if (links.includes(curr)) {
prev.push({ type: 'link', key: curr });
} else if (emojis.includes(curr)) {
prev.push({ type: 'emoji', key: curr });
} else {
prev.push({ type: 'text', key: curr });
}
return prev;
}, []);
return (
<span className={`text-sm leading-5 emoji-text ${className} ${extraClass} `} key={'msg-text'}>
{(objArr || []).map((part, index) => {
if (part.type === 'link') {
return (
<a href={part.key} target='_blank' key={`${part.key}${index}`} rel='noreferrer' className='text-sm'>
{part.key}
</a>
);
} else {
return part.key;
}
})}
</span>
);
});
const [previewVisible, setPreviewVisible] = useState(false);
const [previewSrc, setPreviewSrc] = useState();
const [previewIndex, setPreviewIndex] = useState();
const onPreviewClose = () => {
setPreviewSrc('');
setPreviewVisible(false);
};
const handlePreview = (msg) => {
switch (msg.whatsapp_msg_type) {
case 'image':
setPreviewVisible(true);
setPreviewSrc(msg.data.uri);
setPreviewIndex(imageAlbum.findIndex((url) => url === msg.data.uri));
return false;
case 'document':
window.open(msg.data.link || msg.data.uri, '_blank', 'noopener,noreferrer');
return false;
default:
return false;
}
};
const handlePreviewItem = (msg) => {
switch (msg.whatsapp_msg_type) {
case 'image':
// eslint-disable-next-line no-fallthrough
case 'document':
window.open(msg.data.link || msg.data.uri, '_blank', 'noopener,noreferrer');
return false;
default:
return false;
}
};
const messagesEndRef = useRef(null);
const messageRefs = useRef([]);
const [focusMsg, setFocusMsg] = useState('');
const scrollToMessage = (id, index) => {
const _i = index || chatItemMessages.findIndex((msg) => msg.id === id);
if (_i >= 0) {
messageRefs.current[_i].scrollIntoView({ behavior: 'smooth', block: 'start' });
setFocusMsg(id);
}
};
// eslint-disable-next-line react/display-name
const MessageBoxWithRef = forwardRef((props, ref) => (
<li ref={ref} >
<MessageBox {...props} />
</li>
));
return (
<>
<SearchForm onSubmit={handleSubmit} initialValues={formValues} />
<Divider plain orientation='left' className='mb-0'></Divider>
<Layout hasSider className='h-screen chathistory-wrapper chatwindow-wrapper' style={{ maxHeight: 'calc(100% - 279px)', height: 'calc(100% - 279px)' }}>
<Sider width={240} theme={'light'} className='h-full overflow-y-auto overflow-x-hidden' style={{ maxHeight: 'calc(100vh - 279px)', height: 'calc(100vh - 279px)' }}>
<Spin spinning={conversationsListLoading}>
{conversationsList.map((item) => (
<ChatItem
{...item}
key={item.conversationid}
id={item.conversationid}
letterItem={{ id: item.whatsapp_name || item.whatsapp_phone_number, letter: (item.whatsapp_name || item.whatsapp_phone_number).split(' ')[0] }}
alt={`${item.whatsapp_name}`}
title={item.whatsapp_name || item.whatsapp_phone_number}
subtitle={`${item.OPI_Name || ''} ${item.coli_id || ''}`}
date={item.last_received_time || item.last_send_time}
// dateString={item.last_received_time}
dateString={item.dateText}
className={String(item.conversationid) === String(selectedConversation.conversationid) ? '__active text-primary bg-neutral-100' : ''}
onClick={() => setSelectedConversation(item)}
/>
))}
</Spin>
<ConversationsList />
</Sider>
<Content style={{ maxHeight: 'calc(100vh - 279px)', height: 'calc(100vh - 279px)', minWidth: '360px' }}>
<Flex className='h-full relative'>
{(selectedConversation.matchMsgList || []).length > 0 && isNotEmpty(formValues.search) && (
<div className='w-80 overflow-y-auto overflow-x-hidden'>
<p className='text-center'><mark>{formValues.search}</mark> 的相关记录, 点击定位上下文</p>
{selectedConversation.matchMsgList.map((item) => (
<ChatItem
{...item}
key={item.sn}
id={item.sn}
letterItem={{
id: item.sender === 'me' ? selectedConversation.OPI_Name || item.senderName : item.senderName,
letter: (item.sender === 'me' ? selectedConversation.OPI_Name || item.senderName : item.senderName).split(' ')[0],
}}
alt={`${item.senderName}`}
title={item.sender === 'me' ? selectedConversation.OPI_Name || item.senderName : item.senderName}
subtitle={item.originText}
date={item.msgtime}
dateString={item.dateText}
className={String(item.sn) === String(selectMatch?.sn) ? '__active text-primary bg-neutral-100' : ' bg-white'}
onClick={() => handleMatchMsgClick(item)}
/>
))}
</div>
)}
<div className='h-full relative flex-1 border-dashed border-y-0 border-r-0 border-l border-slate-200' ref={messagesEndRef}>
<List
loading={messageListLoading}
header={loadMorePre}
loadMore={loadMore}
className='h-full overflow-y-auto px-2 relative'
itemLayout='vertical'
dataSource={chatItemMessages}
renderItem={(message, index) => (
// <List.Item>
// <List.Item.Meta avatar={<Avatar src={item.avatarUrl} />} title={item.title} description={item.msgTime} />
// <div>{item.content}</div>
// </List.Item>
<MessageBoxWithRef
ref={(el) => (messageRefs.current[index] = el)}
key={message.id}
{...message}
// position={message.sender === 'me' ? 'right' : 'left'}
position={'left'}
replyButton={false}
onReplyMessageClick={() => scrollToMessage(message.reply.id)}
onOpen={() => handlePreview(message)}
onTitleClick={() => handlePreview(message)}
notch={false}
title={message.whatsapp_msg_type === 'text' ? '' : message.title}
text={<RenderText str={message?.text || ''} className={message.status === 'failed' ? 'line-through text-neutral-400' : ''} />}
copiableDate={true}
dateString={message.dateString || message.localDate}
className={[
'whitespace-pre-wrap mb-2',
message.whatsapp_msg_type === 'sticker' ? 'bg-transparent' : '',
message.sender === 'me' ? 'whatsappme-container' : '',
focusMsg === message.id ? 'message-box-focus' : '',
message.status === 'failed' ? 'failed-msg' : '',
].join(' ')}
style={{
backgroundColor: message.sender === 'me' ? '#ccd4ae' : '#fff',
}}
{...(message.type === 'meetingLink'
? {
actionButtons: [
{
onClickButton: () => {
navigator.clipboard.writeText(message.text);
appMessage.success('复制成功😀');
},
Component: () => <div key='copy'>复制</div>,
},
],
}
: {})}
renderAddCmp={
<div key={'msg-prefix'} className='border-dashed border-0 border-t border-slate-300 text-slate-600 space-x-2 emoji'>
<span
className={`p-1 rounded-b ${message.msg_direction === 'outbound' ? 'text-white' : ''} `}
style={{ backgroundColor: message.msg_direction === 'outbound' ? stringToColour(message.senderName) : 'unset' }}>
{message.msg_direction === 'outbound' ? selectedConversation.OPI_Name : message.senderName}
</span>
<span>{message.dateString || message.localDate}</span>
<span>{message.statusCN}</span>
</div>
}
// date={null}
// status={null}
/>
)}
/>
</div>
<MessagesMatchList />
<MessagesList />
</Flex>
{/* <Image width={0} height={0} src={null} preview={{ visible: previewVisible, src: previewSrc, onClose: onPreviewClose }} /> */}
<Image.PreviewGroup items={imageAlbum} preview={{ current: previewIndex, visible: previewVisible, onClose: onPreviewClose, onChange: setPreviewIndex }} />
<ImageAlbumPreview />
</Content>
</Layout>
</>
);
}
export default ChatHistory;
};
export default Index;

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { Spin } from 'antd';
import { ChatItem } from 'react-chat-elements';
import useFormStore from '@/stores/FormStore';
import { flush, pick } from '@/utils/utils';
import { fetchConversationsSearch } from '@/actions/ConversationActions';
const ConversationsList = ({ ...props }) => {
const [formValues,] = useFormStore(((state) => [state.chatHistoryForm,]));
const [selectedConversation, setSelectedConversation] = useFormStore((state) => [state.chatHistorySelectChat, state.setChatHistorySelectChat]);
const [conversationsListLoading, setConversationsListLoading] = useState(false);
const [conversationsList, setConversationsList] = useState([]);
useEffect(() => {
getConversationsList();
return () => {};
}, [formValues]);
const getConversationsList = async () => {
setConversationsListLoading(true);
const params = flush(pick(formValues, ['opisn', 'whatsapp_id', 'search', 'from_date', 'end_date', 'coli_id']));
const data = await fetchConversationsSearch(params);
setConversationsListLoading(false);
setConversationsList(data);
if (data.length === 1) {
setSelectedConversation(data[0]);
}
};
return (
<>
<Spin spinning={conversationsListLoading}>
{conversationsList.map((item) => (
<ChatItem
{...item}
key={item.conversationid}
id={item.conversationid}
letterItem={{ id: item.whatsapp_name || item.whatsapp_phone_number, letter: (item.whatsapp_name || item.whatsapp_phone_number).split(' ')[0] }}
alt={`${item.whatsapp_name}`}
title={item.whatsapp_name || item.whatsapp_phone_number}
subtitle={`${item.OPI_Name || ''} ${item.coli_id || ''}`}
date={item.last_received_time || item.last_send_time}
// dateString={item.last_received_time}
dateString={item.dateText}
className={String(item.conversationid) === String(selectedConversation.conversationid) ? '__active text-primary bg-neutral-100' : ''}
onClick={() => setSelectedConversation(item)}
/>
))}
</Spin>
</>
);
};
export default ConversationsList;

@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { Image } from 'antd';
import useFormStore from '@/stores/FormStore';
const ImageAlbumPreview = (props) => {
const [ImageAlbum,] = useFormStore((state) => [state.ImageAlbum, ]);
const [ImagePreviewSrc, setImagePreviewSrc] = useFormStore((state) => [state.ImagePreviewSrc, state.setImagePreviewSrc]);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewIndex, setPreviewIndex] = useState();
const onPreviewClose = () => {
setImagePreviewSrc('');
setPreviewVisible(false);
};
useEffect(() => {
if (ImagePreviewSrc) {
setPreviewVisible(true);
setPreviewIndex(ImageAlbum.findIndex((url) => url === ImagePreviewSrc));
}
return () => {};
}, [ImagePreviewSrc]);
return (
<>
<Image.PreviewGroup items={ImageAlbum} preview={{ current: previewIndex, visible: previewVisible, onClose: onPreviewClose, onChange: setPreviewIndex }} />
</>
);
};
export default ImageAlbumPreview;

@ -0,0 +1,268 @@
import { useRef, useEffect, useState, forwardRef, memo } from 'react';
import { App, Flex, List, Button, } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { MessageBox } from 'react-chat-elements';
import { MESSAGE_PAGE_SIZE, fetchMessagesHistory } from '@/actions/ConversationActions';
import useFormStore from '@/stores/FormStore';
import { isEmpty, stringToColour } from '@/utils/utils';
import { useShallow } from 'zustand/react/shallow';
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 100;
const MessagesList = ({ ...props }) => {
const { message: appMessage } = App.useApp();
const [formValues] = useFormStore((state) => [state.chatHistoryForm]);
const [selectedConversation] = useFormStore((state) => [state.chatHistorySelectChat]);
const [paramsForMsgList, setParamsForMsgList] = useFormStore((state) => [state.msgListParams, state.setMsgListParams]);
const [selectMatch, setSelectedMatch] = useFormStore((state) => [state.msgHistorySelectMatch, state.setMsgHistorySelectMatch]);
const [setImageAlbumList, setImagePreviewSrc] = useFormStore(useShallow((state) => [state.setImageAlbum, state.setImagePreviewSrc]));
const [chatItemMessages, setChatItemMessages] = useState([]);
const [messageListPreLoading, setMessageListPreLoading] = useState(false);
const [messageListLoading, setMessageListLoading] = useState(false);
const getMessagesPre = async (chatItem) => {
setMessageListPreLoading(true);
const data = await fetchMessagesHistory({ ...chatItem, lasttime: chatItem.pretime, pagedir: 'pre', pagesize: BIG_PAGE_SIZE });
setMessageListPreLoading(false);
setChatItemMessages((prevValue) => data.concat(prevValue));
const loadPrePage = !(data.length === 0 || data.length < BIG_PAGE_SIZE);
setParamsForMsgList({ loadPrePage });
};
const getMessagesNext = async (chatItem) => {
setMessageListLoading(true);
const data = await fetchMessagesHistory({ ...chatItem, pagedir: 'next', pagesize: BIG_PAGE_SIZE });
setMessageListLoading(false);
setChatItemMessages((prevValue) => prevValue.concat(data));
const loadNextPage = !(data.length === 0 || data.length < BIG_PAGE_SIZE);
setParamsForMsgList({ loadNextPage });
};
// ,
const scrollToSelectedMessage = (selected) => {
const findIndex = chatItemMessages.findIndex((item) => item.id === selected.id);
if (findIndex !== -1) {
scrollToMessage(selected.id, findIndex);
}
};
// ,
useEffect(() => {
setChatItemMessages([]);
setParamsForMsgList({});
setSelectedMatch({});
if (isEmpty(selectedConversation.conversationid) || isEmpty(selectedConversation.opi_sn)) {
return () => {};
}
const firstActionPageParams = { opisn: selectedConversation.opi_sn, whatsappid: selectedConversation.whatsapp_phone_number, loadNextPage: true };
// if (isEmpty(selectedConversation.matchMsgList)) {
if (!isEmpty(formValues?.from_date)) {
firstActionPageParams.lasttime = formValues.from_date;
firstActionPageParams.loadPrePage = true;
}
if (!isEmpty(formValues?.search)) {
firstActionPageParams.pretime = selectedConversation.matchMsgList[0].orgmsgtime;
firstActionPageParams.lasttime = selectedConversation.matchMsgList[0].orgmsgtime;
firstActionPageParams.loadPrePage = true;
}
setParamsForMsgList(firstActionPageParams);
async function getFirstNext() {
await getMessagesNext(firstActionPageParams);
}
getFirstNext();
return () => {};
}, [selectedConversation.conversationid]);
// ,
useEffect(() => {
async function getFirstPre(_params) {
await getMessagesPre(_params);
}
const findIndex = chatItemMessages.findIndex((item) => item.id === selectMatch.id);
if (findIndex === -1) {
setMessageListLoading(true);
getFirstPre({ ...paramsForMsgList });
setMessageListLoading(false);
}
scrollToSelectedMessage(selectMatch);
return () => {};
}, [selectMatch.id]);
// ,
useEffect(() => {
if (chatItemMessages.length > 0) {
setParamsForMsgList({ pretime: chatItemMessages[0].orgmsgtime, lasttime: chatItemMessages[chatItemMessages.length - 1].orgmsgtime });
const album = chatItemMessages.filter((ele) => ele.whatsapp_msg_type === 'image').map((ele) => ele.data.uri);
setImageAlbumList(album);
}
return () => {};
}, [chatItemMessages]);
const onLoadMore = () => {
getMessagesNext(paramsForMsgList);
// window.dispatchEvent(new Event('resize'));
};
const loadMore =
!messageListLoading && paramsForMsgList.loadNextPage ? (
<div className='text-center pt-3 mb-3 h-8 leading-8 border-dotted border-0 border-t border-slate-300'>
<Button onClick={onLoadMore}>load more</Button>
</div>
) : null;
const onLoadMorePre = () => {
getMessagesPre(paramsForMsgList);
// window.dispatchEvent(new Event('resize'));
};
const loadMorePre =
paramsForMsgList.loadPrePage && chatItemMessages.length > 0 ? (
<div className='text-center h-8 leading-8 '>
{messageListPreLoading ? <LoadingOutlined className='text-primary' /> : <Button onClick={onLoadMorePre}>load more previous </Button>}
</div>
) : null;
const messagesEndRef = useRef(null);
const messageRefs = useRef([]);
const [focusMsg, setFocusMsg] = useState('');
const scrollToMessage = (id, index) => {
const _i = index || chatItemMessages.findIndex((msg) => msg.id === id);
if (_i >= 0) {
messageRefs.current[_i].scrollIntoView({ behavior: 'smooth', block: 'start' });
setFocusMsg(id);
}
};
const RenderText = memo(function renderText({ str, className }) {
const parts = str.split(/(https?:\/\/[^\s]+|\p{Emoji_Presentation})/gmu).filter((s) => s !== '');
const links = str.match(/https?:\/\/[\S]+/gi) || [];
const emojis = str.match(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g) || [];
const extraClass = isEmpty(emojis) ? '' : '';
const objArr = parts.reduce((prev, curr, index) => {
if (links.includes(curr)) {
prev.push({ type: 'link', key: curr });
} else if (emojis.includes(curr)) {
prev.push({ type: 'emoji', key: curr });
} else {
prev.push({ type: 'text', key: curr });
}
return prev;
}, []);
return (
<span className={`text-sm leading-5 emoji-text ${className} ${extraClass} `} key={'msg-text'}>
{(objArr || []).map((part, index) => {
if (part.type === 'link') {
return (
<a href={part.key} target='_blank' key={`${part.key}${index}`} rel='noreferrer' className='text-sm'>
{part.key}
</a>
);
} else {
return part.key;
}
})}
</span>
);
});
// eslint-disable-next-line react/display-name
const MessageBoxWithRef = forwardRef((props, ref) => (
<li ref={ref}>
<MessageBox {...props} />
</li>
));
const handlePreview = (msg) => {
switch (msg.whatsapp_msg_type) {
case 'image':
setImagePreviewSrc(msg.data.uri);
return false;
case 'document':
window.open(msg.data.link || msg.data.uri, '_blank', 'noopener,noreferrer');
return false;
default:
return false;
}
};
return (
<>
<Flex vertical className='flex-1'>
<div className='px-2 py-1 text-primary bg-white border-0 border-b border-slate-200'>
{selectedConversation.whatsapp_name} {selectedConversation.whatsapp_phone_number}
</div>
<div style={{ height: 'calc(100% - 30px)' }} className='h-full flex-auto relative border-dashed border-y-0 border-r-0 border-l border-slate-200' ref={messagesEndRef}>
<List
loading={messageListLoading}
header={loadMorePre}
loadMore={loadMore}
className='h-full overflow-y-auto px-2 relative'
itemLayout='vertical'
dataSource={chatItemMessages}
renderItem={(message, index) => (
// <List.Item>
// <List.Item.Meta avatar={<Avatar src={item.avatarUrl} />} title={item.title} description={item.msgTime} />
// <div>{item.content}</div>
// </List.Item>
<MessageBoxWithRef
ref={(el) => (messageRefs.current[index] = el)}
key={message.id}
{...message}
// position={message.sender === 'me' ? 'right' : 'left'}
position={'left'}
replyButton={false}
onReplyMessageClick={() => scrollToMessage(message.reply.id)}
onOpen={() => handlePreview(message)}
onTitleClick={() => handlePreview(message)}
notch={false}
title={message.whatsapp_msg_type === 'text' ? '' : message.title}
text={<RenderText str={message?.text || ''} className={message.status === 'failed' ? 'line-through text-neutral-400' : ''} />}
copiableDate={true}
dateString={message.dateString || message.localDate}
className={[
'whitespace-pre-wrap mb-2',
message.whatsapp_msg_type === 'sticker' ? 'bg-transparent' : '',
message.sender === 'me' ? 'whatsappme-container' : '',
focusMsg === message.id ? 'message-box-focus' : '',
message.status === 'failed' ? 'failed-msg' : '',
].join(' ')}
style={{
backgroundColor: message.sender === 'me' ? '#ccd4ae' : '#fff',
}}
{...(message.type === 'meetingLink'
? {
actionButtons: [
{
onClickButton: () => {
navigator.clipboard.writeText(message.text);
appMessage.success('复制成功😀');
},
Component: () => <div key='copy'>复制</div>,
},
],
}
: {})}
renderAddCmp={
<div key={'msg-prefix'} className='border-dashed border-0 border-t border-slate-300 text-slate-600 space-x-2 emoji'>
<span
className={`p-1 rounded-b ${message.msg_direction === 'outbound' ? 'text-white' : ''} `}
style={{ backgroundColor: message.msg_direction === 'outbound' ? stringToColour(message.senderName) : 'unset' }}>
{message.msg_direction === 'outbound' ? selectedConversation.OPI_Name : message.senderName}
</span>
<span>{message.dateString || message.localDate}</span>
<span>{message.statusCN}</span>
</div>
}
// date={null}
// status={null}
/>
)}
/>
</div>
</Flex>
</>
);
};
export default MessagesList;

@ -0,0 +1,40 @@
import { ChatItem } from 'react-chat-elements';
import useFormStore from '@/stores/FormStore';
import { isNotEmpty } from '@/utils/commons';
const MessagesMatchList = ({ ...props }) => {
const [formValues] = useFormStore((state) => [state.chatHistoryForm]);
const [selectedConversation] = useFormStore((state) => [state.chatHistorySelectChat]);
const [selectMatch, setSelectedMatch] = useFormStore((state) => [state.msgHistorySelectMatch, state.setMsgHistorySelectMatch]);
return (
<>
{(selectedConversation.matchMsgList || []).length > 0 && isNotEmpty(formValues.search) && (
<div className='w-80 overflow-y-auto overflow-x-hidden'>
<p className='text-center'>
<mark>{formValues.search}</mark> 的相关记录, 点击定位上下文
</p>
{selectedConversation.matchMsgList.map((item) => (
<ChatItem
{...item}
key={item.sn}
id={item.sn}
letterItem={{
id: item.sender === 'me' ? selectedConversation.OPI_Name || item.senderName : item.senderName,
letter: (item.sender === 'me' ? selectedConversation.OPI_Name || item.senderName : item.senderName).split(' ')[0],
}}
alt={`${item.senderName}`}
title={item.sender === 'me' ? selectedConversation.OPI_Name || item.senderName : item.senderName}
subtitle={item.originText}
date={item.msgtime}
dateString={item.dateText}
className={String(item.sn) === String(selectMatch?.sn) ? '__active text-primary bg-neutral-100' : ' bg-white'}
onClick={() => setSelectedMatch(item)}
/>
))}
</div>
)}
</>
);
};
export default MessagesMatchList;

@ -0,0 +1,64 @@
/* eslint-disable react/display-name */
import { memo } from 'react';
import { Form, Flex, Input, Button, DatePicker } from 'antd';
import SearchInput from '@/components/SearchInput';
import { isNotEmpty } from '@/utils/commons';
import { fetchSalesAgent, fetchCustomerList } from '@/actions/CommonActions';
const { RangePicker } = DatePicker;
const SearchForm = memo(function ({ initialValues, onSubmit, onReset }) {
const [form] = Form.useForm();
function handleSubmit(values) {
const multiAgents = (values?.agent || []).map((ele) => ele.value).join(',');
const multiCustomers = (values?.customer || []).map((ele) => ele.value).join(',');
onSubmit?.({
...values,
opisn: multiAgents,
whatsapp_id: multiCustomers,
...(isNotEmpty(values.msgDateRange)
? {
from_date: values.msgDateRange[0].format('YYYY-MM-DD'),
end_date: values.msgDateRange[1].format('YYYY-MM-DD'),
}
: {}),
});
}
return (
<Form layout={'inline'} form={form} initialValues={initialValues} onFinish={handleSubmit} style={{}}>
<Flex className='w-full'>
<Flex flex={'auto'} wrap='wrap' gap={4}>
<Form.Item label='发送人' name='agent' style={{ width: '200px' }} rules={[{ required: false, message: '请选择发送人' }]}>
<SearchInput placeholder='搜索发送人' fetchOptions={fetchSalesAgent} mode={'tags'} maxTagCount={0} />
</Form.Item>
<Form.Item label='客人' name='customer' style={{ width: '200px' }}>
<SearchInput placeholder='搜索客人' fetchOptions={fetchCustomerList} mode={'tags'} maxTagCount={0} />
</Form.Item>
<Form.Item label='订单号' name='coli_id'>
<Input placeholder='订单号' allowClear />
</Form.Item>
<Form.Item label='关键词' name='search'>
<Input placeholder='关键词' allowClear />
</Form.Item>
<Form.Item label='日期' name='msgDateRange'>
<RangePicker format={'YYYY-MM-DD'} />
</Form.Item>
</Flex>
<div style={{ flex: '0 1 64px' }} className='flex justify-between'>
{/* <Button
onClick={() => {
form.resetFields();
if (typeof onReset === 'function') {
onReset();
}
}}>
重置
</Button> */}
<Button type='primary' htmlType='submit'>
搜索
</Button>
</div>
</Flex>
</Form>
);
});
export default SearchForm;

@ -4,7 +4,7 @@ import AppLogo from '@/assets/logo-gh.png';
import { useThemeContext } from '@/stores/ThemeContext';
import useAuthStore from '@/stores/AuthStore';
import { Col, Layout, Row, Typography, theme, Space, Avatar, Dropdown, Flex } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import { DownOutlined, FileSearchOutlined } from '@ant-design/icons';
import { NavLink, Outlet, Link } from 'react-router-dom';
const { Header, Footer, Content } = Layout;
const { Title } = Typography;
@ -46,10 +46,13 @@ function MobileApp() {
<Layout>
<Header className='header px-2' style={{ position: 'sticky', top: 0, zIndex: 1, width: '100%', background: 'white' }}>
<Flex justify={'space-between'}>
<Flex gap={8}>
<NavLink to='/'>
<img src={AppLogo} className='logo' alt='App logo' />
{!('Notification' in window) && <span>🔕</span>}
</NavLink>
<NavLink to={'/m/history'} className={'text-primary'}><FileSearchOutlined className='pr-1' />历史</NavLink>
</Flex>
<Dropdown
menu={{
items: [

@ -0,0 +1,31 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { Divider, Layout } from 'antd';
import useFormStore from '@/stores/FormStore';
import SearchForm from '@/views/Conversations/History/SearchForm';
import ConversationsList from '@/views/Conversations/History/ConversationsList';
const { Sider, Content, Header } = Layout;
const History = (props) => {
const [formValues, setFormValues] = useFormStore((state) => [state.chatHistoryForm, state.setChatHistoryForm]);
const handleSubmit = useCallback((values) => {
setFormValues({ ...values });
}, []);
return (
<div className='chathistory-wrapper chatwindow-wrapper'>
<SearchForm onSubmit={handleSubmit} initialValues={formValues} />
<ConversationsList />
{/* <Layout hasSider className='h-screen chathistory-wrapper chatwindow-wrapper' style={{ maxHeight: 'calc(100% - 279px)', height: 'calc(100% - 279px)' }}>
<Header
className='header px-2 h-8 border-0 border-b border-neutral-200 border-solid '
style={{ position: 'sticky', top: 0, zIndex: 1, width: '100%', background: 'white' }}>
<SearchForm onSubmit={handleSubmit} initialValues={formValues} />
</Header>
<Content style={{ maxHeight: 'calc(100vh - 279px)', height: 'calc(100vh - 279px)', minWidth: '360px' }}>
<ConversationsList />
</Content>
</Layout> */}
</div>
);
};
export default History;

@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { NavLink, Outlet, Link, useNavigate } from 'react-router-dom';
import { Layout, Button, Flex, theme } from 'antd';
import { LeftOutlined, HomeOutlined } from '@ant-design/icons';
import { LeftOutlined, HomeOutlined, FileSearchOutlined } from '@ant-design/icons';
const { Content, Header } = Layout;
const HeaderWrapper = ({ children, ...props }) => {
@ -17,8 +17,11 @@ const HeaderWrapper = ({ children, ...props }) => {
style={{ position: 'sticky', top: 0, zIndex: 1, width: '100%', background: 'white' }}>
<Flex justify={'space-between'} align={'center'}>
<Button onClick={() => navigate(-1)} type='link' icon={<LeftOutlined />} />
<Flex gap={8}>
{/* <Button onClick={() => navigate('/m/history', { replace: true })} type='link' icon={<FileSearchOutlined />} /> */}
<Button onClick={() => navigate('/', { replace: true })} type='link' icon={<HomeOutlined />} />
</Flex>
</Flex>
</Header>
<Content className='' style={{ backgroundColor: colorBgContainer }}>
<Outlet />

Loading…
Cancel
Save