diff --git a/README.md b/README.md index 27219c3..ecb5b9a 100644 --- a/README.md +++ b/README.md @@ -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 + diff --git a/package.json b/package.json index 70092fd..a7a7af2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/channel/whatsappUtils.js b/src/channel/whatsappUtils.js index d23f2d5..a785876 100644 --- a/src/channel/whatsappUtils.js +++ b/src/channel/whatsappUtils.js @@ -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] 文件上传失败.', diff --git a/src/config.js b/src/config.js index 5c531f9..241f8bc 100644 --- a/src/config.js +++ b/src/config.js @@ -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'; diff --git a/src/main.jsx b/src/main.jsx index 3c63d25..3680e42 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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: , children: [ { path: 'm/order', element: }, + { path: 'callcenter/call', element: }, + { path: 'callcenter/call/:phonenumber', element: }, ], }, ] @@ -68,6 +71,8 @@ const router = createBrowserRouter([ { path: 'account/profile', element: }, { path: 'chat/unassign/:whatsappid', element: }, { path: 'chat/unassign', element: }, + { path: 'callcenter/call', element: }, + { path: 'callcenter/call/:phonenumber', element: }, ], }, ], diff --git a/src/stores/CallCenterStore.js b/src/stores/CallCenterStore.js new file mode 100644 index 0000000..19a33e1 --- /dev/null +++ b/src/stores/CallCenterStore.js @@ -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; diff --git a/src/views/CallCenter.jsx b/src/views/CallCenter.jsx new file mode 100644 index 0000000..28844e6 --- /dev/null +++ b/src/views/CallCenter.jsx @@ -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 ( + <> + + + + + } + suffix={loading ? : ""} + enterButton={call_id ? "挂断" : "拨号"} + onSearch={oncall} + onChange={e => { + setPhone_number(e.target.value); + }}> + + + + + + Console Logs} bordered dataSource={logs} renderItem={item => {item}} /> + > + ); +}; +export default CallCenter; diff --git a/src/views/Conversations/Online/ConversationsList.jsx b/src/views/Conversations/Online/ConversationsList.jsx index 61ee65f..0b238a7 100644 --- a/src/views/Conversations/Online/ConversationsList.jsx +++ b/src/views/Conversations/Online/ConversationsList.jsx @@ -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' /> + { mobile === undefined?'':{navigate(`/callcenter/call`)}} />} {conversationsListLoading && dataSource.length === 0 ? ( diff --git a/src/views/Conversations/Online/order/CustomerProfile.jsx b/src/views/Conversations/Online/order/CustomerProfile.jsx index b349837..05411e1 100644 --- a/src/views/Conversations/Online/order/CustomerProfile.jsx +++ b/src/views/Conversations/Online/order/CustomerProfile.jsx @@ -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 ( - - - { - setOrderPropValue(currentOrder, 'orderlabel', value) - .then(() => { - message.success('设置成功') - }) - .catch(reason => { - notification.error({ - message: '设置出错', - description: reason.message, - placement: 'top', - duration: 60, - }) - }) - }} - value={orderDetail.tags} - options={orderLabelOptions} - />, - { - setOrderPropValue(currentOrder,'orderstatus', value) - .then(() => { - message.success('设置成功') - }) - .catch(reason => { - notification.error({ - message: '设置出错', - description: reason.message, - placement: 'top', - duration: 60, - }) - }) - }} - value={orderDetail.states} - options={orderStatusOptions} - /> - ]} - > - - - {customerDetail.name + regularText} - {customerDetail.phone} - {customerDetail.email} - - - { - setNewChatModalVisible(true); - setNewChatFormValues(prev => ({...prev, phone_number: customerDetail.whatsapp_phone_number, is_current_order: true, })) - }} >{customerDetail.whatsapp_phone_number} - - - - - 最新报价 - - 0} - whenFalse={暂无报价}>} - whenTrue={ - <> - {lastQuotation.lettertitle} - - - - > - }/> - + if (currentOrder) { + return ( + + + { + setOrderPropValue(currentOrder, "orderlabel", value) + .then(() => { + message.success("设置成功"); + }) + .catch(reason => { + notification.error({ + message: "设置出错", + description: reason.message, + placement: "top", + duration: 60, + }); + }); + }} + value={orderDetail.tags} + options={orderLabelOptions} + />, + { + setOrderPropValue(currentOrder, "orderstatus", value) + .then(() => { + message.success("设置成功"); + }) + .catch(reason => { + notification.error({ + message: "设置出错", + description: reason.message, + placement: "top", + duration: 60, + }); + }); + }} + value={orderDetail.states} + options={orderStatusOptions} + />, + ]}> + + + + + {customerDetail.name + regularText} + + + + { + navigate(`/callcenter/call/` + customerDetail.phone); + }}> + {customerDetail.phone} + + + + + {customerDetail.email} + + + + { + setNewChatModalVisible(true); + setNewChatFormValues(prev => ({ ...prev, phone_number: customerDetail.whatsapp_phone_number, is_current_order: true })); + }}> + {customerDetail.whatsapp_phone_number} + + + + + + + 最新报价 + + + 0} + whenFalse={暂无报价}>} + whenTrue={ + <> + + + + {lastQuotation.lettertitle} + + + + + + > + } + /> + - 表单信息 - - { - 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)}}> - - - { - setIsModalOpen(true) - }}>添加备注 - - setNewChatModalVisible(false)} /> - - ) - } else { - return ( - 暂无相关订单} - > - updateCurrentConversation({coli_sn})} /> - - ) - } -}) + + 表单信息 + + + { + 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); + }}> + + + { + setIsModalOpen(true); + }}> + 添加备注 + + + setNewChatModalVisible(false)} /> + + ); + } else { + return ( + 暂无相关订单}> + updateCurrentConversation({ coli_sn })} /> + + ); + } +}; -export default CustomerProfile +export default CustomerProfile; diff --git a/src/views/DesktopApp.jsx b/src/views/DesktopApp.jsx index 005d90b..430d28b 100644 --- a/src/views/DesktopApp.jsx +++ b/src/views/DesktopApp.jsx @@ -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() { ), }, + { key: '/callcenter/call', label: 语音通话 }, { key: '/chat/history', label: 聊天记录 }, ]} />
{lastQuotation.lettertitle}
+ + + {lastQuotation.lettertitle} + +