Merge branch 'main' of github.com:hainatravel/global-sales

hotfix/new-conversation
Jimmy Liow 1 year ago
commit 1fd2a13a40

@ -19,3 +19,6 @@ npm version minor
[聊天式销售平台需求文档](https://www.kdocs.cn/l/calaUjgmCmDA?from=docs&reqtype=kdocs&startTime=1703645330177&createDirect=true&newFile=true)
## vonage语音视频
安装模块 npm i @vonage/client-sdk

@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"@vonage/client-sdk": "^1.6.0",
"antd": "^5.14.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.10",
@ -35,6 +36,7 @@
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"@vonage/client-sdk": "^1.6.0",
"vite": "^4.5.1",
"vite-plugin-css-modules": "^0.0.1",
"vite-plugin-windicss": "^1.9.3",

@ -411,9 +411,11 @@ export const whatsappMsgTypeMapped = {
type: 'audio',
data: (msg) => ({
id: msg.wamid,
audioProps: { preload: 'auto' },
data: {
audioURL: msg.audio.link,
audioType: msg.audio?.mime_type || 'audio/ogg',
controlsList: 'nofullscreen',
},
}),
renderForReply: (msg) => ({
@ -607,10 +609,12 @@ export const parseRenderMessageList = (messages) => {
export const whatsappError = {
'BAD_REQUEST': ' ',
'PARAM_INVALID': '参数错误, 请联系技术组',
'INTERNAL_SERVER_ERROR': '无法连接WhatsApp.\n请稍重试',
'2': '无法连接WhatsApp.\n请稍候重试', // (#2) Service temporarily unavailable
'INTERNAL_SERVER_ERROR': '无法连接WhatsApp.\n请稍重试',
'2': '[2] 无法连接WhatsApp.\n请稍后重试', // (#2) Service temporarily unavailable
'INVALID_PHONE_NUMBER': '无效号码, 请修正号码后重新从订单进入会话',
'100': '参数错误, 请联系技术组',
'FORBIDDEN': '[FORBIDDEN] ',
'4': '[4] 无法连接WhatsApp.\n请稍后重试', // (#4) Application request limit reached
'131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用邮件联系',
'131047': '[131047] 会话未激活. \n请使用模板消息💬发送',
'131053': '[131053] 文件上传失败.',

@ -6,6 +6,7 @@
export const API_HOST = 'https://p9axztuwd7x8a7.mycht.cn/whatsapp_server';
export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_server'; // prod:
export const VONAGE_URL = 'https://p9axztuwd7x8a7.mycht.cn/vonage-server'; // 语音和视频接口:
export const DATE_FORMAT = 'YYYY-MM-DD';
export const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';

@ -16,6 +16,7 @@ import ErrorPage from '@/components/ErrorPage'
import ChatWindow from '@/views/ChatWindow'
import MobileConversation from '@/views/mobile/Conversation'
import MobileChat from '@/views/mobile/Chat'
import CallCenter from '@/views/CallCenter'
import MobileSecondHeader from '@/views/mobile/SecondHeaderWrapper';
import CustomerProfile from '@/views/Conversations/Online/order/CustomerProfile';
@ -52,6 +53,8 @@ const router = createBrowserRouter([
element: <MobileSecondHeader />,
children: [
{ path: 'm/order', element: <CustomerProfile /> },
{ path: 'callcenter/call', element: <CallCenter /> },
{ path: 'callcenter/call/:phonenumber', element: <CallCenter /> },
],
},
]
@ -68,6 +71,8 @@ const router = createBrowserRouter([
{ path: 'account/profile', element: <AccountProfile /> },
{ path: 'chat/unassign/:whatsappid', element: <ChatAssign /> },
{ path: 'chat/unassign', element: <Unassign /> },
{ path: 'callcenter/call', element: <CallCenter /> },
{ path: 'callcenter/call/:phonenumber', element: <CallCenter /> },
],
},
],

@ -0,0 +1,104 @@
import { create } from "zustand";
import { VonageClient } from "@vonage/client-sdk";
import { fetchJSON } from "@/utils/request";
import { prepareUrl, isNotEmpty } from "@/utils/commons";
import { VONAGE_URL, DATETIME_FORMAT } from "@/config";
import dayjs from "dayjs";
const callCenterStore = create((set, get) => ({
client: new VonageClient({ apiUrl: "https://api-ap-3.vonage.com", websocketUrl: "wss://ws-ap-3.vonage.com" }),
call_id: 0,
loading: false,
logs: "",
//初始化 Vonage
init_vonage: user_id => {
const { client, log } = get();
set({ loading: true });
const fetchUrl = prepareUrl(VONAGE_URL + "/jwt")
.append("user_id", user_id)
.build();
return fetchJSON(fetchUrl).then(json => {
if (json.status === 200) {
let jwt = json.token;
client
.createSession(jwt)
.then(sessionId => {
log("Id of created session: ", sessionId);
})
.catch(error => {
log("Error creating session: ", error);
});
client.on("sessionError", reason => {
// After creating a session
log("Session error reason: ", reason);
});
client.on("legStatusUpdate", (callId, legId, status) => {
// After creating a session
log({ callId, legId, status });
});
client.on("callInvite", (callId, from, channelType) => {
log({ callId, from, channelType }); // Answer / Reject Call
});
client.on("callHangup", (callId, callQuality, reason) => {
log(`Call ${callId} has hung up, callQuality:${callQuality}, reason:${reason}`);
set({ call_id: 0 });
});
client.on("sessionError", error => {
log({ error });
});
} else {
throw new Error("请求jwt失败");
}
set({ loading: false });
});
},
log: (...message) => {
const { logs } = get();
console.log(message);
set({ logs: [...logs, dayjs().format(DATETIME_FORMAT) + " : " + JSON.stringify(message)] });
},
// 创建一个语音通话
make_call: phone_number => {
const { client, log } = get();
if (!isNotEmpty(phone_number)) {
log("请输入电话号码");
return;
}
log("开始拨号:" + phone_number);
if (client) {
set({ loading: true });
client
.serverCall({ to: phone_number })
.then(callId => {
log("Id of created call: ", callId);
set({ call_id: callId });
set({ loading: false });
})
.catch(error => {
log("Error making call: ", error);
set({ loading: false });
});
}
},
// 挂断语音通话
hang_up: () => {
const { client, call_id, log } = get();
log("挂断电话");
if (call_id) {
client.hangup(call_id);
set({ call_id: 0 });
}
},
}));
export default callCenterStore;

@ -0,0 +1,61 @@
import { useCallback, useState, useEffect } from "react";
import { Grid, Divider, Layout, Flex, Spin, Input, Col, Row, List, Typography } from "antd";
import { PhoneOutlined, CustomerServiceOutlined, AudioOutlined } from "@ant-design/icons";
import { useParams, useHref, useNavigate } from "react-router-dom";
import { isEmpty } from "@/utils/commons";
import callCenterStore from "@/stores/CallCenterStore";
import useAuthStore from "@/stores/AuthStore";
const CallCenter = props => {
const href = useHref();
const navigate = useNavigate();
const { phonenumber } = useParams();
const [init_vonage, make_call, hang_up, logs, call_id, loading] = callCenterStore(state => [state.init_vonage, state.make_call, state.hang_up, state.logs, state.call_id, state.loading]);
const [loginUser] = useAuthStore(state => [state.loginUser]);
const [phone_number, setPhone_number] = useState(phonenumber);
useEffect(() => {
if (loginUser.userId === -1 && href.indexOf("/p/") === -1) {
navigate("/p/dingding/login?origin_url=" + href);
} else {
init_vonage(loginUser.userId);
}
}, [href, navigate, init_vonage, loginUser]);
const oncall = () => {
if (isEmpty(call_id)) {
make_call(phone_number);
} else {
hang_up();
}
};
return (
<>
<br />
<Row gutter={16}>
<Col md={24} lg={8} xxl={9}></Col>
<Col md={24} lg={8} xxl={6}>
<Input.Search
type="tel"
size="large"
defaultValue={phone_number}
placeholder="电话号码"
prefix={<AudioOutlined />}
suffix={loading ? <Spin /> : ""}
enterButton={call_id ? "挂断" : "拨号"}
onSearch={oncall}
onChange={e => {
setPhone_number(e.target.value);
}}></Input.Search>
</Col>
<Col md={24} lg={8} xxl={9}></Col>
</Row>
<Divider plain orientation="left" className="mb-0"></Divider>
<List header={<Typography.Text strong>Console Logs</Typography.Text>} bordered dataSource={logs} renderItem={item => <List.Item>{item}</List.Item>} />
</>
);
};
export default CallCenter;

@ -1,7 +1,7 @@
import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { Dropdown, Input, Button, Empty, Tooltip, Tag, Select } from 'antd';
import { PlusOutlined, WhatsAppOutlined, LoadingOutlined, HistoryOutlined, FireOutlined, } from '@ant-design/icons';
import { PlusOutlined, WhatsAppOutlined, LoadingOutlined, HistoryOutlined, FireOutlined,AudioTwoTone } from '@ant-design/icons';
import { fetchConversationsList, fetchOrderConversationsList, fetchConversationItemClose, fetchConversationsSearch, postNewConversationItem, fetchConversationItemUnread, UNREAD_MARK } from '@/actions/ConversationActions';
import { ChatItem } from 'react-chat-elements';
import ConversationsNewItem from './ConversationsNewItem';
@ -263,6 +263,7 @@ const Conversations = ({ mobile }) => {
type='text'
/>
</Tooltip>
{ mobile === undefined?'':<AudioTwoTone onClick={()=>{navigate(`/callcenter/call`)}} />}
</div>
<div className='flex-1 overflow-x-hidden overflow-y-auto relative'>
{conversationsListLoading && dataSource.length === 0 ? (

@ -1,190 +1,231 @@
import { LinkOutlined, MailOutlined, PhoneOutlined, UserOutlined, WhatsAppOutlined } from '@ant-design/icons'
import { App, Button, Card, Empty, Flex, Select, Spin, Typography, Divider, Modal } from 'antd'
import { useEffect, useState, useRef } from 'react'
import { LinkOutlined, MailOutlined, PhoneOutlined, UserOutlined, WhatsAppOutlined } from "@ant-design/icons";
import { App, Button, Card, Empty, Flex, Select, Spin, Typography, Divider, Modal } from "antd";
import { useEffect, useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { copy, isEmpty } from '@/utils/commons'
import { Conditional } from '@/components/Conditional'
import useConversationStore from '@/stores/ConversationStore'
import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions } from '@/stores/OrderStore'
import useAuthStore from '@/stores/AuthStore'
import QuotesHistory from './QuotesHistory'
import ConversationBind from './../ConversationBind';
import ConversationsNewItem from './../ConversationsNewItem';
import { useConversationNewItem } from '@/hooks/useConversation';
import { copy, isEmpty } from "@/utils/commons";
import { Conditional } from "@/components/Conditional";
import useConversationStore from "@/stores/ConversationStore";
import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions } from "@/stores/OrderStore";
import useAuthStore from "@/stores/AuthStore";
import QuotesHistory from "./QuotesHistory";
import ConversationBind from "./../ConversationBind";
import ConversationsNewItem from "./../ConversationsNewItem";
import { useConversationNewItem } from "@/hooks/useConversation";
const CustomerProfile = (() => {
const { notification, message } = App.useApp()
const [loading, setLoading] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const orderCommentRef = useRef(null)
const currentOrder = useConversationStore((state) => state.currentConversation?.coli_sn || '')
const currentConversationID = useConversationStore((state) => state.currentConversation?.sn || '')
const [updateCurrentConversation] = useConversationStore(((state) => [state.updateCurrentConversation]));
const loginUser = useAuthStore((state) => state.loginUser)
const { orderDetail, customerDetail, lastQuotation, quotationList,
fetchOrderDetail, setOrderPropValue, appendOrderComment
} = useOrderStore()
const CustomerProfile = () => {
const { notification, message } = App.useApp();
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const orderCommentRef = useRef(null);
const currentOrder = useConversationStore(state => state.currentConversation?.coli_sn || "");
const currentConversationID = useConversationStore(state => state.currentConversation?.sn || "");
const [updateCurrentConversation] = useConversationStore(state => [state.updateCurrentConversation]);
const loginUser = useAuthStore(state => state.loginUser);
const { orderDetail, customerDetail, lastQuotation, quotationList, fetchOrderDetail, setOrderPropValue, appendOrderComment } = useOrderStore();
const orderLabelOptions = copy(OrderLabelDefaultOptions)
orderLabelOptions.unshift({ value: 0, label: '未设置', disabled: true, })
const navigate = useNavigate();
const orderLabelOptions = copy(OrderLabelDefaultOptions);
orderLabelOptions.unshift({ value: 0, label: "未设置", disabled: true });
const orderStatusOptions = copy(OrderStatusDefaultOptions)
const orderStatusOptions = copy(OrderStatusDefaultOptions);
useEffect(() => {
if (currentOrder) {
setLoading(true)
fetchOrderDetail(currentOrder)
.finally(() => setLoading(false))
.catch(reason => {
notification.error({
message: '查询出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}
}, [currentOrder])
useEffect(() => {
if (currentOrder) {
setLoading(true);
fetchOrderDetail(currentOrder)
.finally(() => setLoading(false))
.catch(reason => {
notification.error({
message: "查询出错",
description: reason.message,
placement: "top",
duration: 60,
});
});
}
}, [currentOrder]);
let regularText = ''
if (orderDetail.buytime > 0) regularText = '(R' + orderDetail.buytime + ')'
let regularText = "";
if (orderDetail.buytime > 0) regularText = "(R" + orderDetail.buytime + ")";
const { openOrderContactConversation } = useConversationNewItem();
const [newChatModalVisible, setNewChatModalVisible] = useState(false);
const [newChatFormValues, setNewChatFormValues] = useState({});
const handleNewChat = async (values) => {
const newContact = { wa_id: values.wa_id };
openOrderContactConversation(newContact.wa_id);
setNewChatModalVisible(false);
}
const { openOrderContactConversation } = useConversationNewItem();
const [newChatModalVisible, setNewChatModalVisible] = useState(false);
const [newChatFormValues, setNewChatFormValues] = useState({});
const handleNewChat = async values => {
const newContact = { wa_id: values.wa_id };
openOrderContactConversation(newContact.wa_id);
setNewChatModalVisible(false);
};
if (currentOrder) {
return (
<div className='divide-x-0 divide-y divide-dashed divide-gray-300'>
<Spin spinning={loading}>
<Card className='p-2 '
bordered={false}
title={orderDetail.order_no}
actions={[
<Select key={'orderlabel'} size='small'
style={{
width: '100%'
}}
variant='borderless'
onSelect={(value) => {
setOrderPropValue(currentOrder, 'orderlabel', value)
.then(() => {
message.success('设置成功')
})
.catch(reason => {
notification.error({
message: '设置出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}
value={orderDetail.tags}
options={orderLabelOptions}
/>,
<Select key={'orderstatus'} size='small'
style={{
width: '100%'
}}
variant='borderless'
onSelect={(value) => {
setOrderPropValue(currentOrder,'orderstatus', value)
.then(() => {
message.success('设置成功')
})
.catch(reason => {
notification.error({
message: '设置出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}
value={orderDetail.states}
options={orderStatusOptions}
/>
]}
>
<Flex gap={10}>
<Flex vertical={true} justify='space-between'>
<Typography.Text ><UserOutlined className=' pr-1' />{customerDetail.name + regularText}</Typography.Text>
<Typography.Text ><PhoneOutlined className=' pr-1' />{customerDetail.phone}</Typography.Text>
<Typography.Text ><MailOutlined className=' pr-1' />{customerDetail.email}</Typography.Text>
<Typography.Text >
<WhatsAppOutlined className='pr-1' />
<Button type='link' size={'small'} onClick={() => {
setNewChatModalVisible(true);
setNewChatFormValues(prev => ({...prev, phone_number: customerDetail.whatsapp_phone_number, is_current_order: true, }))
}} >{customerDetail.whatsapp_phone_number}</Button>
</Typography.Text>
</Flex>
</Flex>
</Card>
<Divider orientation='left'><Typography.Text strong>最新报价</Typography.Text></Divider>
<Flex vertical={true} className='p-2 '>
<Conditional
condition={quotationList.length > 0}
whenFalse={<Empty description={<span>暂无报价</span>}></Empty>}
whenTrue={
<>
<p className='m-0 py-2 line-clamp-2 '><a target='_blank' href={lastQuotation.letterurl}><LinkOutlined />&nbsp;{lastQuotation.lettertitle}</a></p>
<Flex justify={'space-between'} >
<QuotesHistory dataSource={quotationList} />
</Flex>
</>
}/>
</Flex>
if (currentOrder) {
return (
<div className="divide-x-0 divide-y divide-dashed divide-gray-300">
<Spin spinning={loading}>
<Card
className="p-2 "
bordered={false}
title={orderDetail.order_no}
actions={[
<Select
key={"orderlabel"}
size="small"
style={{
width: "100%",
}}
variant="borderless"
onSelect={value => {
setOrderPropValue(currentOrder, "orderlabel", value)
.then(() => {
message.success("设置成功");
})
.catch(reason => {
notification.error({
message: "设置出错",
description: reason.message,
placement: "top",
duration: 60,
});
});
}}
value={orderDetail.tags}
options={orderLabelOptions}
/>,
<Select
key={"orderstatus"}
size="small"
style={{
width: "100%",
}}
variant="borderless"
onSelect={value => {
setOrderPropValue(currentOrder, "orderstatus", value)
.then(() => {
message.success("设置成功");
})
.catch(reason => {
notification.error({
message: "设置出错",
description: reason.message,
placement: "top",
duration: 60,
});
});
}}
value={orderDetail.states}
options={orderStatusOptions}
/>,
]}>
<Flex gap={10}>
<Flex vertical={true} justify="space-between">
<Typography.Text>
<UserOutlined className=" pr-1" />
{customerDetail.name + regularText}
</Typography.Text>
<Typography.Text>
<PhoneOutlined className=" pr-1" />
<Button
type="link"
size={"small"}
onClick={() => {
navigate(`/callcenter/call/` + customerDetail.phone);
}}>
{customerDetail.phone}
</Button>
</Typography.Text>
<Typography.Text>
<MailOutlined className=" pr-1" />
{customerDetail.email}
</Typography.Text>
<Typography.Text>
<WhatsAppOutlined className="pr-1" />
<Button
type="link"
size={"small"}
onClick={() => {
setNewChatModalVisible(true);
setNewChatFormValues(prev => ({ ...prev, phone_number: customerDetail.whatsapp_phone_number, is_current_order: true }));
}}>
{customerDetail.whatsapp_phone_number}
</Button>
</Typography.Text>
</Flex>
</Flex>
</Card>
<Divider orientation="left">
<Typography.Text strong>最新报价</Typography.Text>
</Divider>
<Flex vertical={true} className="p-2 ">
<Conditional
condition={quotationList.length > 0}
whenFalse={<Empty description={<span>暂无报价</span>}></Empty>}
whenTrue={
<>
<p className="m-0 py-2 line-clamp-2 ">
<a target="_blank" href={lastQuotation.letterurl}>
<LinkOutlined />
&nbsp;{lastQuotation.lettertitle}
</a>
</p>
<Flex justify={"space-between"}>
<QuotesHistory dataSource={quotationList} />
</Flex>
</>
}
/>
</Flex>
<Divider orientation='left'><Typography.Text strong>表单信息</Typography.Text></Divider>
<p className='p-2 overflow-auto m-0 break-words whitespace-pre-wrap' dangerouslySetInnerHTML={{__html: orderDetail.order_detail}}></p>
<Modal title='添加备注' open={isModalOpen}
onOk={() => {
const orderCommnet = orderCommentRef.current.value
if (isEmpty(orderCommnet)) {
message.warning('请输入备注后再提交。')
} else {
appendOrderComment(loginUser.userId, currentOrder, orderCommnet)
.then(() => {
message.success('添加成功')
setIsModalOpen(false)
})
.catch(reason => {
notification.error({
message: '添加出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}
orderCommentRef.current.value = ''
}}
onCancel={() => {setIsModalOpen(false)}}>
<textarea ref={orderCommentRef} className='w-full' rows={4}></textarea>
</Modal>
<Button size={'small'} onClick={() => {
setIsModalOpen(true)
}}>添加备注</Button>
</Spin>
<ConversationsNewItem initialValues={newChatFormValues} open={newChatModalVisible} onCreate={handleNewChat} onCancel={() => setNewChatModalVisible(false)} />
</div>
)
} else {
return (
<Empty
description={<span>暂无相关订单</span>}
>
<ConversationBind currentConversationID={currentConversationID} onBoundSuccess={(coli_sn) => updateCurrentConversation({coli_sn})} />
</Empty>
)
}
})
<Divider orientation="left">
<Typography.Text strong>表单信息</Typography.Text>
</Divider>
<p className="p-2 overflow-auto m-0 break-words whitespace-pre-wrap" dangerouslySetInnerHTML={{ __html: orderDetail.order_detail }}></p>
<Modal
title="添加备注"
open={isModalOpen}
onOk={() => {
const orderCommnet = orderCommentRef.current.value;
if (isEmpty(orderCommnet)) {
message.warning("请输入备注后再提交。");
} else {
appendOrderComment(loginUser.userId, currentOrder, orderCommnet)
.then(() => {
message.success("添加成功");
setIsModalOpen(false);
})
.catch(reason => {
notification.error({
message: "添加出错",
description: reason.message,
placement: "top",
duration: 60,
});
});
}
orderCommentRef.current.value = "";
}}
onCancel={() => {
setIsModalOpen(false);
}}>
<textarea ref={orderCommentRef} className="w-full" rows={4}></textarea>
</Modal>
<Button
size={"small"}
onClick={() => {
setIsModalOpen(true);
}}>
添加备注
</Button>
</Spin>
<ConversationsNewItem initialValues={newChatFormValues} open={newChatModalVisible} onCreate={handleNewChat} onCancel={() => setNewChatModalVisible(false)} />
</div>
);
} else {
return (
<Empty description={<span>暂无相关订单</span>}>
<ConversationBind currentConversationID={currentConversationID} onBoundSuccess={coli_sn => updateCurrentConversation({ coli_sn })} />
</Empty>
);
}
};
export default CustomerProfile
export default CustomerProfile;

@ -31,7 +31,7 @@ function DesktopApp() {
let defaultPath = '/order/follow'
if (href !== '/') {
const splitPath = href.split('/')
const splitPath = href.split('/');
if (splitPath.length > 2) {
defaultPath = '/' + splitPath[1] + '/' + splitPath[2]
}
@ -94,6 +94,7 @@ function DesktopApp() {
</Link>
),
},
{ key: '/callcenter/call', label: <Link to='/callcenter/call'>语音通话</Link> },
{ key: '/chat/history', label: <Link to='/chat/history'>聊天记录</Link> },
]}
/>

Loading…
Cancel
Save