Compare commits

..

2 Commits

Author SHA1 Message Date
Lei OT 31b854efab 1 10 months ago
Lei OT ade510a701 feat: 打开供应商邮件 10 months ago

12
.gitignore vendored

@ -13,9 +13,7 @@ dist-ssr
*.local
distTest
dev-dist
tmp
schema*
.gitkeep
tmp
# Editor directories and files
.vscode/*
@ -31,11 +29,3 @@ schema*
/package-lock.json
**/LexicalEditor0
*.zip
.env.*
vonage-client*
**/test
*.bak

@ -22,11 +22,9 @@
npm version [<newversion> | major | minor | patch | premajor | preminor | prepatch | prerelease | from-git]
npm version preminor --preid alpha --no-git-tag-version
npm version preminor --preid beta --no-git-tag-version
1.2.0 -> 1.3.0-beta.0
npm version premajor --no-git-tag-version
1.0.0 -> 2.0.0-0
npm version prerelease --no-git-tag-version
2.0.0-0 -> 2.0.0-1 -> 2.0.0-2 ..n -> 2.0.0-n
npm version patch --no-git-tag-version

@ -1,83 +0,0 @@
## 发送template 消息
### 模板原文
```json
{
"wabaId": "190290134156880",
"name": "order_updated_specialist_assigned_sharon",
"language": "en",
"messageSendTtlSeconds": -1,
"components": [
{
"type": "BODY",
"text": "Hi {{customer_name}}, this is {{your_name1}} your trip advisor. \nHow are you? {{free_style}} \nKindest regards, {{your_name2}}. ",
"example": {
"body_text": [
[
"Mike",
"Coco",
"I'm following up on your itinerary",
"Coco2"
]
]
}
}
],
"category": "UTILITY",
"status": "APPROVED",
"qualityRating": "UNKNOWN",
"reason": "NONE",
"createTime": "2024-12-11T08:31:11.290Z",
"updateTime": "2024-12-11T08:31:34.610Z",
"statusUpdateEvent": "APPROVED"
}
```
### 发送模板消息, 数据结构
```json
{
"action": "message", // 固定值
"actionId": "190.3c8322de-68df-4f9e-be8e-d92e7a13b7d7", // .uuid()
"renderId": "190.3c8322de-68df-4f9e-be8e-d92e7a13b7d7", // 忽略
"externalId": 190, // 留空
"to": "8613557032060",
"from": "+8617607730395",
"msgtype": "template", // 固定值
"msgcontent": {
"name": "order_updated_specialist_assigned_sharon", // 模板名称. 从原文
"language": {
"code": "en" // 语言. 从原文
},
"components": [ // 没有参数的组件, 建议不传. 组件顺序也会报错
{
"type": "body",
"parameters": [ // 模板参数. 按 当前 `type` 中的参数顺序排列
{
"type": "text",
"text": "test OT 22" // 参数值: 不能换行, 连续空格<4
},
{
"type": "text",
"text": "yoyo"
},
{
"type": "text",
"text": "111"
},
{
"type": "text",
"text": "yoyo"
}
],
"text": "Hi test OT 22, this is yoyo your trip advisor. \nHow are you? 111 \nKindest regards, yoyo. " // 拼接一个完整内容
}
]
},
"opi_sn": "404", // 发送人 ID
"coli_sn": 1082591,
"conversationid": 190 // 忽略
}
```

@ -1,10 +1,3 @@
## 查找出掉线的 WhatsApp
select *
from whatsapp_individual.connections
where status IN ('offline')
and wa_id not in ('8618777396951', '8613557032060','8613317835586')
and wa_id <> 'null'
//
SELECT group_concat(opi_sn separator ',') as 'sn_list' FROM (
SELECT
@ -123,60 +116,3 @@ WHERE tos = '5534999923993' AND opi_sn = 587;
* end
* ---------------------------------------------------------
*/
/**
WhatsApp
*/
select * from whatsapp_user
##where wau_opi_sn in (252, 261,264,265,330,360,376,413,421,453,605,620) ## 国际部
where wau_opi_sn in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587,354, 414, 599, 606, 639, 648, 654, 662, 674, 676,391, 451, 476, 501, 512, 525, 528, 585, 586, 644) ## GH
-- 查找每个服务器在线的 WA 数量
select connect_name, count(*)
from whatsapp_individual.connections
where status IN ('open')
group by connect_name
set SESSION group_concat_max_len=4294967295;
-- 查找已经配置 WAI 服务的顾问
select group_concat(wau_whatsapp separator ''',''') as 'sn_list'
from sale_system.whatsapp_user
where wau_wai_server is not null
## 查找在线的 WhatsApp
SELECT group_concat(sesson_id separator ''',''') as 'sesson_list'
FROM whatsapp_individual.connections
where status = 'open'
-- 查找掉线的顾问
select group_concat(sesson_id separator ''',''') as 'sesson_list' from whatsapp_individual.connections
where status IN ('offline')
and wa_id not in ('8618777396951', '8613557032060','8613317835586')
## 查找 GH 没有扫码登录的顾问
select group_concat(wau_opi_sn separator ',') as 'sn_list'
from whatsapp_user
where wau_whatsapp not in ('8613317835586','8617607732272','8613978392676','8618378304803','8617607730629','8619107833371','8619107835971','8617607731491','8615080129281','8617607737720','8618777396951','8618078444860','8615778462307','8617774702925','8615078398450','8619178340224','8617607731153','8617607735120','8617607737646','8618877388203','8615778493040','8613617733956','8618290167273','8617776515283','8617607736381','8613557032060','8613667839691','8618378388403','8613635132972','8617607734598','8617607732512','8615878340720')
and wau_opi_sn in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587,354, 414, 599, 606, 639, 648, 654, 662, 674, 676,391, 451, 476, 501, 512, 525, 528, 585, 586, 644)
-- 查找使用 WhatsApp 顾问信息
SELECT
OPI_SN, OPI_Code,OPI_Name,OPI_DEI_SN,OPI_FirstName,OPI_RealName, DeleteFlag
FROM
dbo.OperatorInfo WHERE
OPI_SN in (143,476,528,391)
--OPI_SN in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587)
SELECT OPI_RealName + '(' + CAST(OPI_SN AS VARCHAR(100)) + ')',OPI_SN
FROM
dbo.OperatorInfo
where
--DeleteFlag = 0 and
OPI_SN in (79,85,114,119,135,143,155,162,178,210,216,222,225,252,261,264,265,273,293,296,311,330,343,347,348,354,360,370,376,387,391,412,413,414,421,441,444,451,453,456,466,468,476,495,497,501,509,512,513,514,517,519,522,525,527,528,539,550,573,585,586,587,592,599,600,605,606,611,617,620,639,644,648,654,656,659,662,663,674,676,690,691)
and OPI_RealName in ('兰芬','孙俊垚','王继伟','曾君','潘宏宇','郑美珍','张丽娟','张倩倩','赵泽菲','王影','陆力影','吕燕珍','何秋云','沈慧香')

@ -1,7 +1,7 @@
{
"name": "global-sales",
"private": true,
"version": "1.5.4",
"version": "1.2.0-alpha.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -12,8 +12,9 @@
"dependencies": {
"@dckj/react-better-modal": "^0.1.2",
"@lexical/react": "^0.20.0",
"@vonage/client-sdk": "^2.0.0",
"antd": "^5.25.2",
"@vonage/client-sdk": "^1.7.2",
"@yoopta/email-builder": "^4.9.2",
"antd": "^5.22.2",
"dayjs": "^1.11.13",
"dingtalk-jsapi": "^3.0.41",
"emoji-picker-react": "^4.12.0",
@ -21,15 +22,19 @@
"react": "^18.3.1",
"react-chat-elements": "^12.0.17",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"react-router-dom": "^6.28.0",
"rxjs": "^7.8.1",
"slate": "^0.110.2",
"slate-dom": "^0.111.0",
"slate-react": "^0.111.0",
"uuid": "^9.0.1",
"zustand": "^4.5.7"
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"@vonage/client-sdk": "^1.7.2",
"autoprefixer": "^10.4.20",
"eslint": "^8.45.0",
"eslint-plugin-react": "^7.37.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 19.9967V14.9967H10V19.9967H19V12.9967H5V19.9967H8ZM4 10.9967H20V7.9967H14V3.9967H10V7.9967H4V10.9967ZM3 20.9967V12.9967H2V6.9967C2 6.44442 2.44772 5.9967 3 5.9967H8V2.9967C8 2.44442 8.44772 1.9967 9 1.9967H15C15.5523 1.9967 16 2.44442 16 2.9967V5.9967H21C21.5523 5.9967 22 6.44442 22 6.9967V12.9967H21V20.9967C21 21.549 20.5523 21.9967 20 21.9967H4C3.44772 21.9967 3 21.549 3 20.9967Z"></path></svg>

Before

Width:  |  Height:  |  Size: 491 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"></path></svg>

Before

Width:  |  Height:  |  Size: 555 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4.9967V7.9967H19V4.9967H5ZM4 2.9967H20C20.5523 2.9967 21 3.44442 21 3.9967V8.9967C21 9.54899 20.5523 9.9967 20 9.9967H4C3.44772 9.9967 3 9.54899 3 8.9967V3.9967C3 3.44442 3.44772 2.9967 4 2.9967ZM6 11.9967H12C12.5523 11.9967 13 12.4444 13 12.9967V15.9967H14V21.9967H10V15.9967H11V13.9967H5C4.44772 13.9967 4 13.549 4 12.9967V10.9967H6V11.9967ZM17.7322 13.7289L19.5 11.9612L21.2678 13.7289C22.2441 14.7052 22.2441 16.2882 21.2678 17.2645C20.2915 18.2408 18.7085 18.2408 17.7322 17.2645C16.7559 16.2882 16.7559 14.7052 17.7322 13.7289Z"></path></svg>

Before

Width:  |  Height:  |  Size: 640 B

@ -1,5 +1,5 @@
import { groupBy, isNotEmpty, pick, sortArrayByOrder, sortBy } from '@/utils/commons';
import { groupBy, isNotEmpty, pick, sortArrayByOrder } from '@/utils/commons';
import { fetchJSON, postJSON, postForm } from '@/utils/request'
import { parseRenderMessageList } from '@/channel/bubbleMsgUtils';
import { API_HOST } from '@/config';
@ -11,87 +11,16 @@ import dayjs from 'dayjs';
*/
export const fetchTemplates = async (params) => {
const data = await fetchJSON(`${API_HOST}/listtemplates`, params);
const topName = [
'agent_intro_with_update_v1',
'online_inquiry_received',
'say_hello_again',
'order_updated_specialist_assigned_christy',
'order_resumed_specialist_followup_schedule_sharon',
'travel_service_update_v2',
'travel_service_update_v1',
'order_updated_specialist_assigned_sharon',
'first_message_for_not_reply',
// 'free_style_3',
// 'free_style_4',
];
// shouwcase
const scNames = ['trip_planner_showcase', 'showcase_different', 'order_status_updated'];
// 客运
const crNames = [
'notification_of_next_trip_planning',
// 'notification_of_following_up_by_cr_v3',
'notification_of_one_day_before_ending_the_trip_by_cr_v2',
'one_day_after_payment_by_yuni',
'notification_of_status_changed',
'notification_of_one_day_before_ending_the_trip_by_cr','one_day_after_payment_by_customer_relations',
'one_day_before_ending_the_trip_contacted_by_yuni','one_day_before_ending_the_trip_first_time_by_yuni',
'post_booking_confirmation_welcome',
];
const crNamesOmit = [
'birthday_greetings_by_marketing','one_day_before_ending_the_trip_by_marketing',
'introduce_the_voucher_one_day_before_ending_the_trip_by_marketing',
'birthday_greetings_by_customer_relations_0',
'post_trip_voucher_issued',
'account_updated_order_ref',
'post_trip_account_updated_from_cr',
'post_trip_account_updated',
'account_update_birthday',
'post_trip_birthday_reward',
'birthday_greetings_by_customer_relations_2',
'birthday_greetings_by_customer_relations_1',
'notification_of_account_updated_by_cr',
'birthday_greetings_by_customer_relations',
'one_day_before_ending_the_trip_by_customer_relations',
]
const canUseTemplates = (data?.result?.items || [])
.filter((_t) => _t.status === 'APPROVED' && !['say_hello_from_trip_advisor', 'free_style_7', 'free_style_1', 'free_style_2'].includes(_t.name))
.map((ele, i) => ({
...ele,
components_origin: ele.components,
components: groupBy(ele.components, (_c) => _c.type.toLowerCase()),
key: ele.name,
// displayName: ele.name.startsWith('order_updated') ? templatesDisplayNameMap['order_updated']+`_${i}` : templatesDisplayNameMap?.[ele.name] || ele.name,
displayName: templatesDisplayNameMap?.[ele.name] || (ele.name.startsWith('order_updated') ? templatesDisplayNameMap['order_updated']+`_${i}` : ele.name),
displayLanguage: crNamesOmit.includes(ele.name) ? '客运-' : (crNames.includes(ele.name) || ele.name.includes('by_cr')) ? ele.language + '-客运' : scNames.includes(ele.name) ? ele.language + '-示例' : ele.language,
}))
const top2Name = topName.concat(canUseTemplates.filter(_t => _t.name.startsWith('order_updated')).map(_tem => _tem.name));
const top = sortArrayByOrder( canUseTemplates.filter((_t) => top2Name.includes(_t.name)), 'name', topName);
const second = canUseTemplates.filter(_t => _t.name.includes('free_style'));
const secondS = second.sort(sortBy('name'));
const raw = canUseTemplates.filter((_t) => !top2Name.includes(_t.name) && !_t.name.includes('free_style'));
// 剩下的排序
const rawS = sortArrayByOrder(raw, 'name', [...crNames, ...scNames, ...crNamesOmit ]);
return [...top, ...secondS, ...rawS];
};
/**
* 上面的模板名称bak
* order_updated_specialist_assigned_sharon : free_style_7
* order_updated_specialist_assigned_christy : free_style_1
* online_inquiry_received: say_hello_from_trip_advisor
* order_resumed_specialist_followup_schedule_sharon: free_style_2
*/
const templatesDisplayNameMap = {
'order_updated_specialist_assigned_sharon': 'specialist_followup',
'order_updated_specialist_assigned_christy': 'specialist_followup_1',
'online_inquiry_received': 'online_inquiry_received/say_hello',
'order_resumed_specialist_followup_schedule_sharon': 'order_resumed/specialist_followup',
'order_updated': 'specialist_followup',
'agent_intro_with_update_v1': 'quick_update_v1',
.filter((_t) => _t.status === 'APPROVED')
.map((ele) => ({ ...ele, components_origin: ele.components, components: groupBy(ele.components, (_c) => _c.type.toLowerCase()) }));
const topName = ['free_style_7', 'say_hello_from_trip_advisor', 'free_style_2', 'free_style_1', 'free_style_3', 'free_style_4'];
const top = sortArrayByOrder( canUseTemplates.filter((_t) => topName.includes(_t.name)), 'name', topName);
const raw = canUseTemplates.filter((_t) => !topName.includes(_t.name));
return [...top, ...raw];
};
export const CONVERSATION_PAGE_SIZE = 100;
export const CONVERSATION_PAGE_SIZE = 20;
/**
*
* @param {object} params { opisn }
@ -121,8 +50,7 @@ export const fetchConversationsList = async (params) => {
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
coli_id: ele.COLI_ID,
}))
return list;
};
@ -148,13 +76,12 @@ export const fetchOrderConversationsList = async (params) => {
export const MESSAGE_PAGE_SIZE = 50;
/**
*
* @param {object} params { coli_sn, opisn, whatsappid, conversationid, lasttime, pagesize }
* @param {object} params { opisn, whatsappid, conversationid, lasttime, pagesize }
*/
export const fetchMessages = async (params) => {
const defaultParams = {
// opisn: '',
// whatsappid: '',
coli_sn: '',
conversationid: '',
lasttime: '',
pagesize: MESSAGE_PAGE_SIZE,
@ -176,7 +103,6 @@ export const fetchConversationItemClose = async (body) => {
* @param {object} body { phone_number, name }
*/
export const postNewOrEditConversationItem = async (body) => {
body.whatsapp_phone_number = `${body.whatsapp_phone_number || ''}`.trim();
const formData = new FormData();
Object.keys(body).forEach(function (key) {
formData.append(key, body[key]);
@ -190,24 +116,16 @@ export const postNewOrEditConversationItem = async (body) => {
...resultItem,
customer_name: `${resultItem.whatsapp_name || ''}`.trim(),
whatsapp_name: `${resultItem.whatsapp_name || ''}`.trim(),
// channels: {},
// tags: [],
// last_message: {},
channels: {},
tags: [],
last_message: {},
top_state: 0,
// conversation_memo: resultItem.session_memo,
conversation_memo: resultItem.session_memo,
};
};
/**
* @param {object} params { conversationid, coli_sn }
*/
export const postEditConversationItemColiAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/update_conversation_orderid`, params);
return errcode !== 0 ? {} : result;
};
/**
* @param {object} params { opisn, conversationid }
* @param {object} params { opisn, whatsappid }
*/
export const fetchCleanUnreadMsgCount = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/clean_unread_msg_count`, params);
@ -253,10 +171,9 @@ export const fetchConversationsSearch = async (params) => {
opi_sn: ele.OPI_SN || ele.opi_sn || 0,
OPI_Name: `${ele.OPI_Name || ele.opi_name || ''}`.trim(),
opi_name: `${ele.OPI_Name || ele.opi_name || ''}`.trim(),
dateText: dayjs((ele.lasttime)).format('MM-DD HH:mm'),
dateText: dayjs((ele.lasttime || ele.lasttime)).format('MM-DD HH:mm'),
matchMsgList: parseRenderMessageList((ele.msglist_AsJOSN || [])), // .reverse()),
coli_id: '',
show_default: ele.session_memo || ele.whatsapp_name || ele?.whatsapp_phone_number || ele?.guest_email || '',
}));
return list;
};
@ -267,15 +184,14 @@ export const fetchConversationsSearch = async (params) => {
*/
export const fetchMessagesHistory = async (params) => {
const defaultParams = {
// opisn: '',
// whatsappid: '',
conversationid: '',
opisn: '',
whatsappid: '',
lasttime: '2024-01-01T00:00:00',
pagesize: MESSAGE_PAGE_SIZE,
pagedir: 'next',
};
const _params = pick(params, Object.keys(defaultParams));
if (isEmpty(_params.conversationid)) {
if (isEmpty(_params.whatsappid)) {
return [];
}
const { errcode, result } = await fetchJSON(`${API_HOST}/get_item_messages`, {...defaultParams, ..._params});

@ -1,47 +1,16 @@
import { fetchJSON, postForm, postJSON } from '@/utils/request';
import { API_HOST, API_HOST_V3, DATE_FORMAT, DATEEND_FORMAT, DATETIME_FORMAT, EMAIL_HOST, EMAIL_HOST_v3 } from '@/config';
import { buildTree, groupBy, isEmpty, objectMapper, omitEmpty, uniqWith } from '@/utils/commons';
import { readIndexDB, writeIndexDB } from '@/utils/indexedDB';
import dayjs from 'dayjs';
import { internalEventEmitter } from '@/utils/EventEmitterService';
export const parseHTMLString = (html, needText = false) => {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
let bodyContent = doc.body.innerHTML
// bodyContent = bodyContent.replace(/<img/g, '<img onerror="this.onerror=null;this.src=\'https://hiana-crm.oss-accelerate.aliyuncs.com/WAMedia/afe412d4-3acf-4e79-a623-048aeb4d696a.png\';"')
const bodyText = (doc.body.innerText);
return needText ? { html, bodyContent, bodyText } : bodyContent
}
export const EMAIL_CHANNEL_NAME = 'mailbox_changes';
let emailChangesChannel = null;
export function getEmailChangesChannel() {
if (!emailChangesChannel) {
emailChangesChannel = new BroadcastChannel(EMAIL_CHANNEL_NAME)
}
return emailChangesChannel
}
// 通知邮件列表数据更新
const notifyMailboxUpdate = (payload) => {
const notificationPayload = payload
// - 多个tab
const channel = getEmailChangesChannel()
channel.postMessage(notificationPayload)
// - 当前tab
internalEventEmitter.emit(EMAIL_CHANNEL_NAME, notificationPayload)
}
import { fetchJSON, postForm } from '@/utils/request';
import { API_HOST, EMAIL_HOST } from '@/config';
import testData from './test1.json';
/**
* 获取顾问签名
* @param {object} { opi_sn }
*/
export const getSalesSignatureAction = async (params) => {
export const salesSignature = async (opisn, lgc = 1) => {
try {
const { result } = await fetchJSON(`${EMAIL_HOST}/email_sign`, params)
const { SignContent: html } = result
const bodyContent = parseHTMLString(html);
const html = await fetchJSON(`http://202.103.68.35/CustomerManager/english/mailsign.asp`, { WL_SN: opisn, LGC: lgc });
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const bodyContent = doc.body.innerHTML;
return bodyContent;
} catch (error) {
return '';
@ -52,8 +21,7 @@ export const getSalesSignatureAction = async (params) => {
* 发送邮件
*/
export const postSendEmail = async (body) => {
const { attaList=[], atta, content, ...bodyData } = body;
bodyData.ordertype = 227001;
const { attaList, atta, content, ...bodyData } = body;
const formData = new FormData();
Object.keys(bodyData).forEach(function (key) {
formData.append(key, bodyData[key]);
@ -81,411 +49,29 @@ export const postResendEmailAction = async (body) => {
const encodeEmailInfo = (info) => {
const encodeQuote = (str = '') => str.replace(/"/g, ''); //.replace(/</g,'&lt;').replace(/>/g,'&gt;')
const CSsClean = encodeQuote(info.MAI_CS).includes(',') ? encodeQuote(info.MAI_CS).split(',') : encodeQuote(info.MAI_CS).split(';');
const tosClean = (encodeQuote(info.MAI_To).includes(',') ? encodeQuote(info.MAI_To).split(',') : encodeQuote(info.MAI_To).split(';')).concat(CSsClean).filter(s => s);
const replyTo = info.MAI_Direction === 1 ? info.MAI_To : info.MAI_From;
const replyToAll = (tosClean.length > 1) ?
(info.MAI_Direction === 1 ? tosClean.join(',') : [...tosClean, info.MAI_From].join(','))
: (info.MAI_Direction === 1 ? info.MAI_To : info.MAI_From)
return {
...info,
MAI_From: encodeQuote(info.MAI_From),
MAI_To: encodeQuote(info.MAI_To),
tos: [...new Set(tosClean)],
replyToAll,
replyTo,
}
};
/**
* 邮件详情
* @param {object} { mai_sn }
*/
export const getEmailDetailAction = async (params) => {
// const cacheKey = params.mai_sn;
// const readCache = await readIndexDB(cacheKey, 'mailinfo', 'mailbox');
// if (!isEmpty(readCache)) { // todo: 除了草稿
// return readCache.data;
// }
const { result } = await fetchJSON(`${EMAIL_HOST}/getmail`, params);
let mailType = result.MailInfo?.[0]?.MAI_ContentType || '';
mailType = mailType === '' && (result.MailContent||'').includes('<html') ? 'text/html' : mailType;
const emailInfo = encodeEmailInfo(result.MailInfo?.[0] || {});
const isFromHub = emailInfo.MAI_From.includes('info@chinahighlights.net');
const delLinefeed = mailType === 'text/html' ? (result.MailContent||'').includes('<html') ? true : false : true;
const cleanContent = (result.MailContent || '').replace(/\r\n/g, delLinefeed ? '' : (isFromHub ? '<br>' : ''));
const { html, bodyContent, bodyText } = mailType === 'text/html' ? parseHTMLString(cleanContent, true) : { html: '', bodyContent: '', bodyText: '' };
const attachments = (isEmpty(result?.AttachList) ? [] : result.AttachList).filter(ele => isEmpty(ele.ATI_ContentID) || ele.ATI_ContentID == '0');
const insideAttachments = (isEmpty(result?.AttachList) ? [] : result.AttachList).filter(ele => !isEmpty(ele.ATI_ContentID) && ele.ATI_ContentID != '0');
const ret = {
info: { ...encodeEmailInfo(result.MailInfo?.[0] || {}), mailType },
content: mailType === 'text/html' ? html : result.MailContent || '',
abstract: bodyText || result.MailContent || '',
attachments,
insideAttachments,
AttachList: isEmpty(result?.AttachList) ? [] : result.AttachList,
}
// writeIndexDB([{key: cacheKey, data: ret}], 'mailinfo', 'mailbox')
return ret;
export const getEmailDetailAction = async (param) => {
const { result } = await fetchJSON(`${EMAIL_HOST}/getmail`, param);
return { info: encodeEmailInfo(result.MailInfo?.[0] || {}), content: result.MailContent || '', attachments: result?.AttachList || [] };
}
export const getEmailOrderAction = async ({ colisn }) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getorderinfo`, { colisn })
return errcode === 0 ? { ...result[0], customerDetail: result[0].contact[0] } : {}
}
/**
* 主动收邮件, 单个账户
* @param {object} { opi_sn, }
*/
export const getEmailFetchAction = async (params) => {
const { opi_sn, } = params
export const getEmailFetchAction = async (param) => {
const { opi_sn, } = param
const { result } = await fetchJSON(`${EMAIL_HOST}/email_fetch`, {
opi_sn,
})
return result
};
/**
* 报价信邮件草稿
* @param {object} { sfi_sn, coli_sn, lgc }
*/
export const getEmailQuotationDraftAction = async (params) => {
const { result } = await fetchJSON(`${EMAIL_HOST}/QuotationLetter`, params)
return { subject: (result.Subject || ''), content: parseHTMLString((result.MailContent || '').replace(/\r\n/g, '')) }
}
/**
* 单个邮件绑定订单
* @param {object} { conversationid, mai_sn, coli_sn, coli_id, sourcetype }
*/
export const fetchEmailBindOrderAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/mailinfo_bindorder`, params)
return errcode === 0 ? true : false;
}
const todoTypes = {
// 1新订单2WhatsApp未读消息3需一催4需二催5需三催6未处理邮件入境提醒coli_ordertype=7余款提醒coli_ordertype=8
1: '新订单',
2: '未读WhatsApp',
3: '一催',
4: '二催',
5: '三催',
6: '老邮件',
7: '入境提醒',
8: '余款提醒',
}
/**
* 顾问的邮箱目录
* @param {object} params { opi_sn, year, by_start_date, by_success, important, if_want_book, if_thinking }
* @param {boolean} retOrder 是否直接返回订单列表 -- 忽略
*/
export const getEmailDirAction = async (params = { opi_sn: '' }, retOrder=false) => {
const defaultParams = { opi_sn: 0, year: dayjs().year(), by_start_date: -1, by_success: -1, important: -1, if_want_book: -1, if_thinking: -1 }
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/email_dir`, { ...defaultParams, ...params })
const mailboxSort = result //.sort(sortBy('MDR_Order'));
let tree = buildTree(mailboxSort, { key: 'VKey', parent: 'VParent', name: 'VName', iconIndex: 'ImageIndex', rootKeys: [1], ignoreKeys: [-227001, -227002] })
tree = tree.filter((ele) => ele.key !== 1)
const retTree = errcode === 0 ? tree : [];
const orderList = groupBy(result, row => `${row.IsTrue}`)?.['0'] || [];
return retOrder !== false ? orderList : { [`${params.opi_sn}`]: retTree }
};
export const getMailboxCountAction = async (params = { opi_sn: '' }, update = true) => {
const defaultParams = {
opi_sn: 0,
// date1: dayjs().subtract(1, 'year').startOf('year').format(DATE_FORMAT),
date1: dayjs().subtract(180, 'days').format(DATE_FORMAT),
date2: dayjs().format(DATEEND_FORMAT)
}
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/dir_count`, {...defaultParams, ...params})
const ret = errcode !== 0 ? { [`${params.opi_sn}`]: {} } : { [`${params.opi_sn}`]: result }
// 更新数量
if (update !== false) {
const readCacheDir = (await readIndexDB(Number(params.opi_sn), 'dirs', 'mailbox')) || {};
const mailboxDir = isEmpty(readCacheDir) ? [] : readCacheDir.tree.filter(node => node?._raw?.IsTrue === 1);
const _MapDir = new Map(mailboxDir.map((obj) => [obj.key, obj]))
Object.keys(result).map(dirKey => {
_MapDir.set(Number(dirKey), {..._MapDir.get(Number(dirKey)), count: result[dirKey]});
})
const _newToUpdate = Array.from(_MapDir.values());
const _MapRoot = new Map((readCacheDir?.tree || []).map((obj) => [obj.key, obj]))
_newToUpdate.forEach((row) => {
_MapRoot.set(row.key, row)
})
const _newRoot = Array.from(_MapRoot.values())
writeIndexDB([{ ...readCacheDir, key: Number(params.opi_sn), tree: _newRoot }], 'dirs', 'mailbox')
notifyMailboxUpdate({ type: 'dirs', key: Number(params.opi_sn) })
}
return ret;
};
export const getTodoOrdersAction = async (params) => {
const opi_arr = params.opisn.split(',')
const defaultStickyTree = opi_arr.reduce(
(a, ele) => ({
...a,
[ele]: [
{
key: ele + '-today',
title: '今日任务',
getMails: false,
iconIndex: 'star',
children: [],
COLI_SN: 0,
},
{
key: ele + '-todo',
title: '待办任务',
getMails: false,
iconIndex: 'calendar',
children: [],
COLI_SN: 0,
},
// {
// key: ele.OPI_DEI_SN + '-reminder',
// title: '催信',
// getMails: false,iconIndex: 'reminder',
// icon: <BellTwoTone />,
// children: [], COLI_SN: 0,
// },
],
}),
{},
)
const { errcode, result } = await fetchJSON(`${API_HOST}/getwlorder`, params)
// 订单重复时, 取后一个状态, 因此翻转两次
const _result_unique = uniqWith(result.reverse(), (a, b) => a.COLI_SN === b.COLI_SN).reverse();
const orderList = errcode === 0 ? _result_unique : []
const byOPI = groupBy(orderList, 'OPI_SN')
const byState = Object.keys(byOPI).reduce((acc, key) => {
const sticky = groupBy(byOPI[key], (ele) => ([1, 6].includes(ele.coli_ordertype) ? 0 : [2, 3, 4, 5].includes(ele.coli_ordertype) ? 1 : 2))
const treeNode = [
{
key: key + '-today',
title: '今日任务',
getMails: false,
iconIndex: 'star',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[0] || []).map((o) => ({
key: `today-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-today',
parentTitle: '今日任务',
parentIconIndex: 'star',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-today', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-today' },
})),
},
{
key: key + '-todo',
title: '待办任务',
getMails: false,
iconIndex: 'calendar',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[2] || []).map((o) => ({
key: `todo-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-todo',
parentTitle: '待办任务',
parentIconIndex: 'calendar',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-todo', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-todo' },
})),
},
...(!isEmpty(sticky[1] || [])
? [
{
key: key + '-reminder',
title: '催信',
getMails: false,
iconIndex: 'reminder',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[1] || []).map((o) => ({
key: `reminder-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-reminder',
parentTitle: '催信',
parentIconIndex: 'reminder',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-reminder', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-reminder' },
})),
},
]
: []),
]
return { ...acc, [key]: treeNode }
}, defaultStickyTree)
return errcode === 0 ? byState : defaultStickyTree
};
/**
* 获取待办目录和邮箱目录
* @param {object} params { opi_sn, userIdStr }
* @param {number} params.opi_sn
* @param {string} params.userIdStr - 用户ID字符串默认为空
*/
export const getRootMailboxDirAction = async ({ opi_sn = 0, userIdStr = '' } = {}) => {
const [stickyTree, ...mailboxDir] = await Promise.all([
getTodoOrdersAction({ opisn: userIdStr || String(opi_sn), otype: 'today' }),
...(userIdStr.split(',').map(_opi => getEmailDirAction({ opi_sn: _opi }))),
])
const mailBoxCount = await Promise.all(userIdStr.split(',').map(_opi => getMailboxCountAction({ opi_sn: _opi }, false)));
const mailboxDirCountByOPI = mailBoxCount.reduce((a, c) => ({ ...a, ...c, }), {})
const mailboxDirByOPI = mailboxDir.reduce((a, c) => ({ ...a, ...(Object.keys(c).reduce((a, opi) => ({...a, [opi]: c[`${opi}`].map((dir) => ({ ...dir, count: mailboxDirCountByOPI[opi][`${dir.key}`] })) }), {} )) }), {})
const rootTree = Object.keys(stickyTree).map((opi) => ({ key: Number(opi), tree: [...stickyTree[opi], ...(mailboxDirByOPI?.[opi] || [])], treeTimestamp: Date.now() }))
writeIndexDB(rootTree, 'dirs', 'mailbox')
const _mapped = groupBy(rootTree, 'key')
return _mapped[opi_sn]?.[0]?.tree || []
}
/**
* 获取邮件列表
* @usage 邮件目录下的邮件列表
* @usage 订单的邮件列表
*/
export const queryEmailListAction = async ({ opi_sn = '', pagesize = 10, last_id = '', node = {}, } = {}) => {
const _params = {
vkey: 0,
vparent: 0,
order_source_type: 0,
// mai_senddate1: dayjs().subtract(1, 'year').startOf('year').format(DATE_FORMAT),
mai_senddate1: dayjs().subtract(180, 'days').format(DATE_FORMAT),
mai_senddate2: dayjs().format(DATEEND_FORMAT),
...omitEmpty({
...node,
opi_sn,
}),
}
_params.mai_senddate1 = dayjs(_params.mai_senddate1).format(DATE_FORMAT)
const cacheKey = isEmpty(_params.coli_sn) ? `dir-${node.vkey}` : `order-${node.vkey}`;
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/mail_list`, _params)
const ret = errcode === 0 ? result : []
if (!isEmpty(ret)) {
const listids = [...new Set(ret.map(ele => ele.MAI_SN))];
writeIndexDB([{key: cacheKey, data: listids}], 'maillist', 'mailbox')
writeIndexDB(ret.map(ele => ({ data: {...ele, listKey: cacheKey }, key: ele.MAI_SN})), 'listrow', 'mailbox')
}
return ret;
}
export const searchEmailListAction = async ({opi_sn = '', mailboxtype = 'ALL', sender = '', receiver = '', subject = '', content=''}={}) => {
const formData = new FormData()
formData.append('opi_sn', opi_sn)
formData.append('mailboxtype', mailboxtype)
formData.append('sender', sender)
formData.append('receiver', receiver)
formData.append('subject', subject)
// formData.append('content', content)
const { errcode, result } = await postForm(`${API_HOST_V3}/mail_search`, formData)
const ret = errcode === 0 ? result : []
notifyMailboxUpdate({ type: 'maillist-search-result', query: [sender, receiver, subject].filter(s => s).join(' '), data: ret.map(ele => ({...ele, key: ele.MAI_SN, showFolder: true })) })
return ret;
}
const removeFromCurrentList = async (params) => {
const readRow0 = await readIndexDB(params.mai_sn_list[0], 'listrow', 'mailbox')
const listKey = readRow0?.data?.listKey || ''
if (listKey) {
const readCache = await readIndexDB(listKey, 'maillist', 'mailbox')
const updatedMailList = readCache.data.filter((mai_sn) => !params.mai_sn_list.includes(mai_sn))
writeIndexDB([{ key: listKey, data: updatedMailList }], 'maillist', 'mailbox')
notifyMailboxUpdate({ type: 'listrow', listKey, affectKeys: params.mai_sn_list })
}
}
const updateEmailKeyMap = { read: 'MOI_ReadState' };
const updateEmailKeyFun = {
read: async (params) => {
const readCache = await readIndexDB(params.mai_sn_list, 'listrow', 'mailbox')
const updateField = Object.keys(params.set).reduce((a, c) => ({ ...a, [updateEmailKeyMap[c]]: params.set[c] }), {})
writeIndexDB(
params.mai_sn_list.map((ele) => ({ data: { ...(readCache.get(ele)?.data || {}), ...updateField }, key: ele })),
'listrow',
'mailbox',
)
// 通知邮件列表数据更新
const listKey = readCache.get(params.mai_sn_list[0])?.data?.listKey || '';
const notificationPayload = { type: 'listrow', listKey, affectKeys: params.mai_sn_list }
notifyMailboxUpdate(notificationPayload)
},
processed: removeFromCurrentList,
delete: removeFromCurrentList
}
/**
* 更新邮件属性
*/
export const updateEmailAction = async (params = { opi_sn: 0, mai_sn_list: [], set: {} }) => {
if (isEmpty(params.mai_sn_list)) {
throw new Error('没有需要更新的邮件');
}
const { errcode, result } = await postJSON(`${API_HOST_V3}/mail_update`, params)
if (errcode === 0 ) {
for (const [key, value] of Object.entries(params.set)) {
const updateFun = updateEmailKeyFun[key] || (() => {});
updateFun(params)
}
}
getMailboxCountAction({ opi_sn: params.opi_sn });
return errcode === 0 ? result : {}
}
/**
* 获取邮件模板
* @param {object} params - Parameters for the email template request.
* @param {number} [params.coli_sn] - Customer order line item serial number.
* @param {number} [params.lgc] - Language code.
* @param {number} [params.opi_sn] - Order product item serial number.
* @param {string} [params.remind_type] - Type of reminder.
* @param {number} [params.remind_index] - Index of the reminder.
*/
export const getReminderEmailTemplateAction = async (params = { coli_sn: 0, lgc: 1, opi_sn: 0, remind_type: '', remind_index: 0 }) => {
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/reminder_letter`, params)
const { html, bodyContent, bodyText } = parseHTMLString(result?.MailContent, true) ;
return errcode === 0 ? {...result, bodyContent }: {}
}
/**
* 保存邮件草稿
* @param {object} body - The body of the email.
* @param {boolean} [isDraft=false] - Whether the email is a draft.
*/
export const saveEmailDraftOrSendAction = async (body, isDraft = false) => {
const url = isDraft !== false ? `${API_HOST_V3}/email_draft_save` : `${EMAIL_HOST_v3}/sendmail`;
const { attaList=[], atta, content, ...bodyData } = body;
bodyData.ordertype = 227001;
const formData = new FormData();
Object.keys(bodyData).forEach(function (key) {
formData.append(key, bodyData[key] || '');
});
// 附件只传新增的
attaList.filter(ele => !ele.fullPath).forEach(function (item) {
formData.append('attachment', item);
});
const { errcode, result } = await postForm(url, formData);
return errcode === 0 ? (result || {}) : {}
};
/**
* 删除邮件附件
*/
export const deleteEmailAttachmentAction = async (ati_sn_list) => {
const { errcode, result } = await postJSON(`${API_HOST_V3}/mail_attachment_delete`, { ati_sn_list })
return errcode === 0 ? result : {}
};
export const queryHTOrderListAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/query_order`, params)
return errcode !== 0 ? [] : result
}
export const queryOPIOrderAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getdvancedwlorder`, params)
return errcode !== 0 ? [] : result
};

@ -1,25 +0,0 @@
import { fetchJSON, postForm, postJSON } from '@/utils/request'
import { usingStorage } from '@/utils/usingStorage'
const WAI_SERVER_KEY = 'G-STR:WAI_SERVER'
const WAI_API_VER = '/api/v1'
export const postSendMsg = async (body) => {
const { waiServer } = usingStorage(WAI_SERVER_KEY)
// const { attaList = [], atta, content, ...bodyData } = body
// const formData = new FormData()
// Object.keys(bodyData).forEach(function (key) {
// formData.append(key, bodyData[key])
// })
// attaList.forEach(function (item) {
// formData.append('attachment', item)
// })
const { result } = await postJSON(`${waiServer}${WAI_API_VER}/messages/send`, body)
return result
}
export const fetchQRCode = (phone) => {
const { waiServer } = usingStorage(WAI_SERVER_KEY)
return fetchJSON(`${waiServer}${WAI_API_VER}/channels/qrcode`, { phone })
}

File diff suppressed because one or more lines are too long

@ -1,6 +1,6 @@
.logo {
float: left;
height: 60px;
height: 68px;
margin: 0 6px 0 0;
background: rgba(255, 255, 255, 0.3);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 20V7L20 3H4L2 7.00353V20C2 20.5523 2.44772 21 3 21H21C21.5523 21 22 20.5523 22 20ZM4 9H20V19H4V9ZM5.236 5H18.764L19.764 7H4.237L5.236 5ZM15 11H9V13H15V11Z"></path></svg>

Before

Width:  |  Height:  |  Size: 262 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966ZM15.6567 14.5113L19.1922 10.9758L12.8283 4.61185L9.29275 8.14738L15.6567 14.5113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 442 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"></path></svg>

Before

Width:  |  Height:  |  Size: 555 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.6512 14.0654L11.6047 20H9.57389L10.9247 12.339L3.51465 4.92892L4.92886 3.51471L20.4852 19.0711L19.071 20.4853L12.6512 14.0654ZM11.7727 7.53009L12.0425 5.99999H10.2426L8.24257 3.99999H19.9999V5.99999H14.0733L13.4991 9.25652L11.7727 7.53009Z"></path></svg>

Before

Width:  |  Height:  |  Size: 347 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13.3414C21.3744 13.1203 20.7013 13 20 13C16.6863 13 14 15.6863 14 19C14 19.7013 14.1203 20.3744 14.3414 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13.3414ZM12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L19.6544 7.75616L18.3456 6.24384L12.0606 11.6829ZM21 18H24V20H21V23H19V20H16V18H19V15H21V18Z"></path></svg>

Before

Width:  |  Height:  |  Size: 460 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13H20V7.23792L12.0718 14.338L4 7.21594V19H14V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13ZM4.51146 5L12.0619 11.662L19.501 5H4.51146ZM21 18H24V20H21V23H19V20H16V18H19V15H21V18Z"></path></svg>

Before

Width:  |  Height:  |  Size: 328 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13.3414C21.3744 13.1203 20.7013 13 20 13C16.6863 13 14 15.6863 14 19C14 19.7013 14.1203 20.3744 14.3414 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13.3414ZM12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L19.6544 7.75616L18.3456 6.24384L12.0606 11.6829ZM19 22L15.4645 18.4645L16.8787 17.0503L19 19.1716L22.5355 15.636L23.9497 17.0503L19 22Z"></path></svg>

Before

Width:  |  Height:  |  Size: 504 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 14H20V7.23792L12.0718 14.338L4 7.21594V19H14V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V14ZM4.51146 5L12.0619 11.662L19.501 5H4.51146ZM19 22L15.4645 18.4645L16.8787 17.0503L19 19.1716L22.5355 15.636L23.9497 17.0503L19 22Z"></path></svg>

Before

Width:  |  Height:  |  Size: 372 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.8032 8.4928C19.4663 8.81764 20.2118 9 21 9C21.3425 9 21.6769 8.96557 22 8.89998V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H16.1C16.0344 3.32311 16 3.65753 16 4C16 5.23672 16.449 6.36857 17.1929 7.24142L12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L18.8032 8.4928ZM21 7C19.3431 7 18 5.65685 18 4C18 2.34315 19.3431 1 21 1C22.6569 1 24 2.34315 24 4C24 5.65685 22.6569 7 21 7Z"></path></svg>

Before

Width:  |  Height:  |  Size: 539 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.1 3C16.0344 3.32311 16 3.65753 16 4C16 4.34247 16.0344 4.67689 16.1 5H4.51146L12.0619 11.662L17.1098 7.14141C17.5363 7.66888 18.0679 8.10787 18.6728 8.42652L12.0718 14.338L4 7.21594V19H20V8.89998C20.3231 8.96557 20.6575 9 21 9C21.3425 9 21.6769 8.96557 22 8.89998V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H16.1ZM21 1C22.6569 1 24 2.34315 24 4C24 5.65685 22.6569 7 21 7C19.3431 7 18 5.65685 18 4C18 2.34315 19.3431 1 21 1Z"></path></svg>

Before

Width:  |  Height:  |  Size: 572 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4.9967V7.9967H19V4.9967H5ZM4 2.9967H20C20.5523 2.9967 21 3.44442 21 3.9967V8.9967C21 9.54899 20.5523 9.9967 20 9.9967H4C3.44772 9.9967 3 9.54899 3 8.9967V3.9967C3 3.44442 3.44772 2.9967 4 2.9967ZM6 11.9967H12C12.5523 11.9967 13 12.4444 13 12.9967V15.9967H14V21.9967H10V15.9967H11V13.9967H5C4.44772 13.9967 4 13.549 4 12.9967V10.9967H6V11.9967ZM17.7322 13.7289L19.5 11.9612L21.2678 13.7289C22.2441 14.7052 22.2441 16.2882 21.2678 17.2645C20.2915 18.2408 18.7085 18.2408 17.7322 17.2645C16.7559 16.2882 16.7559 14.7052 17.7322 13.7289Z"></path></svg>

Before

Width:  |  Height:  |  Size: 640 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6V21H11V6H5V4H19V6H13Z"></path></svg>

Before

Width:  |  Height:  |  Size: 130 B

@ -1,4 +1,4 @@
import { cloneDeep, isEmpty, olog, fixTo2Decimals, pick, objectMapper } from "@/utils/commons";
import { cloneDeep, isEmpty, olog, fixTo2Decimals, pick } from "@/utils/commons";
import dayjs from "dayjs";
import { v4 as uuid } from "uuid";
@ -40,24 +40,7 @@ export const WABAccounts = [
"decision": "DEFERRED",
"requestedVerifiedName": "Global Highlights",
"rejectionReason": "NONE"
},
{
"id": "563254206874812",
"phoneNumber": "+639454682947",
"wabaId": "190290134156880",
"verifiedName": "Customer Relation Specialist",
"qualityRating": "UNKNOWN",
"messagingLimit": "TIER_1K",
"isOfficialBusinessAccount": false,
"codeVerificationStatus": "VERIFIED",
"status": "CONNECTED",
"displayPhoneNumber": "+63 945 468 2947",
"nameStatus": "DECLINED",
"newNameStatus": "NONE",
"decision": "DEFERRED",
"requestedVerifiedName": "Customer Relation Specialist",
"rejectionReason": "NONE"
},
}
];
export const WABAccountsMapped = WABAccounts.reduce((a, c) => ({ ...a, [removeFirstPlus(c.phoneNumber)]: c, [c.phoneNumber]: c }), {})
@ -74,11 +57,6 @@ export const replaceTemplateString = (str, replacements) => {
return result;
}
export const whatsappTemplateBtnParamTypesMapped = {
'copy_code': 'coupon_code',
// 'quick_reply': 'payload',
};
/**
* @deprecated 在渲染时处理
*/
@ -103,7 +81,6 @@ const mediaMsg = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgcontent: {
@ -139,7 +116,6 @@ export const sentMsgTypeMapped = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgtype: 'text',
@ -223,7 +199,6 @@ export const sentMsgTypeMapped = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgtype: 'template',
@ -231,18 +206,18 @@ export const sentMsgTypeMapped = {
...msg.template,
components: [
...msg.template.components.filter((com) => !['footer', 'buttons'].includes(com.type.toLowerCase())),
// ...(msg.template.components.filter((com) => 'buttons' === com.type.toLowerCase()).length > 0
// ? msg.template.components
// .filter((com) => 'buttons' === com.type.toLowerCase())[0]
// // .buttons.filter((btns) => ! ['phone_number', 'url'].includes( btns.type.toLowerCase()))
// .buttons.filter((btns) => !isEmpty(btns.example)) // 静态按钮不发
// .map((btn, btnI) => ({
// type: 'button',
// sub_type: btn.type.toLowerCase(),
// index: btnI,
// // parameters: [{ text: 'lq1FTtA8', type: 'text' }]
// }))
// : []),
...(msg.template.components.filter((com) => 'buttons' === com.type.toLowerCase()).length > 0
? msg.template.components
.filter((com) => 'buttons' === com.type.toLowerCase())[0]
// .buttons.filter((btns) => ! ['phone_number', 'url'].includes( btns.type.toLowerCase()))
.buttons.filter((btns) => !isEmpty(btns.example)) // 静态按钮不发
.map((btn, btnI) => ({
type: 'button',
sub_type: btn.type.toLowerCase(),
index: btnI,
// parameters: [{ text: 'lq1FTtA8', type: 'text' }]
}))
: []),
],
},
}),
@ -309,22 +284,19 @@ export const sentMsgTypeMapped = {
const whatsappMsgMapped = {
'whatsapp.inbound_message.received': {
getMsg: (result) => {
// console.log('whatsapp.inbound_message.received', result);
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id'])
return isEmpty(result?.whatsappInboundMessage) ? null : { ...result.whatsappInboundMessage, ...data1, messageorigin: result.messageorigin, msg_source: 'WABA', msg_direction: 'inbound' }
console.log('whatsapp.inbound_message.received', result);
return isEmpty(result?.whatsappInboundMessage) ? null : { ...result.whatsappInboundMessage, conversationid: result.conversationid, messageorigin: result.messageorigin };
},
contentToRender: (contentObj) => {
// console.log('whatsapp.inbound_message.received to render', contentObj);
return parseRenderMessageItem(contentObj)
console.log('whatsapp.inbound_message.received to render', contentObj);
return parseRenderMessageItem(contentObj);
},
contentToUpdate: () => null,
},
'whatsapp.message.updated': {
getMsg: (result) => {
// console.log('getMsg', result);
return isEmpty(result?.whatsappMessage)
? null
: { ...result.whatsappMessage, conversationid: result.conversationid, messageorigin: result.messageorigin, msg_source: 'WABA', msg_direction: 'outbound' }
return isEmpty(result?.whatsappMessage) ? null : { ...result.whatsappMessage, conversationid: result.conversationid, messageorigin: result.messageorigin };
},
contentToRender: (contentObj) => {
if (contentObj?.status === 'failed' && ['130472', 'BAD_REQUEST'].includes(contentObj.errorCode)) {
@ -334,94 +306,28 @@ const whatsappMsgMapped = {
text: { body: `${whatsappError?.[contentObj.errorCode] || contentObj.errorMessage}` }, // contentObj.errorMessage // Message failed to send.
id: contentObj.id,
wamid: contentObj.id,
}
return parseRenderMessageItem(contentObj)
};
return parseRenderMessageItem(contentObj);
}
// * 仅更新消息状态, 没有输出
return null
return null;
},
contentToUpdate: (msgcontent) => ({
...msgcontent,
...parseRenderMessageItem(msgcontent),
id: msgcontent.wamid,
status: msgStatusRenderMapped[msgcontent?.status || 'accepted'],
status: msgStatusRenderMapped[(msgcontent?.status || 'failed')],
sender: 'me',
dateString: msgcontent.status === 'failed' ? `发送失败 ${whatsappError?.[msgcontent.errorCode] || msgcontent.errorMessage || ''}` : '',
}),
},
'wai.message.received': {
getMsg: (result) => {
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id'])
return isEmpty(result?.waiMessage)
? null
: { ...result.waiMessage, ...data1, messageorigin: result.messageorigin, msg_source: 'wai', ...objectMapper(result.waiMessage, { direction: { key: 'msg_direction' } }) }
},
contentToRender: (contentObj) => {
return parseRenderMessageItem(contentObj)
},
contentToUpdate: () => null,
},
'wai.message.updated': {
getMsg: (result) => {
return isEmpty(result?.waiMessage)
? null
: {
...result.waiMessage,
conversationid: result.conversationid,
messageorigin: result.messageorigin,
msg_source: 'wai',
...objectMapper(result.waiMessage, { direction: { key: 'msg_direction' } }),
}
},
contentToRender: (contentObj) => {
if (contentObj?.status === 'failed') {
contentObj = {
...contentObj,
type: 'error',
text: { body: `` }, // contentObj.errorMessage // Message failed to send.
id: contentObj.id,
wamid: contentObj.id,
}
return parseRenderMessageItem(contentObj)
}
// * 仅更新消息状态, 没有输出
return null
},
contentToUpdate: (msgcontent) => ({
...msgcontent,
...parseRenderMessageItem(msgcontent),
id: msgcontent.wamid,
status: msgcontent.msg_direction === 'outbound' ? msgStatusRenderMapped[msgcontent?.status || 'accepted'] : '',
sender: msgcontent.msg_direction === 'outbound' ? 'me' : msgcontent?.customerProfile?.name || '',
dateString: msgcontent.status === 'failed' ? `发送失败 ❌` : '',
dateString: msgcontent.status==='failed' ? `发送失败 ${whatsappError?.[msgcontent.errorCode] || msgcontent.errorMessage || ''}` : '',
}),
},
'wai.creds.update': {
getMsg: (result) => {
return isEmpty(result?.waiMessage)
? {}
: { ...result.waiMessage, conversationid: result.conversationid, msg_source: 'wai', }
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
contentToNotify: (contentObj) => {
return {
...contentObj,
status: contentObj?.status || '',
key: contentObj.to || '',
content: `WhatsApp号码: ${contentObj.to}`,
title: (contentObj.status === 'offline') ? `WhatsApp 断开连接` : '',
type: (contentObj.status === 'offline') ? 'warning' : 'info',
};
},
},
}
};
const emailMsgMapped = {
'email.inbound.received': {
getMsg: (result) => {
// console.log('email.inbound.received', result);
console.log('email.inbound.received', result);
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id']);
return isEmpty(result?.emailMessage) ? null : { ...result.emailMessage, ...data1, msg_source: 'email', msg_direction: 'inbound' };
return isEmpty(result?.emailMessage) ? null : { ...result.emailMessage, ...data1, };
},
contentToRender: (contentObj) => {
// console.log('email.inbound.received to render', contentObj);
@ -431,24 +337,24 @@ const emailMsgMapped = {
},
'email.updated': {
getMsg: (result) => {
// console.log('email.updated', result);
console.log('email.updated', result);
const { emailMessage } = result;
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id']);
return isEmpty(result?.emailMessage) ? null : { ...emailMessage, ...data1, msg_source: 'email', msg_direction: 'outbound' };
return isEmpty(result?.emailMessage) ? null : { ...emailMessage, ...data1, };
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => ({
...msgcontent,
...parseRenderMessageItem({...msgcontent, }),
id: msgcontent.id,
status: msgStatusRenderMapped[(msgcontent?.status || 'accepted')],
status: msgStatusRenderMapped[(msgcontent?.status || 'failed')],
sender: 'me',
dateString: msgcontent.status==='failed' ? `发送失败 ❌` : '',
}),
},
'email.action.received': {
getMsg: (result) => {
return isEmpty(result?.emailMessage) ? null : { ...result.emailMessage, id: result.id };
return isEmpty(result?.emailMessage) ? null : { ...result.emailMessage, };
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
@ -456,55 +362,13 @@ const emailMsgMapped = {
return {
...contentObj,
status: contentObj?.status || 'failed',
key: contentObj.email || contentObj.from || '',
content: `${contentObj.email || contentObj.from || '未知邮箱'} ${contentObj?.error?.message || ''}`,
content: `${contentObj.email} ${contentObj?.error?.message || ''}`,
title: (contentObj.status === 'failed') ? `接收邮件失败` : '',
type: (contentObj.status === 'failed') ? 'warning' : 'info',
};
},
}
}
const sessionMsgMapped = {
'session.new': {
getMsg: (result) => {
// sessionItem 是数组
return isEmpty(result?.sessionItem)
? null
: result.sessionItem.map((ele) => ({
...ele,
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
msg_source: 'session',
msg_direction: 'inbound',
}))
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
},
'session.updated': {
getMsg: (result) => {
// sessionItem 是数组
return isEmpty(result?.sessionItem)
? []
: result.sessionItem.map((ele) => ({
...ele,
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
msg_source: 'session',
msg_direction: 'inbound',
// last_message: {...ele.last_message, text: { body: ele.last_message?.text_body || '', preview_url: null }},
}))
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
},
}
export const msgStatusRenderMapped = {
'accepted': 'waiting', // 'sent', // 接口的发送请求
'sent': 'sent',
@ -530,7 +394,7 @@ export const receivedMsgTypeMapped = {
...msgcontent,
actionId: msgcontent.actionId,
id: msgcontent.wamid,
status: msgStatusRenderMapped[(msgcontent?.status || 'accepted')],
status: msgStatusRenderMapped[(msgcontent?.status || 'failed')],
conversationid: msgcontent.actionId.split('.')[0], // msgcontent.conversation.sn,
date: msgcontent.createTime,
sender: 'me',
@ -541,8 +405,6 @@ export const receivedMsgTypeMapped = {
getMsg: (result) => result,
contentToRender: () => null,
contentToUpdate: (msgcontent) => {
if (isEmpty(msgcontent)) return null;
if (isEmpty(msgcontent.error)) return null;
let apiErrorCode,
apiErrorMsg = '';
const waCode = msgcontent.error.message.match(/\(#(\d+)\)/);
@ -564,7 +426,6 @@ export const receivedMsgTypeMapped = {
},
},
...cloneDeep(emailMsgMapped),
...cloneDeep(sessionMsgMapped),
};
/**
* 消息类型处理, 合并各渠道类型
@ -575,17 +436,15 @@ export const whatsappMsgTypeMapped = {
error: {
type: (_m) => ({ type: 'system' }),
data: (msg) => ({ id: msg.wamid, text: msg.errorCode ? msg.errorMessage : msg.text.body }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.errorCode ? msg.errorMessage : msg.text.body }),
},
system: {
type: 'system',
data: (msg) => ({ id: msg.wamid, text: msg.system?.body }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg?.text?.body || msg?.text }),
},
text: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: autoLinkText(msg?.text?.body), originText: msg?.text?.body, title: msg?.customerProfile?.name || '', }), // msg?.from ||
renderForReply: (msg) => ({ id: msg.wamid, message: msg?.text?.body || msg?.text }),
data: (msg) => ({ id: msg.wamid, text: autoLinkText(msg?.text?.body), originText: msg?.text?.body, title: msg?.customerProfile?.name || msg?.from || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg?.text?.body }),
},
image: {
type: 'photo',
@ -616,7 +475,7 @@ export const whatsappMsgTypeMapped = {
width: 'auto',
height: 200,
alt: msg.image?.caption || '',
message: msg.image?.caption || '[图片]',
message: msg.image?.caption || '',
}),
},
sticker: {
@ -691,23 +550,13 @@ export const whatsappMsgTypeMapped = {
// unsupported: { type: 'system', data: (msg) => ({ text: 'Message type is currently not supported.' }) },
unsupported: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: `[对方删除消息](${msg.wamid})`, dateString: `${dayjs(msg.sendTime).format('MM-DD HH:mm')} [ WhatsApp未提供消息内容 ] 客人删除消息/会话` }),
renderForReply: (msg) => ({ id: msg?.wamid || msg.id, message: `[Message type unsupported](${msg.wamid})` }),
},
unresolvable: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: `[无法解析](${msg.wamid})`, }),
renderForReply: (msg) => ({ id: msg?.wamid || msg.id, message: `[无法解析](${msg.wamid})` }),
data: (msg) => ({ id: msg.wamid, text: `[暂不支持此消息类型](${msg.wamid})`, dateString: `${dayjs(msg.sendTime).format('MM-DD HH:mm')} [ WhatsApp未提供消息内容 ] 可能是客人删除消息/会话, \n可询问客人截图/详细内容 或 忽略 📌` }),
renderForReply: (msg) => ({ id: msg?.wamid || msg.id, text: `[Message type unsupported](${msg.wamid})` }),
},
reaction: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: msg.reaction?.emoji || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.reaction?.emoji || '' }),
},
button: {
type: 'text', // todo: 后端返回 type='button' button: { payload, text }
data: (msg) => ({ id: msg.wamid, text: msg.button?.payload || msg.button?.text || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.button?.payload || msg.button?.text || '' }),
renderForReply: (msg) => ({ id: msg.wamid, text: msg.reaction?.emoji || '' }),
},
document: {
type: 'file',
@ -715,7 +564,7 @@ export const whatsappMsgTypeMapped = {
id: msg.wamid,
title: msg.document?.filename || '',
text: msg.document?.caption || msg.document?.filename || '',
data: { uri: msg.document?.link, status: { click: false, download: true, loading: 0 } },
data: { uri: msg.document.link, status: { click: false, download: true, loading: 0 } },
originText: msg.document?.caption || msg.document?.filename || '',
}),
renderForReply: (msg) => ({
@ -742,11 +591,11 @@ export const whatsappMsgTypeMapped = {
type: 'location',
data: (msg) => ({
id: msg.wamid,
title: `位置信息 ${msg.location?.name || ''} 已转高德地图, ↓点击打开`,
text: msg.location?.address, // 地址
// src: `https://uri.amap.com/marker?position=${msg.location?.longitude},${msg.location?.latitude}&callnative=1`,
title: `位置信息 ${msg.location.name || ''} 已转高德地图, ↓点击打开`,
text: msg.location.address, // 地址
// src: `https://uri.amap.com/marker?position=${msg.location.longitude},${msg.location.latitude}&callnative=1`,
src: 'https://cdn.pixabay.com/photo/2016/03/22/04/23/map-1272165_1280.png',
href: `https://uri.amap.com/marker?position=${msg.location?.longitude},${msg.location?.latitude}&callnative=1`,
href: `https://uri.amap.com/marker?position=${msg.location.longitude},${msg.location.latitude}&callnative=1`,
data: {
longitude: msg.location?.longitude,
latitude: msg.location?.latitude,
@ -761,12 +610,12 @@ export const whatsappMsgTypeMapped = {
template: {
type: 'text',
data: (msg) => {
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [(v.type).toLowerCase()]: v }), {}) : {};
return { id: msg.wamid, text: autoLinkText(templateDataMapped?.body?.text || `......${(templateDataMapped?.body?.parameters || []).map(pv => pv?.text || '').join('......')}......`), title: '模板消息', }; // msg.template.name
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [v.type]: v }), {}) : {};
return { id: msg.wamid, text: autoLinkText(templateDataMapped?.body?.text || `......${(templateDataMapped?.body?.parameters || []).map(pv => pv?.text || '').join('......')}......`), title: msg.template.name };
},
renderForReply: (msg) => {
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [(v.type).toLowerCase()]: v }), {}) : null;
return { id: msg.wamid, message: templateDataMapped?.body?.text || templateDataMapped?.body?.parameters?.[0]?.text || '', title: '模板消息', }; // `${msg.template.name}`
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [v.type]: v }), {}) : null;
return { id: msg.wamid, message: templateDataMapped?.body?.text || templateDataMapped?.body?.parameters?.[0]?.text || '', title: `${msg.template.name}` };
},
},
email: {
@ -788,8 +637,6 @@ export const parseRenderMessageItem = (msg) => {
// console.log('parseRenderMessageItem', msg);
const thisMsgType = Object.keys(whatsappMsgTypeMapped).includes(msg.type) ? msg.type : 'unsupported';
return {
...msg,
opi_sn: msg.opi_sn || '',
msgOrigin: msg,
date: msg?.sendTime || msg?.createTime || '',
...(whatsappMsgTypeMapped?.[thisMsgType]?.data(msg) || {}),
@ -800,15 +647,14 @@ export const parseRenderMessageItem = (msg) => {
dateString: dayjs(msg?.sendTime || msg.createTime).format('MM-DD HH:mm'),
from: msg.from,
sender: msg.from,
senderName: msg.msg_direction === 'outbound' ? 'me' : msg?.customerProfile?.name || msg?.fromName || msg?.from || '',
senderName: msg?.customerProfile?.name || msg?.fromName || msg?.from || 'me',
customer_name: msg?.customerProfile?.name || '',
whatsapp_name: msg?.customerProfile?.name || '',
whatsapp_phone_number: isEmpty(msg?.customerProfile) ? msg.to : msg.from,
// whatsapp_msg_type: msg.msg_source==='WABA' ? msg.type : '',
// whatsapp_msg_type: (msg.msg_source || 'WABA') === 'WABA' ? msg.type : '', // 1.0接口没有msg_source
statusCN: msgStatusRenderMappedCN[msg?.status || 'accepted'],
statusTitle: msgStatusRenderMappedCN[msg?.status || 'accepted'],
replyButton: !['accepted', 'waiting', 'failed'].includes(msg?.status || '') ,
whatsapp_msg_type: msg.msg_source==='WABA' ? msg.type : '',
statusCN: msgStatusRenderMappedCN[msg?.status || 'failed'],
statusTitle: msgStatusRenderMappedCN[msg?.status || 'failed'],
replyButton: ['text', 'document', 'image', 'email'].includes(msg.type) && (msg?.status || '') !== 'failed',
...((isEmpty(msg.context) && isEmpty(msg.reaction)) || msg.context?.forwarded === true // || isEmpty(msg.messageorigin)
? {}
: {
@ -825,16 +671,6 @@ export const parseRenderMessageItem = (msg) => {
},
origin: msg.context,
}),
msg_source: msg?.msg_source || msg.type,
...((msg.msg_source) === 'WABA' ? {
whatsapp_msg_type: msg.type,
waba: msg.msg_direction === 'outbound' ? msg.from : msg.to,
wabaName: WABAccountsMapped[msg.msg_direction === 'outbound' ? msg.from : msg.to]?.verifiedName,
} : {
whatsapp_msg_type: '',
waba: '',
wabaName: '',
}),
};
};
/**
@ -856,28 +692,25 @@ export const parseRenderMessageList = (messages) => {
// }
}
const msgContent = typeof msgtext === 'string' ? JSON.parse(msgContentString) : (msgtext || {});
const msgType = isEmpty(msgContent) ? msg.msgtype : (Object.keys(whatsappMsgTypeMapped).includes(msgContent.type) ? msgContent.type : 'unresolvable')
const msgType = isEmpty(msgContent) ? msg.msgtype : (Object.keys(whatsappMsgTypeMapped).includes(msgContent.type) ? msgContent.type : 'unsupported')
msgContent.template = msg.msgtype === 'template' ? { ...msgContent.template, ...template } : {};
// const parseMethod = msgContent.bizType === 'whatsapp' ? cloneDeep(whatsappMsgTypeMapped) : {};
let waCode, waError = '';
if ((msgContent?.status || 'accepted') === 'failed' && (msgContent.errorMessage || msg.errors_code) && msg.msg_direction === 'outbound') {
(waCode = (msgContent.errorMessage || msg.errors_code).match(/\(#(\d+)\)/));
(waError = (whatsappError?.[waCode?.[1]] || whatsappError?.[msgContent.errorCode] || msgContent.errorMessage || whatsappError?.[msg.errors_code]));
if ((msgContent?.status || 'failed') === 'failed' && msgContent.errorMessage && msg.msg_direction === 'outbound') {
(waCode = msgContent.errorMessage.match(/\(#(\d+)\)/));
(waError = (whatsappError?.[waCode?.[1]] || whatsappError?.[msgContent.errorCode] || msgContent.errorMessage));
if (!isEmpty(msgContent.whatsappApiError)) {
waError = whatsappError?.[msgContent.whatsappApiError.code] || msgContent.whatsappApiError.message;
// waError += `\n[${msgContent.errorCode}] ${whatsappError?.[msgContent.errorCode] || msgContent.errorMessage}`;
}
if ((msgContent.errorMessage || msg.errors_code).includes('Invalid E.146 phone number')) {
if (msgContent.errorMessage.includes('Invalid E.146 phone number')) {
waError = whatsappError.INVALID_PHONE_NUMBER;
}
}
const msgTypeData = whatsappMsgTypeMapped?.[msgType]?.data(msgContent) || {};
return {
...msg,
msgOrigin: { ...msgContent, ...msgContent.email },
// id: msg.id || msgContent.wamid || msgContent.id,
...msgTypeData,
id: msgTypeData?.id || msg.sn,
...(whatsappMsgTypeMapped?.[msgType]?.data(msgContent) || {}),
type: msgContent.type,
...(typeof whatsappMsgTypeMapped[msgType].type === 'function' ? whatsappMsgTypeMapped[msgType].type(msg) : { type: whatsappMsgTypeMapped[msgType].type || 'text' }),
date: msg.msgtime, // msgContent?.sendTime || msg.msgtime || '',
@ -887,20 +720,17 @@ export const parseRenderMessageList = (messages) => {
from: msgContent.from,
sender: msgContent.from,
senderName: msgContent?.customerProfile?.name || msgContent.from || 'me',
replyButton: !['accepted', 'waiting', 'failed'].includes(msgContent?.status || '') ,
replyButton: ['text', 'document', 'image', 'email'].includes(msgContent.type) && (msgContent?.status || '') !== 'failed',
// 用forwarded表示Resend, 与Reply互斥
forwarded: msg.msg_direction === 'outbound' && msg.msg_source === 'email' && ['email'].includes(msgContent.type) && (msgContent?.status || 'accepted') === 'failed',
forwarded: msg.msg_direction === 'outbound' && msg.msg_source === 'email' && ['email'].includes(msgContent.type) && (msgContent?.status || 'failed') === 'failed',
...(msg.msg_direction === 'outbound'
? {
sender: 'me',
senderName: 'me',
status: msgStatusRenderMapped[msgContent?.status || 'accepted'],
dateString: msgStatusRenderMapped[msgContent?.status || 'accepted'] === 'failed' ? `${(msg.msgtime || '').replace('T', ' ')} 发送失败 ${waError}` : '',
statusCN: msgStatusRenderMappedCN[msgContent?.status || 'accepted'],
statusTitle: msgStatusRenderMappedCN[msgContent?.status || 'accepted'],
id: (msgContent?.status || 'accepted') === 'failed' ? (msgContent.actionId || msgContent.id) : (msgTypeData.id || msg.id || msg.sn),
actionId: msgContent.actionId,
replyButton: !['accepted', 'waiting', 'failed'].includes(msgContent?.status || 'accepted') ,
status: msgStatusRenderMapped[msgContent?.status || 'failed'],
dateString: msgStatusRenderMapped[msgContent?.status || 'failed'] === 'failed' ? `${(msg.msgtime || '').replace('T', ' ')} 发送失败 ${waError}` : '',
statusCN: msgStatusRenderMappedCN[msgContent?.status || 'failed'],
statusTitle: msgStatusRenderMappedCN[msgContent?.status || 'failed'],
}
: {}),
...(isEmpty(messageorigin) && (isEmpty(msgContent.context) || msgContent.context?.forwarded === true)
@ -922,19 +752,7 @@ export const parseRenderMessageList = (messages) => {
}),
// conversationid: conversationid,
// title: msg.customerProfile.name,
// whatsapp_msg_type: (msg.msg_source || 'WABA') === 'WABA' ? msgContent.type : '', // 1.0接口没有msg_source
whatsapp_msg_type: '',
waba: '',
wabaName: '',
...((msg.msg_source) === 'WABA' ? {
whatsapp_msg_type: msgContent.type,
waba: msg.msg_direction === 'outbound' ? msgContent.from : msgContent.to,
wabaName: WABAccountsMapped[msg.msg_direction === 'outbound' ? msgContent.from : msgContent.to]?.verifiedName,
} : {}),
...((msg.msg_source) === 'wai' ? {
whatsapp_msg_type: msgContent.type,
wabaName: '个人号',
} : {}),
whatsapp_msg_type: (msg.msg_source || 'WABA') === 'WABA' ? msgContent.type : '', // 1.0接口没有msg_source
}
});
};
@ -947,12 +765,12 @@ export const whatsappError = {
'100': '参数错误, 请联系技术组',
'FORBIDDEN': '[FORBIDDEN] ',
'4': '[4] 无法连接WhatsApp.\n请稍后重试', // (#4) Application request limit reached
'131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用 邮件/个人WhatsApp 联系',
'131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用邮件联系',
'131047': '[131047] 会话未激活. \n请使用模板消息💬发送',
'131053': '[131053] 文件上传失败.',
'131048': '[131048] 账户被风控.', // 消息发送太多, 达到垃圾数量限制
'131049': '[131049] 号码触发风控. \n请暂停发送营销消息, 使用"触达率高"模板\n或引导客户主动发起会话.', // 消息发送太多, 营销限制
'131031': '[131031] 账户已被禁用.',
'131049': '[131049] 号码触发风控. \n请暂停发送营销消息, 引导客户主动发起会话.', // 消息发送太多, 营销限制
'131031': '[131031] 账户已锁定.',
'130472': '[130472] 此号码不接收商业号消息\n请使用邮件联系 或 引导客户主动发起会话.',
};

@ -2,7 +2,6 @@ import { webSocket } from 'rxjs/webSocket';
import { of, timer, concatMap, EMPTY, takeWhile, concat } from 'rxjs';
import { filter, buffer, map, tap, retryWhen, retry, delay, take, catchError } from 'rxjs/operators';
import { v4 as uuid } from 'uuid';
import { logWebsocket } from '@/utils/indexedDB';
export class RealTimeAPI {
constructor(param, onOpenCallback, onCloseCallback, onRetryCallback) {
@ -105,12 +104,6 @@ export class RealTimeAPI {
}
sendMessage(messageObject) {
// console.log(
// `%c websocket Message OUT ⬆`,
// 'background:#41b883 ; padding: 1px; border-radius: 3px; color: #fff',
// JSON.stringify(messageObject, null, 2),
// );
logWebsocket(messageObject, 'O');
this.webSocket.next(messageObject);
}

@ -46,7 +46,7 @@ const DnDModal = ({ children, open, setOpen, onCancel, onMove, onResize, initial
maskClosable={false}
// theme='dark'
// className={'!border !border-solid !border-indigo-500 rounded !p-2' }
titleBarClassName={`!bg-neutral-100 !rounded !rounded-b-none !border-none !p-3 !font-bold !text-slate-600 ${props.titleClassName}`}
titleBarClassName='!bg-neutral-100 !rounded !rounded-b-none !border-none !p-3 !font-bold !text-slate-600'
contentClassName='!p-2'
footerClassName='!p-2'
className={`!rounded-t !rounded-b-none !border !border-solid !shadow-heavy ${props.rootClassName}`}
@ -54,7 +54,7 @@ const DnDModal = ({ children, open, setOpen, onCancel, onMove, onResize, initial
initialWidth={(mobile ? window.innerWidth : (initial.width ?? 680))} // window.innerWidth < 680
initialHeight={(mobile ? window.innerHeight : (initial.height ?? 600))} // window.innerHeight < 700
initialTop={mobile ? 0 : (initial.top ?? 74)}
initialLeft={mobile ? 0 : (initial.left ?? (window.innerWidth - 300))}
initialLeft={mobile ? 0 : (initial.left ?? (window.innerWidth - 700))}
title={title}
minimizeButton={<></>}
onMove={onHandleMove}

@ -15,9 +15,7 @@ class ErrorBoundary extends PureComponent {
componentDidCatch(error, info) {
console.error('Sorry, Something went wrong.')
console.error(error)
if (import.meta.env.PROD && window.$pageSpy) {
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload')
}
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
this.setState({ hasError: true, info: error.message })
}

@ -1,7 +1,7 @@
import Icon from '@ant-design/icons';
import ReplyLineSVG from '@/assets/icons/reply-line.svg?react';
import ReplyAllLineSVG from '@/assets/icons/reply-all-fill.svg?react';
import ReplyAllLineSVG from '@/assets/icons/reply-all-line.svg?react';
import AttachmentLineSVG from '@/assets/icons/attachment-line.svg?react';
import AttachmentFillSVG from '@/assets/icons/attachment-fill.svg?react';
// import ShareForwardFillSVG from '@/assets/icons/share-forward-fill.svg?react';
@ -13,12 +13,6 @@ import SendPlaneLineSVG from '@/assets/icons/send-plane-line.svg?react';
import ResendLineSVG from '@/assets/icons/reset-left-line.svg?react';
import EditLineSVG from '@/assets/icons/quill-pen-line.svg?react';
import MailDownloadLineSVG from '@/assets/icons/mail-download-line.svg?react';
import MailOpenLineSVG from '@/assets/icons/mail-open-line.svg?react';
import MailAddLineSVG from '@/assets/icons/mail-add-line.svg?react';
import MailCheckSVG from '@/assets/icons/mail-check-line.svg?react';
import MailUnreadSVG from '@/assets/icons/mail-unread-line.svg?react';
import MailArchiveSVG from '@/assets/icons/archive-2-line.svg?react';
import TextSVG from '@/assets/icons/text.svg?react';
export const ReplyIcon = (props) => <Icon component={ReplyLineSVG} {...props} />;
@ -33,13 +27,6 @@ export const SendPlaneLineIcon = (props) => <Icon component={SendPlaneLineSVG} {
export const ResendIcon = (props) => <Icon component={ResendLineSVG} {...props} />;
export const EditIcon = (props) => <Icon component={EditLineSVG} {...props} />;
export const MailDownloadIcon = (props) => <Icon component={MailDownloadLineSVG} {...props} />;
export const MailOpenIcon = (props) => <Icon component={MailOpenLineSVG} {...props} />;
export const MailAddloadIcon = (props) => <Icon component={MailAddLineSVG} {...props} />;
export const MailCheckIcon = (props) => <Icon component={MailCheckSVG} {...props} />;
export const MailUnreadIcon = (props) => <Icon component={MailUnreadSVG} {...props} />;
export const MailArchiveIcon = (props) => <Icon component={MailArchiveSVG} {...props} />;
export const TextIcon = (props) => <Icon component={TextSVG} {...props} />;
const WABSvg = () => (
<svg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' width='16' height='16'>
@ -70,21 +57,7 @@ const Sent = () => (
)
export const SentIcon = (props) => <Icon component={Sent} {...props} />;
const Waiting = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" height="1em" width="1em"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z" stroke="none"></path></svg>
)
export const WaitingIcon = (props) => <Icon component={Waiting} {...props} />;
const Failed = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" color='#ED1C24' height="1em" width="1em"><path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z" stroke="none"></path></svg>
)
export const FailedIcon = (props) => <Icon component={Failed} {...props} />;
const Filter = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="1em" width="1em" fill="currentColor"><path d="M6.17071 18C6.58254 16.8348 7.69378 16 9 16C10.3062 16 11.4175 16.8348 11.8293 18H22V20H11.8293C11.4175 21.1652 10.3062 22 9 22C7.69378 22 6.58254 21.1652 6.17071 20H2V18H6.17071ZM12.1707 11C12.5825 9.83481 13.6938 9 15 9C16.3062 9 17.4175 9.83481 17.8293 11H22V13H17.8293C17.4175 14.1652 16.3062 15 15 15C13.6938 15 12.5825 14.1652 12.1707 13H2V11H12.1707ZM6.17071 4C6.58254 2.83481 7.69378 2 9 2C10.3062 2 11.4175 2.83481 11.8293 4H22V6H11.8293C11.4175 7.16519 10.3062 8 9 8C7.69378 8 6.58254 7.16519 6.17071 6H2V4H6.17071Z"></path></svg>
)
export const FilterIcon = (props) => <Icon component={Filter} {...props} />;
const Expand = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="1em" width="1em" fill="currentColor"><path d="M18.2072 9.0428 12.0001 2.83569 5.793 9.0428 7.20721 10.457 12.0001 5.66412 16.793 10.457 18.2072 9.0428ZM5.79285 14.9572 12 21.1643 18.2071 14.9572 16.7928 13.543 12 18.3359 7.20706 13.543 5.79285 14.9572Z"></path></svg>);
export const ExpandIcon = (props) => <Icon component={Expand} {...props} />;

@ -45,8 +45,6 @@ import TableCellResizer from './plugins/TableCellResizer';
// import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import FormatPaintPlugin from './plugins/FormatPaint';
import { TextNode, $getRoot, $getSelection, $createParagraphNode } from 'lexical';
import { $generateHtmlFromNodes, $generateNodesFromDOM, } from '@lexical/html';
@ -205,7 +203,6 @@ export default function Editor({ isRichText, isDebug, editorRef, onChange, defau
<EditorRefPlugin editorRef={editorRef} />
<ImagesPlugin />
<InlineImagePlugin />
<FormatPaintPlugin />
<MyOnChangePlugin onChange={onChange}/>
</div>
</div>

@ -16,17 +16,17 @@ const MATCHERS = [
}
);
},
// (text) => {
// const match = EMAIL_MATCHER.exec(text);
// return (
// match && {
// index: match.index,
// length: match[0].length,
// text: match[0],
// url: `mailto:${match[0]}`
// }
// );
// }
(text) => {
const match = EMAIL_MATCHER.exec(text);
return (
match && {
index: match.index,
length: match[0].length,
text: match[0],
url: `mailto:${match[0]}`
}
);
}
];
export default function PlaygroundAutoLinkPlugin() {

@ -1,27 +0,0 @@
import { LexicalCommand, createCommand, TextFormatType } from 'lexical';
export interface CopiedFormat {
textFormatFlags: number; // 从node.getFormat()
style: string; // 从node.getStyle()
// todo: p 标签的样式
}
export interface ActivateFormatPainterPayload {
sticky: boolean;
}
// activate the format painter and copy the current selection's format
export const ACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand<ActivateFormatPainterPayload> =
createCommand('ACTIVATE_FORMAT_PAINTER_COMMAND');
// deactivate the format painter
export const DEACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand<void> =
createCommand('DEACTIVATE_FORMAT_PAINTER_COMMAND');
// dispatched by the plugin to inform UI about state changes
export interface FormatPainterState {
isActive: boolean;
isSticky: boolean;
}
export const FORMAT_PAINTER_STATE_UPDATE_COMMAND: LexicalCommand<FormatPainterState> =
createCommand('FORMAT_PAINTER_STATE_UPDATE_COMMAND');

@ -1,86 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, LexicalEditor, COMMAND_PRIORITY_NORMAL } from 'lexical';
import {
ACTIVATE_FORMAT_PAINTER_COMMAND,
DEACTIVATE_FORMAT_PAINTER_COMMAND,
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
FormatPainterState,
} from './FormatPainterCommands';
const PaintBrushIcon = () => <i className='format painter' />;
export function FormatPainterToolbarButton() {
const [editor] = useLexicalComposerContext();
const [isActive, setIsActive] = useState(false);
const [isSticky, setIsSticky] = useState(false);
const [canCopy, setCanCopy] = useState(false);
// 插件状态
useEffect(() => {
return editor.registerCommand<FormatPainterState>(
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
(payload) => {
setIsActive(payload.isActive);
setIsSticky(payload.isSticky);
return true;
},
COMMAND_PRIORITY_NORMAL,
);
}, [editor]);
// 选区状态
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
setCanCopy(true);
} else {
setCanCopy(false);
}
});
});
}, [editor]);
const handleClick = () => {
if (isActive) {
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
} else if (canCopy) {
editor.dispatchCommand(ACTIVATE_FORMAT_PAINTER_COMMAND, { sticky: false });
}
// * !isActive and !canCopy 什么也不做
};
// 双击 保持激活
const handleDoubleClick = () => {
if (isActive && isSticky) {
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
} else if (canCopy) {
editor.dispatchCommand(ACTIVATE_FORMAT_PAINTER_COMMAND, { sticky: true });
}
};
return (
<button
type="button"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className={`toolbar-item spaced ${isActive ? 'active' : ''}`}
// title={isActive ? (isSticky ? 'Format Painter (Sticky)' : 'Format Painter (Active)') : 'Format Painter'}
title={'格式刷'}
aria-label={isActive ? (isSticky ? 'Deactivate Format Painter (Sticky)' : 'Deactivate Format Painter (Active)') : 'Activate Format Painter'}
disabled={!isActive && !canCopy}
>
<PaintBrushIcon />
{/* <span style={{wordBreak: 'keep-all'}}>格式刷</span> */}
</button>
);
}
{/* <button type='button'
className={'toolbar-item spaced ' + (isActive ? 'active' : '')}
aria-label='Format Painter'>
<i className='format painter' />
</button> */}
export default FormatPainterToolbarButton;

@ -1,267 +0,0 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$getSelection,
$isRangeSelection,
$isTextNode,
TextFormatType,
LexicalEditor,
COMMAND_PRIORITY_NORMAL,
COMMAND_PRIORITY_LOW,
} from 'lexical';
import { $patchStyleText, } from '@lexical/selection';
// $patchStyleText is more efficient for merging styles.
import {
CopiedFormat,
ACTIVATE_FORMAT_PAINTER_COMMAND,
DEACTIVATE_FORMAT_PAINTER_COMMAND,
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
ActivateFormatPainterPayload,
FormatPainterState,
} from './FormatPainterCommands';
// parse style string to object for $patchStyleText
function parseStyleText(style: string): Record<string, string> {
const styleObj: Record<string, string> = {};
style.split(';').forEach((rule) => {
const [key, value] = rule.split(':');
if (key && value && key.trim() && value.trim()) {
styleObj[key.trim()] = value.trim();
}
});
return styleObj;
}
// map format flags to TextFormatType
const textFormatTypeMap: { flag: number; type: TextFormatType }[] = [
{ flag: 1, type: 'bold' },
{ flag: 2, type: 'italic' },
{ flag: 4, type: 'strikethrough' },
{ flag: 8, type: 'underline' },
{ flag: 16, type: 'code' },
{ flag: 32, type: 'subscript' },
{ flag: 64, type: 'superscript' },
];
export function FormatPainterPlugin(): null {
const [editor] = useLexicalComposerContext();
const [copiedFormat, setCopiedFormat] = useState<CopiedFormat | null>(null);
const [isActive, setIsActive] = useState(false);
const [isSticky, setIsSticky] = useState(false);
// 避免多次复制
const isPickingUpRef = useRef(false);
const broadcastState = useCallback(() => {
editor.dispatchCommand(FORMAT_PAINTER_STATE_UPDATE_COMMAND, {
isActive,
isSticky,
});
}, [editor, isActive, isSticky]);
// Update broadcast whenever state changes
useEffect(() => {
broadcastState();
}, [isActive, isSticky, broadcastState]);
// Activate Format Painter (Copy Format)
useEffect(() => {
return editor.registerCommand<ActivateFormatPainterPayload>(
ACTIVATE_FORMAT_PAINTER_COMMAND,
(payload) => {
isPickingUpRef.current = true;
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
const anchorNode = selection.anchor.getNode();
let formatToCopy: CopiedFormat | null = null;
if ($isTextNode(anchorNode)) {
formatToCopy = {
textFormatFlags: anchorNode.getFormat(),
style: anchorNode.getStyle(),
};
} else {
// ? todo: 从第一个字符获取格式
const nodes = selection.getNodes();
for (const node of nodes) {
if ($isTextNode(node)) {
formatToCopy = {
textFormatFlags: node.getFormat(),
style: node.getStyle(),
};
break;
}
}
}
if (formatToCopy) {
setCopiedFormat(formatToCopy);
setIsActive(true);
setIsSticky(payload.sticky);
// console.log('Format Painter Activated. Sticky:', payload.sticky, 'Format:', formatToCopy);
}
}
});
// 鼠标抬起
setTimeout(() => { isPickingUpRef.current = false; }, 50);
return true;
},
COMMAND_PRIORITY_NORMAL,
);
}, [editor]);
// Deactivate Format Painter
useEffect(() => {
return editor.registerCommand<void>(
DEACTIVATE_FORMAT_PAINTER_COMMAND,
() => {
if (!isActive) return false;
setIsActive(false);
setIsSticky(false);
// 不保留
setCopiedFormat(null);
// console.log('Format Painter Deactivated.');
return true;
},
COMMAND_PRIORITY_NORMAL,
);
}, [editor, isActive]);
// 应用复制的格式
const applyFormat = useCallback(() => {
if (!isActive || !copiedFormat || isPickingUpRef.current) {
return false;
}
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) && copiedFormat) {
// console.log('copiedFormat:', copiedFormat, '\ntextFormatTypeMap:', textFormatTypeMap);
// TextNode (bold, italic, ...)
textFormatTypeMap.forEach(fmt => {
if (copiedFormat.textFormatFlags & fmt.flag) {
selection.formatText(fmt.type);
} else {
const currentSelection = $getSelection();
if ($isRangeSelection(currentSelection)) {
textFormatTypeMap.forEach(fmt => {
const shouldHaveFormat = (copiedFormat.textFormatFlags & fmt.flag) > 0;
if (currentSelection.hasFormat(fmt.type) !== shouldHaveFormat) {
currentSelection.formatText(fmt.type);
}
});
}
}
});
// ensure applied
let newSelection = $getSelection();
if ($isRangeSelection(newSelection)) {
textFormatTypeMap.forEach(fmt => {
if (copiedFormat.textFormatFlags & fmt.flag) {
if (!newSelection!.hasFormat(fmt.type)) newSelection!.formatText(fmt.type);
} else {
if (newSelection!.hasFormat(fmt.type)) newSelection!.formatText(fmt.type);
}
});
}
// inline styles (font-family, color, font-size, ...)
const stylesToApply = parseStyleText(copiedFormat.style);
// console.log('inline style', stylesToApply);
if (Object.keys(stylesToApply).length > 0) {
newSelection = $getSelection();
if ($isRangeSelection(newSelection)) {
$patchStyleText(newSelection as any, stylesToApply);
}
} else {
// 清除格式
const selectedNodes = newSelection.getNodes();
selectedNodes.forEach(node => {
if ($isTextNode(node)) {
if (node.getStyle() !== "") {
node.setStyle(""); // 清除
}
}
// todo: <p> node
});
}
// console.log('Format Applied. Sticky:', isSticky);
if (!isSticky) {
setIsActive(false);
}
}
});
return true;
}, [editor, isActive, isSticky, copiedFormat]);
// 鼠标抬起
useEffect(() => {
if (!isActive || !copiedFormat) return;
const editorElement = editor.getRootElement();
if (!editorElement) return;
const handleMouseUp = (event: MouseEvent) => {
if (isPickingUpRef.current) return;
if (editorElement.contains(event.target as Node)) {
// todo: 改为在下一帧更新
setTimeout(() => {
const selection = editor.getEditorState().read($getSelection);
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
applyFormat();
} else if ($isRangeSelection(selection) && selection.isCollapsed()) {
// 折叠的选区, 也应用
// applyFormat();
}
}, 0);
}
};
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
};
}, [editor, isActive, copiedFormat, applyFormat]);
// 按 esc 键取消格式
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [editor, isActive]);
// 鼠标样式
useEffect(() => {
const editorElement = editor.getRootElement();
if (editorElement) {
editorElement.style.cursor = isActive ? 'copy' : 'auto';
}
return () => {
if (editorElement) {
editorElement.style.cursor = 'auto';
}
};
}, [editor, isActive]);
return null;
}
export default FormatPainterPlugin;

@ -15,7 +15,6 @@ import {
$isRangeSelection,
$createParagraphNode,
$getNodeByKey,
$isTextNode,
} from 'lexical';
import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link';
import {
@ -26,14 +25,12 @@ import {
// $wrapNodes,
$isAtNodeEnd,
} from '@lexical/selection';
import { $findMatchingParent, $getNearestNodeOfType, $getNearestBlockElementAncestorOrThrow, mergeRegister } from '@lexical/utils';
import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, $isListNode, ListNode } from '@lexical/list';
import { createPortal } from 'react-dom';
import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from '@lexical/rich-text';
import { $createCodeNode, $isCodeNode, getDefaultCodeLanguage, getCodeLanguages } from '@lexical/code';
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode';
import {$isDecoratorBlockNode} from '@lexical/react/LexicalDecoratorBlockNode';
import {$isTableSelection} from '@lexical/table';
import DropDown, { DropDownItem } from './../ui/DropDown';
import DropdownColorPicker from '../ui/DropdownColorPicker';
import {
@ -42,7 +39,6 @@ import {
// InsertImagePayload,
} from './ImagesPlugin';
import {InsertInlineImageDialog} from './InlineImagePlugin';
import FormatPainterToolbarButton from './FormatPaint/FormatPainterToolbarButton';
import useModal from './../hooks/useModal';
@ -74,19 +70,16 @@ const FONT_FAMILY_OPTIONS = [
const FONT_SIZE_OPTIONS = [
['10px', '10px'],
// ['11px', '11px'],
['11px', '11px'],
['12px', '12px'],
['13px', '13px'],
['14px', '14px'],
// ['15px', '15px'],
['15px', '15px'],
['16px', '16px'],
// ['17px', '17px'],
['17px', '17px'],
['18px', '18px'],
// ['19px', '19px'],
['19px', '19px'],
['20px', '20px'],
['24px', '24px'],
['32px', '32px'],
// ['48px', '48px'],
];
const ELEMENT_FORMAT_OPTIONS = {
@ -98,62 +91,6 @@ const ELEMENT_FORMAT_OPTIONS = {
start: { icon: 'left-align', iconRTL: 'right-align', name: 'Start Align' },
};
// toolbar utils
const clearFormatting = (editor) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
const anchor = selection.anchor;
const focus = selection.focus;
const nodes = selection.getNodes();
const extractedNodes = selection.extract();
if (anchor.key === focus.key && anchor.offset === focus.offset) {
return;
}
nodes.forEach((node, idx) => {
// We split the first and last node by the selection
// So that we don't format unselected text inside those nodes
if ($isTextNode(node)) {
// Use a separate variable to ensure TS does not lose the refinement
let textNode = node;
if (idx === 0 && anchor.offset !== 0) {
textNode = textNode.splitText(anchor.offset)[1] || textNode;
}
if (idx === nodes.length - 1) {
textNode = textNode.splitText(focus.offset)[0] || textNode;
}
/**
* If the selected text has one format applied
* selecting a portion of the text, could
* clear the format to the wrong portion of the text.
*
* The cleared text is based on the length of the selected text.
*/
// We need this in case the selected text only has one format
const extractedTextNode = extractedNodes[0];
if (nodes.length === 1 && $isTextNode(extractedTextNode)) {
textNode = extractedTextNode;
}
if (textNode.__style !== '') {
textNode.setStyle('');
}
if (textNode.__format !== 0) {
textNode.setFormat(0);
$getNearestBlockElementAncestorOrThrow(textNode).setFormat('');
}
node = textNode;
} else if ($isHeadingNode(node) || $isQuoteNode(node)) {
node.replace($createParagraphNode(), true);
} else if ($isDecoratorBlockNode(node)) {
node.setFormat('');
}
});
}
});
};
function dropDownActiveClass(active) {
if (active) {
return 'active dropdown-item-active';
@ -490,27 +427,27 @@ function BlockOptionsDropdownList({ editor, blockType, toolbarRef, setShowBlockO
return (
<div className='dropdown' ref={dropDownRef}>
<button type='button' className='item' onClick={formatParagraph}>
<button className='item' onClick={formatParagraph}>
<span className='icon paragraph' />
<span className='text'>Normal</span>
{blockType === 'paragraph' && <span className='active' />}
</button>
<button type='button' className='item' onClick={formatLargeHeading}>
<button className='item' onClick={formatLargeHeading}>
<span className='icon large-heading' />
<span className='text'>Heading 1</span>
{blockType === 'h1' && <span className='active' />}
</button>
<button type='button' className='item' onClick={formatSmallHeading}>
<button className='item' onClick={formatSmallHeading}>
<span className='icon small-heading' />
<span className='text'>Heading 2</span>
{blockType === 'h2' && <span className='active' />}
</button>
<button type='button' className='item' onClick={formatSmallHeading3}>
<button className='item' onClick={formatSmallHeading3}>
<span className='icon h3' />
<span className='text'>Heading 3</span>
{blockType === 'h3' && <span className='active' />}
</button>
<button type='button' className='item' onClick={formatBulletList}>
<button className='item' onClick={formatBulletList}>
<span className='icon bullet-list' />
<span className='text'>Bullet List</span>
{blockType === 'ul' && <span className='active' />}
@ -555,13 +492,12 @@ function FontDropDown({ editor, value, style, disabled = false }) {
<DropDown
disabled={disabled}
buttonClassName={'toolbar-item ' + style}
buttonLabel={value}
// buttonLabel={value}
buttonIconClassName={style === 'font-family' ? 'icon block-type font-family' : ''}
buttonAriaLabel={buttonAriaLabel}>
{(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map(([option, text]) => (
<DropDownItem
className={`item font-m-${option.replace(/\s+/g, '_')} ${dropDownActiveClass(value === option)} ${style === 'font-size' ? 'fontsize-item' : ''}`}
style={{ fontFamily: style === 'font-family' ? option : undefined, fontSize: style === 'font-size' ? option : undefined }}
onClick={() => handleClick(option)}
key={option}>
<span className='text'>{text}</span>
@ -669,7 +605,6 @@ export default function ToolbarPlugin() {
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const [fontFamily, setFontFamily] = useState('Arial');
const [fontSize, setFontSize] = useState('16px');
const [fontColor, setFontColor] = useState('#000');
const [bgColor, setBgColor] = useState('#fff');
const [elementFormat, setElementFormat] = useState('left');
@ -757,9 +692,6 @@ export default function ToolbarPlugin() {
setFontFamily(
$getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'),
);
setFontSize(
$getSelectionStyleValueForProperty(selection, 'font-size', '16px'),
);
let matchingParent;
if ($isLinkNode(parent)) {
// If node is a link, we need to fetch the parent paragraph node to set format
@ -834,8 +766,8 @@ export default function ToolbarPlugin() {
}, [editor]);
return (
<div className='toolbar sticky top-[-10px] z-10' ref={toolbarRef}>
<button type='button'
<div className='toolbar' ref={toolbarRef}>
<button
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND);
@ -844,7 +776,7 @@ export default function ToolbarPlugin() {
aria-label='Undo'>
<i className='format undo' />
</button>
<button type='button'
<button
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND);
@ -853,18 +785,10 @@ export default function ToolbarPlugin() {
aria-label='Redo'>
<i className='format redo' />
</button>
<FormatPainterToolbarButton />
<button type='button'
onClick={() => clearFormatting(activeEditor)}
className='toolbar-item'
title="清除格式"
aria-label='Clear'>
<i className='format clear' />
</button>
<Divider />
{supportedBlockTypes.has(blockType) && (
<>
<button type='button' className='toolbar-item block-controls' onClick={() => setShowBlockOptionsDropDown(!showBlockOptionsDropDown)} aria-label='Formatting Options'>
<button className='toolbar-item block-controls' onClick={() => setShowBlockOptionsDropDown(!showBlockOptionsDropDown)} aria-label='Formatting Options'>
<span className={'icon block-type ' + blockType} />
<span className='text'>{blockTypeToBlockName[blockType]}</span>
<i className='chevron-down' />
@ -890,14 +814,8 @@ export default function ToolbarPlugin() {
value={fontFamily}
editor={editor}
/>
<FontDropDown
disabled={!isEditable}
style={'font-size'}
value={fontSize}
editor={editor}
/>
<Divider />
<button type='button'
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
@ -905,7 +823,7 @@ export default function ToolbarPlugin() {
aria-label='Format Bold'>
<i className='format bold' />
</button>
<button type='button'
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
@ -913,7 +831,7 @@ export default function ToolbarPlugin() {
aria-label='Format Italics'>
<i className='format italic' />
</button>
<button type='button'
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
@ -921,7 +839,7 @@ export default function ToolbarPlugin() {
aria-label='Format Underline'>
<i className='format underline' />
</button>
<button type='button'
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
@ -929,7 +847,7 @@ export default function ToolbarPlugin() {
aria-label='Format Strikethrough'>
<i className='format strikethrough' />
</button>
{/* <button type='button'
{/* <button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
}}
@ -938,11 +856,11 @@ export default function ToolbarPlugin() {
>
<i className="format code" />
</button> */}
<button type='button' onClick={insertLink} className={'toolbar-item spaced ' + (isLink ? 'active' : '')} aria-label='Insert Link'>
<button onClick={insertLink} className={'toolbar-item spaced ' + (isLink ? 'active' : '')} aria-label='Insert Link'>
<i className='format link' />
</button>
{isLink && createPortal(<FloatingLinkEditor editor={editor} />, document.body)}
<button type='button' onClick={insertHorizontalRule}
<button onClick={insertHorizontalRule}
// onClick={() => {
// editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined);
// }}

@ -80,7 +80,7 @@
.editor-input {
min-height: 150px;
resize: none;
font-size: 16px;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
@ -404,7 +404,6 @@ pre::-webkit-scrollbar-thumb {
padding: 8px;
cursor: pointer;
vertical-align: middle;
word-break: keep-all;
}
.toolbar button.toolbar-item:disabled {
@ -431,8 +430,7 @@ pre::-webkit-scrollbar-thumb {
}
.toolbar button.toolbar-item.active {
/* background-color: rgba(223, 232, 250, 0.8); */
background-color: #eef2ff;
background-color: rgba(223, 232, 250, 0.3);
}
.toolbar button.toolbar-item.active i {
@ -477,12 +475,12 @@ pre::-webkit-scrollbar-thumb {
.toolbar .toolbar-item .text {
display: flex;
line-height: 20px;
/* width: 200px; */
width: 200px;
vertical-align: middle;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
width: 3rem;
width: 70px;
overflow: hidden;
height: 20px;
text-align: left;
@ -606,8 +604,7 @@ i.chevron-down {
background-size: contain;
}
button.item.dropdown-item-active {
/* background-color: #dfe8fa4d; */
background-color: #eef2ff;
background-color: #dfe8fa4d;
}
.dropdown .item:first-child {
@ -764,9 +761,6 @@ i.undo {
i.redo {
background-image: url(/images/icons/arrow-clockwise.svg);
}
i.clear{
background-image: url(/images/icons/eraser-line.svg);
}
.icon.paragraph {
background-image: url(/images/icons/text-paragraph.svg);
@ -842,10 +836,6 @@ i.outdent {
background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-indent-right'%3e%3cpath%20d='M2%203.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm10.646%202.146a.5.5%200%200%201%20.708.708L11.707%208l1.647%201.646a.5.5%200%200%201-.708.708l-2-2a.5.5%200%200%201%200-.708l2-2zM2%206.5a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e")
}
i.painter {
background-image: url(/images/icons/brush-3-line.svg);
}
i.bold {
background-image: url(/images/icons/type-bold.svg);
}

@ -29,13 +29,12 @@ export function DropDownItem({
children,
className,
onClick,
title, style
title,
}: {
children: React.ReactNode;
className: string;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
title?: string;
style?: React.CSSProperties;
}) {
const ref = useRef<HTMLButtonElement>(null);
@ -54,7 +53,7 @@ export function DropDownItem({
}, [ref, registerItem]);
return (
<button style={style}
<button
className={className}
onClick={onClick}
ref={ref}

@ -20,7 +20,7 @@
background-color: rgba(40, 40, 40, 0.6);
flex-grow: 0px;
flex-shrink: 1px;
z-index: 1202;
z-index: 100;
}
.Modal__modal {
padding: 20px;

@ -1,356 +0,0 @@
import {
WhatsAppOutlined,
FileAddOutlined,
MailOutlined,
PhoneOutlined,
UserOutlined,
FieldNumberOutlined,
CompassOutlined,
CalendarOutlined,
EditOutlined,
CheckOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { App, Flex, Select, Tooltip, Divider, Typography, Skeleton, Checkbox, Drawer, Button, Form, Input } from 'antd'
import { useOrderStore, fetchSetRemindStateAction, OrderLabelDefaultOptions, OrderStatusDefaultOptions, remindStatusOptions } from '@/stores/OrderStore'
import { copy, isEmpty } from '@/utils/commons'
import { useShallow } from 'zustand/react/shallow'
import useConversationStore from '@/stores/ConversationStore'
import useAuthStore from '@/stores/AuthStore'
const OrderProfile = ({ coliSN, ...props }) => {
const { notification, message } = App.useApp()
const [formComment] = Form.useForm()
const [formWhatsApp] = Form.useForm()
const [formExtra] = Form.useForm()
const [loading, setLoading] = useState(false)
const [openOrderCommnet, setOpenOrderCommnet] = useState(false)
const [openWhatsApp, setOpenWhatsApp] = useState(false)
const [openExtra, setOpenExtra] = useState(false)
const orderLabelOptions = copy(OrderLabelDefaultOptions)
orderLabelOptions.unshift({ value: 0, label: '未设置', disabled: true })
const orderStatusOptions = copy(OrderStatusDefaultOptions)
const [orderDetail, customerDetail, fetchOrderDetail, setOrderPropValue, appendOrderComment, updateWhatsapp, updateExtraInfo] = useOrderStore((s) => [
s.orderDetail,
s.customerDetail,
s.fetchOrderDetail,
s.setOrderPropValue,
s.appendOrderComment,
s.updateWhatsapp,
s.updateExtraInfo,
])
const loginUser = useAuthStore((state) => state.loginUser)
const currentOrder = useConversationStore(useShallow((state) => state.currentConversation?.coli_sn || ''))
const orderId = coliSN || currentOrder
const [orderRemindState, setOrderRemindState] = useState(orderDetail.remindstate)
useEffect(() => {
setOrderRemindState(orderDetail.remindstate)
}, [orderDetail.remindstate])
useEffect(() => {
if (orderId) {
setLoading(true)
fetchOrderDetail(orderId)
.finally(() => setLoading(false))
.catch((reason) => {
notification.error({
message: '查询出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}
return () => {}
}, [orderId])
const handleSetRemindState = async (checkedValue) => {
const state = checkedValue.filter((v) => v !== orderRemindState)
const oldState = orderRemindState
try {
if (isEmpty(state)) {
setOrderRemindState(null)
} else {
setOrderRemindState(state[0])
}
await fetchSetRemindStateAction({ coli_sn: coliSN, remindstate: state })
message.success('设置成功')
} catch (error) {
notification.warning({ message: '设置失败', description: error.message, placement: 'top', duration: 60 })
setOrderRemindState(oldState)
}
}
const getCustomerName = () => {
if (orderDetail.buytime > 0) return customerDetail.name + '(R' + orderDetail.buytime + ')'
return customerDetail.name
}
const getPlanStatus = () => {
return orderDetail.DidPlan === 0 ? '未做计划' : '已做计划'
}
return (
<>
<Skeleton active loading={loading}>
<Flex gap='small' vertical={true} justify='space-between' className='p-2'>
<Typography.Text>
<FieldNumberOutlined className='pr-1' />
{orderDetail.order_no}
</Typography.Text>
<Typography.Text>
<UserOutlined className=' pr-1' />
{getCustomerName()}
</Typography.Text>
<Typography.Text>
<CompassOutlined className=' pr-1' />
{orderDetail.MEI_Country}
</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' />
{isEmpty(customerDetail.whatsapp_phone_number) ? (
<Button type='text' onClick={() => setOpenWhatsApp(true)} size='small'>
设置 WhatsApp
</Button>
) : (
<Link to={`/order/chat/${coliSN}`} state={{...orderDetail, coli_guest_WhatsApp: customerDetail.whatsapp_phone_number, }}>
{customerDetail.whatsapp_phone_number}
</Link>
)}
</Typography.Text>
<Typography.Text>
<Tooltip title='出发日期'>
<CalendarOutlined className='pr-1' />
{orderDetail.COLI_OrderStartDate}
</Tooltip>
</Typography.Text>
<Typography.Text>
<Tooltip title='计划状态'>
<CheckOutlined className='pr-1' />
{getPlanStatus()}
</Tooltip>
</Typography.Text>
</Flex>
<Divider orientation='left'>
<Typography.Text strong>订单状态</Typography.Text>
</Divider>
<Flex gap='small' vertical={false} justify='space-between'>
<Select
className={`[&_.ant-select-selection-item]:text-gray-950`}
key={'orderlabel'}
size='small'
style={{
width: '100%',
}}
variant='underlined'
onSelect={(value) => {
setOrderPropValue(coliSN, 'orderlabel', value)
.then(() => {
message.success('设置成功')
})
.catch((reason) => {
notification.error({
message: '设置出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}
value={orderDetail.tags}
options={orderLabelOptions}
/>
<Select
className={`[&_.ant-select-selection-item]:text-gray-950`}
key={'orderstatus'}
size='small'
style={{
width: '100%',
}}
variant='underlined'
onSelect={(value) => {
setOrderPropValue(coliSN, 'orderstatus', value)
.then(() => {
message.success('设置成功')
})
.catch((reason) => {
notification.error({
message: '设置出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}
value={orderDetail.states}
options={orderStatusOptions}
/>
</Flex>
<Divider orientation='left'>
<Typography.Text strong>催信</Typography.Text>
</Divider>
<Checkbox.Group key='substatus' className='px-2' value={[orderRemindState]} options={remindStatusOptions} onChange={handleSetRemindState} />
<Divider orientation='left'>
<Typography.Text strong>表单信息</Typography.Text>
<Tooltip title='添加'>
<FileAddOutlined
className='pl-1'
onClick={() => {
setOpenOrderCommnet(true)
}}
/>
</Tooltip>
</Divider>
<p className='p-2 overflow-auto m-0 break-words whitespace-pre-wrap' dangerouslySetInnerHTML={{ __html: orderDetail.order_detail }}></p>
<Divider orientation='left'>
<Typography.Text strong>特殊要求</Typography.Text>
</Divider>
<Typography.Text>{orderDetail.customer_request}</Typography.Text>
<Divider orientation='left'>
<Typography.Text strong>外联备注</Typography.Text>
{/* <Tooltip title=''>
<EditOutlined className='pl-1' />
</Tooltip> */}
</Divider>
<Typography.Text>{orderDetail.wl_memo}</Typography.Text>
<Divider orientation='left'>
<Typography.Text strong>附加信息</Typography.Text>
<Tooltip title='修改'>
<EditOutlined
className='pl-1'
onClick={() => {
formExtra.setFieldsValue({ extra: orderDetail.COLI_Introduction })
setOpenExtra(true)
}}
/>
</Tooltip>
</Divider>
<Typography.Text>{orderDetail.COLI_Introduction}</Typography.Text>
</Skeleton>
<Drawer title='添加表单信息' closable={{ 'aria-label': 'Close Button' }} onClose={() => setOpenOrderCommnet(false)} open={openOrderCommnet}>
<Form
layout={'vertical'}
form={formComment}
initialValues={{ comment: '' }}
scrollToFirstError
onFinish={(values) => {
appendOrderComment(loginUser.userId, orderId, values.comment)
.then(() => {
notification.success({
message: '温性提示',
description: '添加表单信息成功',
})
setOpenOrderCommnet(false)
formComment.setFieldsValue({ comment: '' })
})
.catch((reason) => {
notification.error({
message: '添加出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}>
<Form.Item name='comment' label='表单信息' rules={[{ required: true, message: '请输入表单信息' }]}>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit'>
提交
</Button>
</Form.Item>
</Form>
</Drawer>
<Drawer title='设置 WhatsApp' closable={{ 'aria-label': 'Close Button' }} onClose={() => setOpenWhatsApp(false)} open={openWhatsApp}>
<Form
layout={'vertical'}
form={formWhatsApp}
initialValues={{ number: '' }}
scrollToFirstError
onFinish={(values) => {
updateWhatsapp(orderId, values.number)
.then(() => {
notification.success({
message: '温性提示',
description: '设置 WhatsApp 成功',
})
setOpenWhatsApp(false)
formWhatsApp.setFieldsValue({ number: '' })
})
.catch((reason) => {
notification.error({
message: '设置出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}>
<Form.Item name='number' label='WhatsApp' rules={[{ required: true, message: '请输入 WhatsApp 号码' }]}>
<Input placeholder='国家代码+城市代码+电话号码' />
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit'>
提交
</Button>
</Form.Item>
</Form>
</Drawer>
<Drawer title='设置附加信息' closable={{ 'aria-label': 'Close Button' }} onClose={() => setOpenExtra(false)} open={openExtra}>
<Form
layout={'vertical'}
form={formExtra}
scrollToFirstError
onFinish={(values) => {
updateExtraInfo(orderId, values.extra)
.then(() => {
notification.success({
message: '温性提示',
description: '设置附加信息成功',
})
setOpenExtra(false)
})
.catch((reason) => {
notification.error({
message: '设置出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}>
<Form.Item name='extra' label='附加信息' rules={[{ required: true, message: '请输入附加信息' }]}>
<Input />
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit'>
提交
</Button>
</Form.Item>
</Form>
</Drawer>
</>
)
}
export default OrderProfile

@ -6,40 +6,26 @@
// export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_144'; // prod: Slave
// debug:
// export const API_HOST = 'http://202.103.68.144:8889/v2';
// export const WS_URL = 'ws://202.103.68.144:8888';
// export const WAI_HOST = 'http://47.83.248.120/api/v1'; // 香港服务器
// export const WAI_HOST = 'http://47.254.53.81/api/v1'; // 美国服务器
// export const WAI_HOST = 'http://localhost:3031/api/v1'; // 美国服务器
export const WAI_SERVER_KEY = 'G-STR:WAI_SERVER'
// prod:--------------------------------------------------------------------------------------------------
export const EMAIL_ATTA_HOST = 'https://p9axztuwd7x8a7.mycht.cn/attachment'; // 邮件附件
export const WAI_HOST = 'https://wai-server-qq4qmtq7wc9he4.mycht.cn/api/v1';
export const EMAIL_HOST = 'https://p9axztuwd7x8a7.mycht.cn/mail-server/service-mail';
export const EMAIL_HOST_v3 = 'https://p9axztuwd7x8a7.mycht.cn/mail-server/service-mail';
export const API_HOST = 'https://p9axztuwd7x8a7.mycht.cn/whatsapp_server/v2';
export const API_HOST_V3 = 'https://p9axztuwd7x8a7.mycht.cn/whatsapp_server/v3';
export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_server'; // prod:
export const API_HOST = 'http://202.103.68.144:8889/v2';
export const WS_URL = 'ws://202.103.68.144:8889';
export const EMAIL_HOST = 'http://202.103.68.231:888/service-mail';
// prod:
export const EMAIL_ATTA_HOST = 'https://p9axztuwd7x8a7.mycht.cn/attatchment'; // 邮件附件
// 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 HT3 = process.env.NODE_ENV === 'production' ? 'https://p9axztuwd7x8a7.mycht.cn/ht3' : 'https://p9axztuwd7x8a7.mycht.cn/ht3';
export const DATE_FORMAT = 'YYYY-MM-DD';
export const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export const DATEEND_FORMAT = 'YYYY-MM-DD 23:59';
export const ERROR_IMG = 'https://hiana-crm.oss-accelerate.aliyuncs.com/WAMedia/afe412d4-3acf-4e79-a623-048aeb4d696a.png';
export const OSS_URL_CN = 'https://haina-sale-system.oss-cn-shenzhen.aliyuncs.com/WAMedia/';
export const OSS_URL_AP = 'https://hiana-crm.oss-ap-southeast-1.aliyuncs.com/WAMedia/';
export const OSS_URL = OSS_URL_AP;
export const DEFAULT_CHANNEL = 'waba'; // 默认渠道 waba email wa
export const DEFAULT_WABA = '+8617607730395';
const __BUILD_VERSION__ = `__BUILD_VERSION__`.replace(/"/g, '')
const __BUILD_DATE__ = `__BUILD_DATE__`;
export const BUILD_VERSION = process.env.NODE_ENV === 'production' ? __BUILD_VERSION__ : process.env.NODE_ENV;
export const BUILD_DATE = process.env.NODE_ENV === 'production' ? __BUILD_DATE__ : new Date().toLocaleString();
export const POPUP_FEATURES = 'left=20,top=20,width=1000,height=800,toolbar=no,menubar=no,scrollbars=yes,resizable=yes,location=no,status=no';

@ -31,12 +31,10 @@ const CHAT_ITEM_RECORD = {
};
export function useConversationNewItem() {
const [currentConversation, setCurrentConversation, updateConversationItem] = useConversationStore((state) => [
const [currentConversation, setCurrentConversation] = useConversationStore((state) => [
state.currentConversation,
state.setCurrentConversation,
state.updateConversationItem,
]);
const updateCurrentConversation = useConversationStore((state) => state.updateCurrentConversation);
const conversationsList = useConversationStore((state) => state.conversationsList);
const addToConversationList = useConversationStore((state) => state.addToConversationList);
const userId = useAuthStore((state) => state.loginUser.userId);
@ -79,36 +77,29 @@ export function useConversationNewItem() {
guest_phone: body.phone_number || '',
guest_name: body.name || '',
}
let buildChatItem = {};
const createdNew = await postNewOrEditConversationItem({ ...newChat, opisn: opisn || userId, conversationid });
// addToConversationList([{...createdNew, sn: createdNew.conversationid}]);
// const _list = await fetchConversationsList({ opisn });
// addToConversationList(_list, 'top');
if (!isEmpty(createdNew)) {
buildChatItem = {
// ...CHAT_ITEM_RECORD,
...createdNew,
sn: createdNew.conversationid,
channels: {
email: createdNew.guest_email,
phone_number: createdNew.guest_phone,
whatsapp_phone_number: createdNew.whatsapp_phone_number,
},
conversation_memo: createdNew.guest_name || createdNew.remark,
// lasttime: createdNew.session_creatime,
show_default: createdNew.remark || createdNew.guest_name || createdNew.guest_phone || createdNew.guest_email || createdNew.whatsapp_phone_number || '',
}
}
if (isEmpty(body.conversationid)) {
// const newChat = _list.find((item) => item.sn === createdNew.conversationid)
if (!isEmpty(createdNew)) {
buildChatItem = {
const newChat = createdNew;
if (!isEmpty(newChat)) {
const buildChatItem = {
...CHAT_ITEM_RECORD,
...buildChatItem,
...createdNew,
sn: createdNew.conversationid,
channels: {
email: createdNew.guest_email,
phone_number: createdNew.guest_phone,
whatsapp_phone_number: createdNew.whatsapp_phone_number,
},
conversation_memo: createdNew.remark,
lasttime: createdNew.session_creatime,
show_default: createdNew.remark || createdNew.guest_name || createdNew.guest_phone || createdNew.guest_email || createdNew.whatsapp_phone_number || '',
}
setCurrentConversation(buildChatItem);
updateConversationItem(buildChatItem);
addToConversationList([buildChatItem], 'top');
return ;
}
@ -118,12 +109,6 @@ export function useConversationNewItem() {
// })
return ;
}
if (currentConversation.sn === createdNew.conversationid) {
updateCurrentConversation(buildChatItem);
} else {
updateConversationItem(buildChatItem);
}
};
return { openOrderContactConversation, newConversation };

@ -1,442 +1,49 @@
import { useState, useEffect, useCallback } from 'react'
import { isEmpty, objectMapper, olog, } from '@/utils/commons'
import { readIndexDB } from '@/utils/indexedDB'
import { getEmailDetailAction, postResendEmailAction, getSalesSignatureAction, getEmailOrderAction, queryEmailListAction, searchEmailListAction, getReminderEmailTemplateAction, saveEmailDraftOrSendAction, updateEmailAction, getEmailChangesChannel, EMAIL_CHANNEL_NAME } from '@/actions/EmailActions'
import { App } from 'antd'
import useConversationStore from '@/stores/ConversationStore';
import { msgStatusRenderMapped } from '@/channel/bubbleMsgUtils';
import { POPUP_FEATURES } from '@/config';
import { internalEventEmitter } from '@/utils/EventEmitterService';
export const useEmailSignature = (opi_sn) => {
const [signature, setSignature] = useState('')
const getSignature = useCallback(async () => {
if (isEmpty(Number(opi_sn))) {
return false
}
try {
const data = await getSalesSignatureAction({ opi_sn })
setSignature(data)
} catch (err) {
console.error(err)
}
}, [opi_sn])
useEffect(() => {
getSignature()
}, [getSignature])
return { signature }
}
import { useState, useEffect } from 'react'
import { isEmpty } from '@/utils/commons'
import { getEmailDetailAction, postResendEmailAction } from '@/actions/EmailActions'
import { App, Button, Divider, Avatar } from 'antd'
/**
* 邮件详情
*
* @param mai_sn 邮件编号ID
* @param data 直接传递, 不重复获取
* * 在详情点击`回复`呼出编辑时
* @param {number|boolean} oid 订单ID
* - If `number`: 直接传递, 直接获取订单详情
* - If `false`: 不需要获取订单信息
*/
export const useEmailDetail = (mai_sn=0, data={}, oid=0, markRead=false) => {
export const useEmailDetail = (mai_sn, data) => {
const {notification} = App.useApp()
const [loading, setLoading] = useState(false)
const [mailData, setMailData] = useState({ loading, info: { MAI_COLI_SN: 0 }, content: '', attachments: [], AttachList: [] })
const [maiSN, setMaiSN] = useState(mai_sn);
const [coliSN, setColiSN] = useState(oid);
const [orderDetail, setOrderDetail] = useState({});
const [refreshTrigger, setRefreshTrigger] = useState(0);
const refresh = useCallback(() => {
setRefreshTrigger(prev => prev + 1);
}, []);
// console.log(maiSN, 'mailSN', mai_sn)
// const [updateMessageItem] = useConversationStore(state => [state.updateMessageItem]);
const getEmailDetail = useCallback(async () => {
if (isEmpty(Number(maiSN)) && isEmpty(Number(mai_sn))) {
return false
}
const [mailData, setMailData] = useState({ loading, info: {}, content: '', attachments: [] })
if (!isEmpty(data)) {
setMailData(data)
return false
}
try {
setLoading(true)
const data = await getEmailDetailAction({ mai_sn: Number(mai_sn) || maiSN })
// console.log(data)
setMailData(data)
setColiSN(oid === false ? 0 : data.info.MAI_COLI_SN)
setLoading(false)
// `已读`
if (markRead !== false && data.info?.MOI_ReadState !== 1) {
updateEmailAction({
opi_sn: data.info.MAI_OPI_SN,
mai_sn_list: [Number(mai_sn) || maiSN],
set: { read: 1 },
})
useEffect(() => {
const getEmailDetail = async () => {
if (isEmpty(mai_sn)) {
return false
}
try {
setLoading(true)
const data = await getEmailDetailAction({ mai_sn })
setMailData(data)
setLoading(false)
} catch (err) {
setLoading(false)
notification.error({
message: "请求失败",
description: err.message || '网络异常',
placement: "top",
duration: 3,
});
}
} catch (err) {
setLoading(false)
notification.error({
message: '请求失败',
description: err.message || '网络异常',
placement: 'top',
duration: 3,
})
}
}, [mai_sn, maiSN, refreshTrigger])
const getOrderDetail = useCallback(async () => {
// console.log(coliSN, '====colisn======')
if (isEmpty(Number(coliSN))) {
return false
}
try {
setLoading(true)
const data = await getEmailOrderAction({ colisn: coliSN })
setOrderDetail(data)
setLoading(false)
} catch (err) {
setLoading(false)
notification.error({
message: '请求失败',
description: err.message || '网络异常',
placement: 'top',
duration: 3,
})
}
}, [coliSN])
useEffect(() => {
getEmailDetail();
}, [getEmailDetail])
useEffect(() => {
getOrderDetail()
}, [getOrderDetail])
if (isEmpty(data)) getEmailDetail()
else setMailData(data)
}, [mai_sn])
const postEmailResend = async ({ mai_sn, conversationid: externalid, actionId: actionid, ...body }) => {
if (isEmpty(Number(mai_sn))) {
if (isEmpty(mai_sn)) {
return false
}
await postResendEmailAction({ mai_sn, externalid, actionid, token: 0 })
}
const postEmailSaveOrSend = async (body, isDraft) => {
const { id: savedID } = await saveEmailDraftOrSendAction(body, isDraft)
setMaiSN(savedID)
if (isDraft) {
refresh()
}
return savedID
};
return { loading, mailData, orderDetail, postEmailResend, postEmailSaveOrSend }
}
export const EmailBuilder = ({subject, content}) => {
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><title></title><meta http-equiv="Content-Type" content="text/html charset=UTF-8" /><meta content="width=device-width" name="viewport"><meta charset="UTF-8"><meta content="IE=edge" http-equiv="X-UA-Compatible"><meta content="width=device-width" name="viewport"><meta charset="UTF-8"><meta content="IE=edge" http-equiv="X-UA-Compatible"><meta content="telephone=no,address=no,email=no,date=no,url=no" name="format-detection"><meta content="light" name="color-scheme"><meta content="light" name="supported-color-schemes"><style id="font">body#highlights-email{ font-family: Verdana, sans-serif;} table{ border-collapse: collapse; border-spacing: 0;} </style></head><body id="highlights-email" style="margin: 0 auto; padding: 0; width: 900px; background-color: #fcfcfc;">${content}</body></html>`;
return { loading, mailData, postEmailResend }
}
const objectToFeatureString = (obj) =>
obj && typeof obj === 'object'
? Object.entries(obj)
.map(([key, value]) => `${key}=${typeof value === 'boolean' ? (value ? 'yes' : 'no') : String(value)}`)
.join(',')
: '';
export const openPopup = (url, target, extraFeatures = {}) => {
let features2 = objectToFeatureString(extraFeatures)
let screenWidth = window.screen.availWidth;
let screenHeight = window.screen.availHeight;
features2 += extraFeatures?.fullscreen === true ? `,width=${screenWidth},height=${screenHeight}` : ''
window.open(url, target, POPUP_FEATURES+`,${features2}`);
};
export const useEmailList = (mailboxDirNode) => {
const [loading, setLoading] = useState(false)
const [mailList, setMailList] = useState([])
const [error, setError] = useState(null)
const [isFreshData, setIsFreshData] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [tempBreadcrumb, setTempBreadcrumb] = useState(null);
const refresh = useCallback(() => {
setRefreshTrigger((prev) => prev + 1)
}, [])
const { OPI_SN: opi_sn, COLI_SN, VKey, VParent, ApplyDate, OrderSourceType, IsTrue } = mailboxDirNode
const markAsUnread = useCallback(
async (sn_list) => {
// 优化性能的话,需要更新 mailList 数据,
// 但是更新 mailList 会造成页面全部刷新
// 所以还是先用 refresh()
// const updatedMailList = mailList.map(mail => {
// if (sn_list.includes(mail.MAI_SN)) {
// return { ...mail, MOI_ReadState: 1 };
// }
// return mail;
// });
// setMailList(updatedMailList);
// setLoading(true)
await updateEmailAction({
opi_sn: opi_sn,
mai_sn_list: sn_list,
set: { read: 0 },
})
},
[VKey],
)
const markAsProcessed = useCallback(
async (sn_list) => {
await updateEmailAction({
opi_sn: opi_sn,
mai_sn_list: sn_list,
set: { processed: 1 },
})
},
[VKey],
)
const markAsDeleted = useCallback(
async (sn_list) => {
await updateEmailAction({
opi_sn: opi_sn,
mai_sn_list: sn_list,
set: { delete: 1 },
})
},
[VKey],
)
const loadMailListFromCache = useCallback(async (payload) => {
const cacheKey = isEmpty(COLI_SN) ? `dir-${VKey}` : `order-${VKey}`
const readCacheIDList = await readIndexDB(cacheKey, 'maillist', 'mailbox')
if (!isEmpty(readCacheIDList)) {
const readCacheListRowsMap = await readIndexDB(readCacheIDList.data, 'listrow', 'mailbox')
const _x = readCacheIDList.data.map((ele) => readCacheListRowsMap.get(ele).data || {})
setMailList(_x)
setLoading(false)
}
}, [VKey])
const getMailList = useCallback(async () => {
// console.log('getMailList', mailboxDirNode)
if (!opi_sn || !VKey || (!IsTrue && !COLI_SN)) {
setMailList([])
setLoading(false)
setError(null)
setIsFreshData(false)
return
}
setTempBreadcrumb(null)
setLoading(true)
setError(null)
setIsFreshData(false)
// const cacheKey = isEmpty(COLI_SN) ? `dir-${VKey}` : `order-${VKey}`
try {
// 1. 先从缓存读取
await loadMailListFromCache();
// 2. 从接口获取最新的列表
const nodeParam = { coli_sn: COLI_SN, order_source_type: OrderSourceType, vkey: VKey, vparent: VParent, mai_senddate1: ApplyDate }
const x = await queryEmailListAction({ opi_sn, node: nodeParam })
// 配合List的结构
const _x = x.map((ele) => ({
...ele,
key: ele.MAI_SN,
}))
setMailList(_x)
} catch (networkError) {
setError(new Error(`Failed to get mail list: ${networkError.message}.`))
} finally {
setLoading(false)
}
}, [VKey, refreshTrigger])
useEffect(() => {
getMailList()
// --- Setup Internal Event Listener ---
const handleInternalUpdate = (event) => {
// console.log(`🔔[useEmailList] Received internal event. `, event.detail)
if (isEmpty(event.detail)) {
return false;
}
const { type, } = event.detail
if (type === 'listrow') {
loadMailListFromCache()
}
if (type === 'maillist-search-result') {
const { data, query } = event.detail
setMailList(data)
setTempBreadcrumb([{title: '查找邮件:'+query, iconIndex: 'search'}]);
}
}
internalEventEmitter.on(EMAIL_CHANNEL_NAME, handleInternalUpdate)
// --- Setup BroadcastChannel Listener ---
const channel = getEmailChangesChannel()
const handleMessage = (event) => {
// console.log(`[useEmailList] Received channel event. `, event.data)
if (isEmpty(event.data)) {
return false;
}
const { type, } = event.data
const cacheKey = isEmpty(COLI_SN) ? `dir-${VKey}` : `order-${VKey}`
if (type === 'listrow' && cacheKey === event.data.listKey) {
// cacheKey 不相同时, 不需要更新; 邮箱目录不相同
loadMailListFromCache(event.data)
}
if (type === 'maillist-search-result') {
// 搜索的结果不需要更新所有页面
// const { data } = event.detail
// setMailList(data)
}
}
channel.addEventListener('message', handleMessage)
// Cleanup
return () => {
internalEventEmitter.off(EMAIL_CHANNEL_NAME, handleInternalUpdate)
channel.removeEventListener('message', handleMessage)
}
}, [getMailList])
return { loading, isFreshData, error, mailList, tempBreadcrumb, refresh, markAsUnread, markAsProcessed, markAsDeleted }
}
const orderMailTypes = new Map([
['48001', '发送一催'],
['48002', '发送二催'],
['48003', '发送三催'],
['48004', '发送已收客人付款通知'],
['48005', '发送FAQ及PreSurvey'],
['48006', '发送降价邮件'],
['48007', '发送亚马逊津贴'],
['48008', '发送报价信'],
['48009', '提交调度'],
['48010', '发送确认信'],
['48011', '发送余款提醒信'],
['48012', '发送入境提醒'],
['48013', '首站关怀'],
['48014', '抵桂面谈'],
['48015', '末站关怀'],
['48016', '发送计划'],
['48017', '报价中'],
['48018', '以后联系'],
['48019', '成行'],
['48020', '团款收讫'],
['48021', '取消'],
['48022', '丢失'],
['48023', '已做计划'],
['48024', '一催回复'],
['48025', '二催回复'],
['48026', '第一次报价回复'],
['48027', '发送商务感谢信'],
['48028', '商务酒店房满邮件'],
['48029', '商务预订传真'],
['48030', '商务取消邮件'],
['48031', '商务单团财务表'],
['48032', '商务收款单'],
['48033', '商务退款单'],
['48034', '商务财务转帐申请'],
['48035', '商务未订先确认'],
['48036', '商务等待确认'],
['48037', '商务已确认'],
['48038', '商务已付款'],
['48039', '商务成功订单'],
['48040', '商务取消订单'],
['48041', '商务不成行订单'],
['48042', '商务已付款未出票'],
['48043', '商务已付款并出票'],
['48044', '商务无效订单'],
['48045', '问候提醒'],
['48046', '发送邮件'],
['48047', '来华一周年问候'],
['48048', '来华二周年提醒'],
['48049', 'CH Phone询问邮件'],
['48050', '订单电话销售'],
['48051', '等待付订金'],
['48052', '商务订单预订中'],
['48053', '客户需求调查'],
['48054', '发送TourCredits通知邮件'],
['48055', 'GH海外PostSurvey'],
])
export const emailTemplates = [
{ type: 'RemindOneWL', index: 1, key: '1@RemindOneWL', value: '1@RemindOneWL', label: '一催模板一,询问客人是否收到报价信' },
{ type: 'RemindOneWL', index: 2, key: '2@RemindOneWL', value: '2@RemindOneWL', label: '一催模板二,询问客人是否修改行程' },
{ type: 'divider' },
{ type: 'RemindTwoWL', index: 1, key: '1@RemindTwoWL', value: '1@RemindTwoWL', label: '二催模板一,询问客人对行程的看法' },
{ type: 'RemindTwoWL', index: 2, key: '2@RemindTwoWL', value: '2@RemindTwoWL', label: '二催模板二,表达服务的意识' },
{ type: 'divider' },
{ type: 'RemindThreeWL', index: 1, key: '1@RemindThreeWL', value: '1@RemindThreeWL', label: '三催模板三,强调价格有效期' },
];
export const emailTemplateMap = emailTemplates.reduce((acc, cur) => {
if (cur.type === 'divider') {
return acc
}
acc[cur.key] = cur
return acc
}, {});
/**
*
* @param {object} params - Parameters for the email template request.
* @param {number} [params.coli_sn] - Customer order line item serial number.
* @param {number} [params.lgc] - Language code.
* @param {number} [params.opi_sn] - Order product item serial number.
*/
export const useEmailTemplate = (templateKey, params) => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [templateContent, setTemplateContent] = useState({ mailtype: '', mailtypeName: '', subject: '', mailcontent: '' })
const getTemplateContent = useCallback(async () => {
if (!templateKey || !Number(params.coli_sn) || !Number(params.opi_sn)) {
setTemplateContent({ mailtype: '', mailtypeName: '', subject: '', mailcontent: '' })
setLoading(false)
setError(null)
return
}
setLoading(true)
setError(null)
try {
const { index: remind_index, type: remind_type } = emailTemplateMap[templateKey] || {};
const _params = { ...params, remind_index, remind_type};
const x = await getReminderEmailTemplateAction(_params)
const lowerCaseShallow = Object.keys(x).reduce((acc, key) => ({...acc, [key.toLowerCase()]: x[key]}), {})
setTemplateContent({...lowerCaseShallow, mailtypeName: orderMailTypes.get(lowerCaseShallow.mailtype)})
} catch (networkError) {
setError(new Error(`Failed to get template content: ${networkError.message}.`))
} finally {
setLoading(false)
}
}, [templateKey, params.opi_sn, params.coli_sn, params.lgc])
useEffect(() => {
getTemplateContent()
}, [getTemplateContent])
return { loading, error, templateContent };
}
export const mailboxSystemDirs = [
{ key: 1, value: 1, label: '收件箱' },
{ key: 2, value: 2, label: '未读邮件' },
{ key: 3, value: 3, label: '已发邮件' },
{ key: 4, value: 4, label: '待发邮件' },
{ key: 5, value: 5, label: '草稿' },
{ key: 6, value: 6, label: '垃圾邮件' },
{ key: 7, value: 7, label: '已处理邮件' },
]

@ -1,35 +0,0 @@
import { useEffect } from 'react'
import { isEmpty } from '@/utils/commons'
import { App, notification } from 'antd'
import useConversationStore from '@/stores/ConversationStore'
export const useGlobalNotify = () => {
// const { notification } = App.useApp() // 在AntApp 中App.useApp() 获取不到notification
const [globalNotify, clearGlobalNotify] = useConversationStore((state) => [state.globalNotify, state.clearGlobalNotify])
useEffect(() => {
if (isEmpty(globalNotify)) {
return () => {}
}
// message.info(globalNotify[0].content, 3)
notification.open({
key: globalNotify[0].key,
message: globalNotify[0].title,
description: globalNotify[0].content,
duration: 6,
placement: 'top',
type: globalNotify[0].type,
onClick: () => {
clearGlobalNotify()
},
})
// setTimeout(() => {
// clearGlobalNotify()
// }, 3030)
return () => {}
}, [globalNotify])
return {};
}

@ -17,7 +17,7 @@ import MobileConversation from '@/views/mobile/Conversation'
import MobileChat from '@/views/mobile/Chat'
import CallCenter from '@/views/CallCenter'
import MobileSecondHeader from '@/views/mobile/SecondHeaderWrapper'
import OrderProfile from '@/components/OrderProfile'
import CustomerProfile from '@/views/Conversations/Online/order/CustomerProfile'
import SnippetList from '@/views/accounts/SnippetList'
import GeneratePayment from '@/views/accounts/GeneratePayment'
@ -30,12 +30,6 @@ import DingdingAuthCode from '@/views/dingding/AuthCode'
import useAuthStore from '@/stores/AuthStore'
import '@/assets/index.css'
import CustomerRelation from '@/views/customer_relation/index'
import NewEmail from '@/views/NewEmail'
import EmailDetailWindow from '@/views/EmailDetailWindow'
import { executeDailyCleanupTask, setupDailyMidnightCleanupScheduler } from '@/utils/indexedDB'
useAuthStore.getState().loadUserSession()
const isMobileApp =
@ -64,7 +58,7 @@ const router = createBrowserRouter([
{
element: <MobileSecondHeader />,
children: [
{ path: 'm/order', element: <OrderProfile /> },
{ path: 'm/order', element: <CustomerProfile /> },
{ path: 'callcenter/call', element: <CallCenter /> },
{ path: 'callcenter/call/:phonenumber', element: <CallCenter /> },
],
@ -87,14 +81,8 @@ const router = createBrowserRouter([
{ path: 'chat/unassign', element: <Unassign /> },
{ path: 'callcenter/call', element: <CallCenter /> },
{ path: 'callcenter/call/:phonenumber', element: <CallCenter /> },
{ path: 'customer_relation/index', element: <CustomerRelation /> },
],
},
{ path: 'email/view/:mailid', element: <EmailDetailWindow />},
{ path: 'email/:action/:quoteid/:oid/:templateKey', element: <NewEmail />},
{ path: 'email/:action/:quoteid/:oid', element: <NewEmail />},
{ path: 'email/:action/:quoteid', element: <NewEmail />},
// { path: 'email/new/0/:oid', element: <NewEmail />},
],
},
{
@ -144,13 +132,3 @@ createRoot(root).render(
</ThemeContext.Provider>
</React.Suspense>,
)
// --- Global Setup After React App Mounts ---
// This part will run once when the application script is loaded and executed.
document.addEventListener('DOMContentLoaded', () => {
console.log(`[${new Date().toLocaleTimeString()}] Application fully loaded. Initiating global daily cleanup checks.`);
executeDailyCleanupTask();
setupDailyMidnightCleanupScheduler();
});

@ -3,15 +3,8 @@ import { devtools } from 'zustand/middleware'
import { fetchJSON } from '@/utils/request'
import { isEmpty, isNotEmpty } from '@/utils/commons'
import { API_HOST, BUILD_VERSION } from '@/config'
import { usingStorage } from '@/utils/usingStorage';
export const PERM_MERGE_CONVERSATION = 'merge-conversation'
export const PERM_ASSIGN_NEW_CONVERSATION = 'assign-new-conversation'
export const PERM_USE_EMAL = 'use-email'
export const PERM_USE_WHATSAPP = 'use-whatsapp'
export const PERM_IMPORT_EMAIL = 'import-email'
const WAI_SERVER_KEY = 'G-STR:WAI_SERVER'
const useAuthStore = create(devtools((set, get) => ({
loginUser: {
@ -23,30 +16,14 @@ const useAuthStore = create(devtools((set, get) => ({
email: '',
openId: '',
accountList: [],
emailList: [],
whatsAppBusiness: '',
accountName: '',
permissionList: [],
},
loginStatus: 0,
isPermitted: (perm) => {
const { waiServer } = usingStorage(WAI_SERVER_KEY)
const { loginUser } = get()
if (perm === PERM_USE_WHATSAPP) {
return isNotEmpty(waiServer) // ['370', '143', '495', '404', '383', '227'].includes(loginUser.userId)
}
if (perm === PERM_USE_EMAL) {
return true//['501', '466', '599', '495', '143', '370', '639', '513', '654', '404', '383', '227'].includes(loginUser.userId)
}
// 导入邮件消息,需要配置才能使用
if (perm === PERM_IMPORT_EMAIL && window.localStorage.getItem('PERM_IMPORT_EMAIL')) {
return true
}
if (perm === PERM_MERGE_CONVERSATION) {
return ['404', '383', '227'].includes(loginUser.userId)
}
@ -55,24 +32,31 @@ const useAuthStore = create(devtools((set, get) => ({
return ['79', '383', '404', '227'].includes(loginUser.userId)
}
// 以上是 Hardcode 判断
// 动态权限列表参考海外供应商平台实现
// 以下是权限列表从数据库读取后使用的方法
// return this.permissionList.some((value, key, arry) => {
// if (value.indexOf(WILDCARD_TOKEN) > -1) {
// return true;
// }
// if (value === perm) {
// return true;
// }
// return false;
// });
},
login: async (authCode) => {
const { setStorage } = usingStorage()
const { saveUserSession, setLoginStatus } = get()
setLoginStatus(200)
// TODO 正式上线要切换地址
const json = await fetchJSON(
'https://p9axztuwd7x8a7.mycht.cn/dingtalk/dingtalkwork/WhatsAppAuth',
'http://202.103.68.144:889/dingtalk/dingtalkwork/WhatsAppAuth',
//`https://p9axztuwd7x8a7.mycht.cn/dingtalk/dingtalkwork/WhatsAppAuth`,
{ authCode },
)
if (json.errcode === 0 && isNotEmpty(json.result.opisn)) {
// TODO保存个人 WhatsApp 服务器地址
const waiServer = json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].wai_server : ''
setStorage('G-STR:WAI_SERVER', waiServer)
set(() => ({
loginUser: {
userId: json.result.opisn,
@ -90,8 +74,7 @@ const useAuthStore = create(devtools((set, get) => ({
backup: item.Isbakemail == 1,
}
}),
whatsAppBusiness: json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].whatsapp_waba : '',
whatsAppNo: json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].whatsapp_wa : '',
// whatsAppBusiness: json.result.opicode,
accountName: json.result.opicode,
username: json.result.nick,
avatarUrl: json.result.avatarUrl,
@ -149,16 +132,13 @@ const useAuthStore = create(devtools((set, get) => ({
email: '',
openId: '',
accountList: [],
emailList: [],
whatsAppBusiness: '',
accountName: '',
permissionList: [],
},
}))
},
loadUserSession: () => {
let sessionData = window.sessionStorage.getItem('GLOBAL_SALES_LOGIN_USER')
// if (import.meta.env.DEV) sessionData ='{"userId":"155","userIdStr":"155","emailList":[],"whatsAppBusiness":"+8617607730395","whatsAppNo":null,"username":"尹诚诚","avatarUrl":"https://static-legacy.dingtalk.com/media/lADPBE1XYG_HAcDNAgDNAgA_512_512.jpg","mobile":"+86-18507832160","email":"ycc@hainatravel.com","openId":"K8BNXMf8ESSr1DzLVUrX7wiEiE","accountList":[{"OPI_SN":155,"OPI_Code":"YCC","OPI_NameCN":"尹诚诚","OPI_DEI_SN":1,"OPI_NameEN":"Yin Chengcheng"}]}'
let sessionData = window.localStorage.getItem('GLOBAL_SALES_LOGIN_USER')
// if (window.location.hostname === '202.103.68.93' && window.location.port === '4173' && isEmpty(sessionData)) {
// sessionData = `{"userId":"383","userIdStr":"383,609","emailList":[{"opi_sn":383,"mat_sn":760,"email":"lyj@asiahighlights.com","default":false,"backup":false},{"opi_sn":383,"mat_sn":759,"email":"lyj@chinahighlights.com","default":true,"backup":false},{"opi_sn":383,"mat_sn":758,"email":"lyj@hainatravel.com","default":false,"backup":false}],"username":"廖一军","avatarUrl":"https://static-legacy.dingtalk.com/media/lALPBDDrhXr716HNAoDNAoA_640_640.png","mobile":"+86-18777396951","email":"lyj@hainatravel.com","whatsAppBusiness":"8617458471254","openId":"iioljiPmZ4RPoOYpkFiSn7IKAiEiE","accountList":[{"OPI_SN":383,"OPI_Code":"LYJ","OPI_NameCN":"廖一军","OPI_DEI_SN":7,"OPI_NameEN":"Jimmy Liow"},{"OPI_SN":609,"OPI_Code":"LYJAH","OPI_NameCN":"廖一军ah","OPI_DEI_SN":28,"OPI_NameEN":"Jimmy Liow"}]}`
@ -185,11 +165,18 @@ const useAuthStore = create(devtools((set, get) => ({
)
},
setWhatsAppProfile: async (userId, whatsAppBusiness, whatsAppNo) => {
copyUserSession: () => {
const sessionData = window.sessionStorage.getItem('GLOBAL_SALES_LOGIN_USER')
if (sessionData !== null) {
navigator.clipboard.writeText(sessionData)
}
},
setWhatsAppBusiness: async (userId, whatsAppBusiness) => {
const { loginUser, saveUserSession } = get()
const postWABAUrl = `${API_HOST}/v2/set_whatsapp_info`
const params = {opi_sn: userId, whatsapp_waba: whatsAppBusiness, whatsapp_wa: whatsAppNo.replace(/\D/g, '')};
const params = {opi_sn: userId, whatsapp_waba: whatsAppBusiness};
return fetchJSON(postWABAUrl, params)
.then(json => {
@ -197,7 +184,6 @@ const useAuthStore = create(devtools((set, get) => ({
set(() => ({
loginUser: {
...loginUser,
whatsAppNo: whatsAppNo,
whatsAppBusiness: whatsAppBusiness,
}
}))

@ -1,16 +1,12 @@
import { create } from "zustand";
import { VonageClient, ClientConfig, ConfigRegion, LoggingLevel } from '@vonage/client-sdk'
import { VonageClient } from "@vonage/client-sdk";
import { fetchJSON } from "@/utils/request";
import { prepareUrl, isNotEmpty, } from "@/utils/commons";
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({
region: ConfigRegion.AP,
// apiUrl: "https://api-ap-3.vonage.com", websocketUrl: "wss://ws-ap-3.vonage.com",
// loggingLevel: LoggingLevel.Debug,
}),
client: new VonageClient({ apiUrl: "https://api-ap-3.vonage.com", websocketUrl: "wss://ws-ap-3.vonage.com" }),
call_id: 0,
loading: false,
logs: "",
@ -19,20 +15,11 @@ const callCenterStore = create((set, get) => ({
init_vonage: user_id => {
const { client, log } = get();
set({ loading: true });
// const client = new VonageClient({
// loggingLevel: LoggingLevel.DEBUG,
// region: ConfigRegion.AP,
// })
// update some options after initialization.
// const vonageConfig = new ClientConfig(ConfigRegion.AP);
// client.setConfig(vonageConfig);
const fetchUrl = prepareUrl(VONAGE_URL + "/jwt")
.append("user_id", user_id)
.build();
return fetchJSON(fetchUrl).then(res => {
const json = res.result;
if (json?.status === 200) {
return fetchJSON(fetchUrl).then(json => {
if (json.status === 200) {
let jwt = json.token;
client
@ -43,11 +30,6 @@ const callCenterStore = create((set, get) => ({
.catch(error => {
log("Error creating session: ", error);
});
// debug:
// client
// .getUser('me')
// .then((user) => log('getUser --me', user))
// .catch((error) => log);
client.on("sessionError", reason => {
// After creating a session
@ -74,7 +56,7 @@ const callCenterStore = create((set, get) => ({
} else {
throw new Error("请求jwt失败");
}
set({ loading: false, client });
set({ loading: false });
});
},
@ -95,7 +77,7 @@ const callCenterStore = create((set, get) => ({
if (client) {
set({ loading: true });
client
.serverCall({ to: phone_number, })
.serverCall({ to: phone_number })
.then(callId => {
log("Id of created call: ", callId);
set({ call_id: callId });

@ -1,29 +1,17 @@
import { create } from 'zustand';
import { RealTimeAPI } from '@/channel/realTimeAPI';
import { olog, isEmpty, groupBy, sortArrayByOrder, pick, sortKeys, omit, sortObjectsByKeysMap, merge } from '@/utils/commons';
import { logWebsocket, clean7DaysWebsocketLog } from '@/utils/indexedDB'
import { olog, isEmpty, groupBy } from '@/utils/commons';
import { receivedMsgTypeMapped, handleNotification } from '@/channel/bubbleMsgUtils';
import { fetchConversationsList, fetchTemplates, fetchConversationsSearch, UNREAD_MARK, fetchTags } from '@/actions/ConversationActions';
import { devtools } from 'zustand/middleware';
import { WS_URL, DATETIME_FORMAT } from '@/config';
import dayjs from 'dayjs';
import EmailSlice from './EmailSlice';
const replaceObjectsByKey = (arr1, arr2, key) => {
const map2 = new Map(arr2.map(ele => [ele[key], ele]));
return arr1.map(item => map2.has(item[key]) ? map2.get(item[key]) : item);
const map = new Map(arr2.map(ele => [ele[key], ele]));
return arr1.map(item => map.has(item[key]) ? map.get(item[key]) : item);
}
const sortConversationList = (list) => {
const mergedListMapped = groupBy(list, 'top_state');
const topValOrder = Object.keys(mergedListMapped).filter(ss => ss !== '1').sort((a, b) => b - a);
const pagelist = topValOrder.reduce((r, topVal) => r.concat(mergedListMapped[String(topVal)]), []);
return {
topList: mergedListMapped['1'] || [],
pageList: pagelist,
}
};
// const WS_URL = 'ws://202.103.68.144:8888/whatever/';
// const WS_URL = 'ws://120.79.9.217:10022/whatever/';
const conversationRow = {
@ -38,7 +26,6 @@ const conversationRow = {
customer_name: '',
whatsapp_phone_number: '',
top_state: 0,
session_type: 0,
};
const initialConversationState = {
@ -67,24 +54,16 @@ const initialConversationState = {
msgListLoading: false,
detailPopupOpen: false,
wai: {},
};
const globalNotifySlice = (set) => ({
globalNotify: [],
setGlobalNotify: (notify) => set(() => ({ globalNotify: notify })),
addGlobalNotify: (notify) => set((state) => ({ globalNotify: [notify, ...state.globalNotify] })),
addGlobalNotify: (notify) => set((state) => ({ globalNotify: [...state.globalNotify, notify] })),
removeGlobalNotify: (id) => set((state) => ({ globalNotify: state.globalNotify.filter(item => item.id !== id) })),
clearGlobalNotify: () => set(() => ({ globalNotify: [] })),
})
const waiSlice = (set) => ({
wai: {},
setWai: (wai) => set({ wai }),
});
// 顾问的自定义标签
const tagsSlice = (set) => ({
tags: [],
@ -175,38 +154,28 @@ const websocketSlice = (set, get) => ({
}, 500);
},
handleMessage: (data) => {
// olog('websocket Message IN ⬇', JSON.stringify(data, null, 2));
logWebsocket(data, 'I');
// olog('websocket Messages ----', data);
olog('handleMessage------------------', data);
// console.log(data);
const { updateMessageItem, sentOrReceivedNewMessage, addGlobalNotify, setWai, addToConversationList, updateMailboxCount } = get()
const { updateMessageItem, sentOrReceivedNewMessage, addGlobalNotify } = get();
const { errcode, errmsg, result } = data;
if (!result) {
return false;
}
let resultType = result?.action || result?.type;
let resultType = result?.action || result.type;
if (errcode !== 0) {
// addError('Error Connecting to Server');
resultType = 'error';
}
console.log(resultType, 'result.type');
const msgObj = receivedMsgTypeMapped[resultType].getMsg(result);
const msgRender = receivedMsgTypeMapped[resultType].contentToRender(msgObj);
const msgUpdate = receivedMsgTypeMapped[resultType].contentToUpdate(msgObj);
// console.log('msgRender msgUpdate', msgRender, msgUpdate);
if (['email.updated', 'email.inbound.received',].includes(resultType)) {
updateMailboxCount({ opi_sn: msgObj.opi_sn })
// if (!isEmpty(msgRender)) {
// const msgNotify = receivedMsgTypeMapped[resultType].contentToNotify(msgObj);
// addGlobalNotify(msgNotify);
// }
return false;
}
console.log('msgRender msgUpdate', msgRender, msgUpdate);
if ([
'whatsapp.message.updated', 'message', 'error',
'email.updated', 'wai.message.updated',
].includes(resultType) && !isEmpty(msgUpdate)) {
'email.updated',
].includes(resultType)) {
updateMessageItem(msgUpdate);
}
if (!isEmpty(msgRender)) {
@ -223,28 +192,7 @@ const websocketSlice = (set, get) => ({
const msgNotify = receivedMsgTypeMapped[resultType].contentToNotify(msgObj);
addGlobalNotify(msgNotify);
}
// WhatsApp creds update
if ([
'wai.creds.update'
].includes(resultType)) {
const _data = receivedMsgTypeMapped[resultType].getMsg(result);
setWai(_data)
if (['offline', 'close'].includes(_data.status)) {
const msgNotify = receivedMsgTypeMapped[resultType].contentToNotify(msgObj);
addGlobalNotify(msgNotify);
}
// setTimeout(() => {
// setWai({}); // 60s 后清空
// }, 60_000);
}
// 会话表 更新
if (['session.new', 'session.updated'].includes(resultType)
&& result.webhooksource !== 'email'
) {
const sessionList = receivedMsgTypeMapped[resultType].getMsg(result);
addToConversationList(sessionList || [], 'top')
}
// console.log('handleMessage*******************');
console.log('handleMessage*******************');
},
});
@ -274,29 +222,15 @@ const conversationSlice = (set, get) => ({
* 搜索结果
*/
setConversationsList: (conversationsList) => {
const { activeConversations, currentConversation } = get();
// 让当前会话显示在页面上
let _tmpCurrentMsgs = [];
if (currentConversation.sn) {
// _tmpCurrentMsgs = activeConversations[currentConversation.sn];
}
const { activeConversations, } = get();
const conversationsMapped = conversationsList.reduce((r, v) => ({ ...r, [`${v.sn}`]: [] }), {});
const indexCurrent = currentConversation.sn ? conversationsList.findIndex(ele => Number(ele.sn) === Number(currentConversation.sn)) : -1;
if (indexCurrent !== -1) {
const [currentElement] = conversationsList.splice(indexCurrent, 1);
conversationsList.unshift(currentElement); // Add to top
// const hasCurrent = Object.keys(conversationsMapped).findIndex(sn => Number(sn) === Number(currentConversation.sn)) !== -1;
// conversationsMapped[currentConversation.sn] = _tmpCurrentMsgs;
// conversationsList.unshift(hasCurrent ? )
// hasCurrent ? 0 : conversationsList.unshift(currentConversation);
}
const { topList, pageList } = sortConversationList(conversationsList);
const conversationsTopStateMapped = groupBy(conversationsList, 'top_state');
return set({
topList,
topList: conversationsTopStateMapped[1] || [],
// conversationsList: conversationsTopStateMapped[0],
pageList,
pageList: conversationsTopStateMapped[0] || [],
conversationsList,
activeConversations: { ...conversationsMapped, ...activeConversations }
})
@ -307,7 +241,7 @@ const conversationSlice = (set, get) => ({
return set({ closedConversationsList, activeConversations: { ...activeConversations, ...listMapped } });
},
addToConversationList: (newList, position='top') => {
const { activeConversations, conversationsList, currentConversation } = get();
const { activeConversations, conversationsList, } = get();
// const conversationsIds = Object.keys(activeConversations);
const conversationsIds = conversationsList.map((chatItem) => `${chatItem.sn}`);
const newConversations = newList.filter((conversation) => !conversationsIds.includes(`${conversation.sn}`));
@ -319,27 +253,15 @@ const conversationSlice = (set, get) => ({
const updateList = replaceObjectsByKey(conversationsList, newList, 'sn');
const mergedList = position==='top' ? [...newList, ...withoutNew] : [...updateList, ...newConversations];
const mergedListMsgs = { ...newConversationsMapped, ...activeConversations, };
const needUpdateCurrent = -1 !== newList.findIndex(row => Number(row.sn) === Number(currentConversation.sn));
const updateCurrent = needUpdateCurrent ? { currentConversation: newList.find(row => Number(row.sn) === Number(currentConversation.sn)) } : {};
// 让当前会话显示在页面上
const indexCurrent = currentConversation.sn ? mergedList.findIndex(ele => Number(ele.sn) === Number(currentConversation.sn)) : -1;
if (indexCurrent !== -1 ) {
const [currentElement] = mergedList.splice(indexCurrent, 1);
mergedList.unshift(currentElement); // Add to top
// hasCurrent ? 0 : mergedList.unshift(currentConversation);
}
const refreshTotalNotify = mergedList.reduce((r, c) => r+(c.unread_msg_count === UNREAD_MARK ? 0 : c.unread_msg_count), 0);
const mergedListMapped = groupBy(mergedList, 'top_state');
const { topList, pageList } = sortConversationList(mergedList)
const refreshTotalNotify = mergedList.reduce((r, c) => r+(c.unread_msg_count === UNREAD_MARK ? 0 : c.unread_msg_count), 0);
return set((state) => ({
...updateCurrent,
topList,
pageList,
topList: mergedListMapped[1] || [],
pageList: mergedListMapped[0] || [],
conversationsList: mergedList,
activeConversations: mergedListMsgs,
activeConversations: { ...activeConversations, ...newConversationsMapped },
totalNotify: refreshTotalNotify,
// totalNotify: state.totalNotify + newConversations.map((ele) => ele.unread_msg_count).reduce((acc, cur) => acc + (cur || 0), 0),
}));
@ -349,11 +271,11 @@ const conversationSlice = (set, get) => ({
const targetId = conversation.sn;
const targetIndex = conversationsList.findIndex((ele) => String(ele.sn) === String(targetId));
conversationsList.splice(targetIndex, 1);
const { topList, pageList } = sortConversationList(conversationsList)
const mergedListMapped = groupBy(conversationsList, 'top_state');
return set({
topList,
pageList,
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
conversationsList: [...conversationsList],
activeConversations: { ...activeConversations, [`${targetId}`]: [] },
currentConversation: {},
@ -377,17 +299,12 @@ const conversationSlice = (set, get) => ({
totalNotify: state.totalNotify - (conversation.unread_msg_count || 0),
currentConversation: Object.assign({}, conversation, targetItemFromList),
referenceMsg: {},
// topList: mergedListMapped['1'] || [],
// pageList: mergedListMapped['0'] || [],
// conversationsList: [...conversationsList],
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
conversationsList: [...conversationsList],
}));
},
updateCurrentConversation: (conversation) => {
const { updateConversationItem, currentConversation } = get();
updateConversationItem({...currentConversation, ...conversation})
return set((state) => ({ currentConversation: { ...state.currentConversation, ...conversation } }))
},
updateCurrentConversation: (conversation) => set((state) => ({ currentConversation: { ...state.currentConversation, ...conversation } })),
updateConversationItem: (conversation) => {
const { conversationsList } = get();
const targetId = conversation.sn;
@ -398,11 +315,11 @@ const conversationSlice = (set, get) => ({
...conversation,
})
: null;
const { topList, pageList } = sortConversationList(conversationsList)
const mergedListMapped = groupBy(conversationsList, 'top_state');
return set({
topList,
pageList,
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
conversationsList: [...conversationsList]
});
},
@ -428,17 +345,10 @@ const messageSlice = (set, get) => ({
const targetMsgs = (activeConversations[String(targetId)] || []).map((ele) => {
// 更新状态
// * 已读的不再更新状态, 有时候投递结果在已读之后返回
// if (ele.id === ele.actionId && ele.actionId === message.actionId) {
if (ele.actionId === message.actionId && !isEmpty(ele.actionId) && !isEmpty(message.actionId)) {
// console.log('actionID', message.actionId, ele.actionId)
// WABA: 同步返回, 根据actionId 更新消息的id;
const toUpdateFields = pick(message, ['msgOrigin', 'id', 'status', 'dateString', 'replyButton', 'coli_id', 'coli_sn']);
return { ...ele, ...toUpdateFields, status: ele.status === 'read' ? ele.status : message.status, };
if (ele.id === ele.actionId && ele.actionId === message.actionId) {
return { ...ele, id: message.id, status: ele.status === 'read' ? ele.status : message.status, dateString: message.dateString };
} else if (String(ele.id) === String(message.id)) {
// console.log('id', message.id, ele.id)
// WABA: 异步的后续状态更新, id已更新为wamid
// console.log('coming msg', message.type, message);
// console.log('old msg ele', ele.type, ele);
// console.log('old msg ele', ele);
const renderStatus = message?.data?.status ? { status: { ...ele.data.status, loading: 0, download: true } } : {};
const keepReply = ele.reply ? { reply: ele.reply } : {};
const keepTemplate = ele.template ? { template: ele.template, template_origin: ele.template_origin, text: ele.text } : {};
@ -452,25 +362,124 @@ const messageSlice = (set, get) => ({
targetMsgs.push(message);
}
const targetIndex = conversationsList.findIndex((ele) => String(ele.sn) === String(targetId));
let newConversations = [];
if (targetIndex !== -1) { // 'delivered'
// 更新列表的时间
conversationsList.splice(targetIndex, 1, {
...conversationsList[targetIndex],
last_received_time: message.status === 'received' ? dayjs(message.deliverTime).add(8, 'hours').format(DATETIME_FORMAT) : conversationsList[targetIndex].last_received_time,
conversation_expiretime: message?.conversation?.expireTime || conversationsList[targetIndex].conversation_expiretime || '', // 保留使用UTC时间
});
} else if (targetIndex === -1) {
// 当前客户端不存在的会话
// todo: 设置为当前(在WhatsApp返回号码不一致时)
newConversations = [{
...conversationRow,
...message,
sn: targetId,
opi_sn: currentConversation.opi_sn, // todo: coli sn
last_received_time: message.date,
unread_msg_count: 0,
whatsapp_name: message.to, //message?.senderName || message?.sender || '',
customer_name: message.to, // message?.senderName || message?.sender || '',
conversation_expiretime: message?.conversation?.expireTime || '', // 保留使用UTC时间
whatsapp_phone_number: message.type === 'email' ? null : message.to,
show_default: message.to || '',
last_message: message,
channels: {
"email": message.type === 'email' ? message.from : null,
"phone_number": message.type === 'email' ? null : message.from,
"whatsapp_phone_number": message.type === 'email' ? null : message.from,
},
}];
}
const mergedList = [...newConversations, ...conversationsList]
const mergedListMapped = groupBy(mergedList, 'top_state');
setFilter({ loadNextPage: true });
return set({
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
conversationsList: mergedList,
activeConversations: { ...activeConversations, [String(targetId)]: targetMsgs },
});
},
sentOrReceivedNewMessage: (targetId, message) => {
// msgRender:
// console.log('sentOrReceivedNewMessage', targetId, message)
const { activeConversations, setFilter } = get();
const { activeConversations, conversationsList, currentConversation, totalNotify, setFilter } = get();
const targetMsgs = activeConversations[String(targetId)] || [];
const targetIndex = conversationsList.findIndex((ele) => Number(ele.sn) === Number(targetId));
const lastReceivedTime = (message.type !== 'system' && message.sender !== 'me') ? dayjs(message.date).add(8, 'hours').format(DATETIME_FORMAT) : null;
const newConversation =
targetIndex !== -1
? {
...conversationsList[targetIndex],
last_received_time: lastReceivedTime || conversationsList[targetIndex].last_received_time,
unread_msg_count:
Number(targetId) !== Number(currentConversation.sn) && message.sender !== 'me'
? conversationsList[targetIndex].unread_msg_count + 1
: conversationsList[targetIndex].unread_msg_count,
last_message: message,
}
: {
...conversationRow,
...message,
sn: targetId,
opi_sn: currentConversation.opi_sn, // todo: coli sn
last_received_time: dayjs(message.date).add(8, 'hours').format(DATETIME_FORMAT),
unread_msg_count: message.sender === 'me' ? 0 : 1,
whatsapp_name: message?.senderName || message?.sender || '',
customer_name: message?.senderName || message?.sender || '',
whatsapp_phone_number: message.type === 'email' ? null : message.from,
show_default: message?.senderName || message?.sender || message.from || '',
last_message: message,
channels: {
"email": message.type === 'email' ? message.from : null,
"phone_number": message.type === 'email' ? null : message.from,
"whatsapp_phone_number": message.type === 'email' ? null : message.from,
},
};
conversationsList.splice(targetIndex, 1);
conversationsList.unshift(newConversation);
// console.log('find in list, i:', targetIndex);
// console.log('find in list, chat updated and Top: \n', JSON.stringify(newConversation, null, 2));
// console.log('list updated : \n', JSON.stringify(conversationsList, null, 2));
const mergedListMapped = groupBy(conversationsList, 'top_state');
setFilter({ loadNextPage: true });
const isCurrent = Number(targetId) === Number(currentConversation.sn);
const updatedCurrent = isCurrent
? {
...currentConversation,
last_received_time: dayjs(message.date).add(8, 'hours').format(DATETIME_FORMAT),
conversation_expiretime: dayjs(message.date).add(24, 'hours').format(DATETIME_FORMAT),
last_message: message,
}
: {...currentConversation, last_message: message,};
return set({
currentConversation: updatedCurrent,
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
conversationsList: [...conversationsList],
totalNotify: totalNotify + (message.sender === 'me' ? 0 : 1),
activeConversations: { ...activeConversations, [String(targetId)]: [...targetMsgs, message] },
});
},
});
/**
* Email
*/
const emailSlice = (set, get) => ({
emailMsg: {},
setEmailMsg: (emailMsg) => set({ emailMsg }),
detailPopupOpen: false,
setDetailOpen: (v) => set({ detailPopupOpen: v }),
openDetail: () => set(() => ({ detailPopupOpen: true })),
closeDetail: () => set(() => ({ detailPopupOpen: false })),
})
export const useConversationStore = create(
devtools((set, get) => ({
...initialConversationState,
@ -483,8 +492,7 @@ export const useConversationStore = create(
...tagsSlice(set, get),
...filterSlice(set, get),
...globalNotifySlice(set, get),
...EmailSlice(set, get),
...waiSlice(set, get),
...emailSlice(set, get),
// state actions
addError: (error) => set((state) => ({ errors: [...state.errors, error] })),
@ -492,10 +500,7 @@ export const useConversationStore = create(
// side effects
fetchInitialData: async ({userId, whatsAppBusiness, ...loginUser}) => {
const { addToConversationList, setTemplates, setInitial, setTags,
initMailbox } = get();
initMailbox({ userId, dei_sn: loginUser.accountList[0].OPI_DEI_SN, opi_sn: loginUser.accountList[0].OPI_SN, userIdStr: loginUser.userIdStr })
const { addToConversationList, setTemplates, setInitial, setTags } = get();
const conversationsList = await fetchConversationsList({ opisn: userId });
addToConversationList(conversationsList);

@ -1,41 +0,0 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { fetchJSON, postForm } from '@/utils/request'
import { HT3, EMAIL_HOST } from '@/config'
import { isNotEmpty, prepareUrl } from '@/utils/commons'
export const useCustomerRelationStore = create((set, get) => ({
loading: false,
setLoading: (loading) => set({ loading }),
tasksList: [],
fetchSearchTasks: async (data) => {
set({ loading: true })
const formData = new FormData()
for (const key in data) {
formData.append(key, data[key])
}
fetch(`${HT3}/customerrelation/search_tasks`, {
method: 'POST',
body: formData,
})
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
return res.json()
})
.then((data) => {
set({ tasksList: data })
})
.catch((error) => {
console.error('Fetch error:', error)
})
.finally(() => {
set({ loading: false })
})
},
}))
export default useCustomerRelationStore

@ -1,189 +0,0 @@
import { getEmailDirAction, getMailboxCountAction, getRootMailboxDirAction, getEmailChangesChannel, EMAIL_CHANNEL_NAME } from '@/actions/EmailActions'
import { buildTree, isEmpty, olog, sortArrayByOrder } from '@/utils/commons'
import { readIndexDB, writeIndexDB, createIndexedDBStore, clean7DaysMailboxLog } from '@/utils/indexedDB';
import { internalEventEmitter } from '@/utils/EventEmitterService';
/**
* Email
*/
const emailSlice = (set, get) => ({
emailMsg: { id: -1, conversationid: '', actionId: '', order_opi: '', coli_sn: '', msgOrigin: {} },
setEmailMsg: (emailMsg) => {
const { editorOpen } = get()
return editorOpen ? false : set({ emailMsg }) // 已经打开的不更新
},
detailPopupOpen: false,
setDetailOpen: (v) => set({ detailPopupOpen: v }),
openDetail: () => set(() => ({ detailPopupOpen: true })),
closeDetail: () => set(() => ({ detailPopupOpen: false })),
editorOpen: false,
setEditorOpen: (v) => set({ editorOpen: v }),
openEditor: () => set(() => ({ editorOpen: true })),
closeEditor: () => set(() => ({ editorOpen: false })),
// EmailEditorPopup 组件的 props
// @property {string} fromEmail - 发件人邮箱
// @property {string} fromUser - 发件人用户
// @property {string} fromOrder - 发件订单
// @property {string} toEmail - 收件人邮箱
// @property {string} conversationid - 会话ID
// @property {string} quoteid - 引用邮件ID
// @property {object} draft - 草稿
// @property {string} action - reply / forward / new / edit
// @property {string} oid - coli_sn
// @property {object} mailData - 邮件内容
// @property {string} receiverName - 收件人称呼
emailEdiorProps: new Map(),
setEditorProps: (v) => {
const { emailEdiorProps } = get()
const uniqueKey = v.quoteid || Date.now().toString(32)
const currentEditValue = { ...v, key: `${v.action}-${uniqueKey}` }
const news = new Map(emailEdiorProps).set(currentEditValue.key, currentEditValue)
for (const [key, value] of news.entries()) {
console.log(value)
}
return set((state) => ({ emailEdiorProps: news, currentEditKey: currentEditValue.key, currentEditValue }))
// return set((state) => ({ emailEdiorProps: { ...state.emailEdiorProps, ...v } }))
},
closeEditor1: (key) => {
const { emailEdiorProps } = get()
const newProps = new Map(emailEdiorProps)
newProps.delete(key)
return set(() => ({ emailEdiorProps: newProps }))
},
clearEditor: () => {
return set(() => ({ emailEdiorProps: new Map() }))
},
currentEditKey: '',
setCurrentEditKey: (key) => {
const { emailEdiorProps, setCurrentEditValue } = get()
const value = emailEdiorProps.get(key)
setCurrentEditValue(value)
return set(() => ({ currentEditKey: key }))
},
currentEditValue: {},
setCurrentEditValue: (v) => {
return set(() => ({ currentEditValue: v }))
},
// mailboxNestedDirs: new Map(),
// setMailboxNestedDirs: (opi, dirs) => {
// const { mailboxNestedDirs } = get()
// const news = mailboxNestedDirs.set(opi, dirs)
// return set(() => ({ mailboxNestedDirs: news }))
// },
currentMailboxDEI: 0,
setCurrentMailboxDEI: (id) => {
return set(() => ({ currentMailboxDEI: id }))
},
currentMailboxOPI: 0,
setCurrentMailboxOPI: (id) => {
return set(() => ({ currentMailboxOPI: id }))
},
mailboxNestedDirsActive: [],
setMailboxNestedDirsActive: (dir) => {
return set(() => ({ mailboxNestedDirsActive: dir }))
},
updateCurrentMailboxNestedDirs: (dirs) => {
const { mailboxNestedDirsActive } = get()
const _Map = new Map(mailboxNestedDirsActive.map((obj) => [obj.key, obj]))
dirs.forEach((row) => {
_Map.set(row.key, row)
})
// const _newValue = sortArrayByOrder(Array.from(_Map.values()), 'key', ['search-orders'])
const _newValue = Array.from(_Map.values())
return set(() => ({ mailboxNestedDirsActive: _newValue }))
},
mailboxActiveNode: {},
setMailboxActiveNode: (node) => {
return set(() => ({ mailboxActiveNode: node }))
},
mailboxList: [],
setMailboxList: (list) => {
return set(() => ({ mailboxList: list }))
},
mailboxActiveMAI: 0,
setMailboxActiveMAI: (mai) => {
return set(() => ({ mailboxActiveMAI: mai }))
},
mailboxActiveCOLI: 0,
setMailboxActiveCOLI: (coli) => {
return set(() => ({ mailboxActiveCOLI: coli }))
},
getOPIEmailDir: async (opi_sn = 0, userIdStr = '', refreshNow = false) => {
// console.log('🌐requesting opi dir', opi_sn, typeof opi_sn)
const { setMailboxNestedDirsActive, updateMailboxCount } = get()
const readCache = await readIndexDB(Number(opi_sn), 'dirs', 'mailbox')
// console.log(readCache);
let isNeedRefresh = refreshNow
if (!isEmpty(readCache)) {
setMailboxNestedDirsActive(readCache?.tree || [])
isNeedRefresh = refreshNow || Date.now() - readCache.treeTimestamp > 1 * 60 * 60 * 1000
// isNeedRefresh = true; // test: 0
}
if (isEmpty(readCache) || isNeedRefresh) {
// > {4} 更新
const rootTree = await getRootMailboxDirAction({ opi_sn, userIdStr: String(userIdStr || opi_sn) })
// console.log('empty', opi_sn, userIdStr, isEmpty(readCache), isNeedRefresh, rootTree);
setMailboxNestedDirsActive(rootTree)
} else {
// 只更新数量
updateMailboxCount({ opi_sn })
}
return false
},
/**
* 更新数量
* @usage 1. 邮件列表页切换用户时
* @usage 2. 收到新邮件推送时
*
*/
updateMailboxCount: async ({ opi_sn }) => {
// const { setMailboxNestedDirsActive } = get()
await getMailboxCountAction({ opi_sn })
// const readCache = await readIndexDB(Number(opi_sn), 'dirs', 'mailbox')
// if (!isEmpty(readCache)) {
// setMailboxNestedDirsActive(readCache?.tree || [])
// }
},
async initMailbox({ opi_sn, dei_sn, userIdStr }) {
olog('Initialize Mailbox ---- ')
const { currentMailboxOPI, setCurrentMailboxOPI, setCurrentMailboxDEI, getOPIEmailDir, setMailboxNestedDirsActive, } = get()
createIndexedDBStore(['dirs', 'maillist', 'listrow', 'mailinfo', 'draft'], 'mailbox')
setCurrentMailboxOPI(opi_sn)
setCurrentMailboxDEI(dei_sn)
getOPIEmailDir(opi_sn, userIdStr, true)
// --- Setup Internal Event Listener ---
internalEventEmitter.on(EMAIL_CHANNEL_NAME, async (event) => {
// console.log(`🔔Received internal event. `, event.detail)
if (event.detail && event.detail.type === 'dirs') {
const readCache = await readIndexDB(event.detail.key, 'dirs', 'mailbox')
if (!isEmpty(readCache)) {
setMailboxNestedDirsActive(readCache?.tree || [])
}
}
})
// --- Setup BroadcastChannel Listener ---
const channel = getEmailChangesChannel()
channel.addEventListener('message', async (event) => {
// console.log(`📣Received channel event. `, event.data)
if (event.data.type === 'dirs' && currentMailboxOPI === event.data.key) {
const readCache = await readIndexDB(event.data.key, 'dirs', 'mailbox')
if (!isEmpty(readCache)) {
setMailboxNestedDirsActive(readCache?.tree || [])
}
}
})
},
})
export default emailSlice

@ -12,15 +12,11 @@ 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 }),
EmailList: [],
setEmailList: (EmailList) => set({ EmailList }),
// 订单跟踪页面
orderFollowForm: {
type: 'today',
@ -32,6 +28,6 @@ export const useFormStore = create(
setOrderFollowForm: (orderFollowForm) => set({ orderFollowForm }),
orderFollowAdvanceChecked: false,
setOrderFollowAdvanceChecked: (orderFollowAdvanceChecked) => set({ orderFollowAdvanceChecked }),
}), { name: 'form-store' })
}))
);
export default useFormStore;

@ -1,257 +1,173 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { fetchJSON, postForm, postJSON } from '@/utils/request'
import { API_HOST, API_HOST_V3, EMAIL_HOST } from '@/config'
import { isNotEmpty, prepareUrl, uniqWith } from '@/utils/commons'
import { fetchJSON, postForm } from '@/utils/request'
import { API_HOST, EMAIL_HOST } from '@/config'
import { isNotEmpty, prepareUrl } from '@/utils/commons'
export const useOrderStore = create(devtools((set, get) => ({
const initialState = {
orderList: [],
orderDetail: {},
customerDetail: {},
lastQuotation: {},
quotationList: [],
otherEmailList: [],
}
export const useOrderStore = create(
devtools(
(set, get) => ({
...initialState,
drawerOpen: false,
resetOrderStore: () => set(initialState),
openDrawer: () => {
set(() => ({
drawerOpen: true,
}))
},
closeDrawer: () => {
set(() => ({
drawerOpen: false,
}))
},
fetchOrderList: async (formValues, loginUser) => {
let fetchOrderUrl = `${API_HOST}/getwlorder?opisn=${loginUser.userIdStr}&otype=${formValues.type}`
const params = {}
if (formValues.type === 'advance') {
fetchOrderUrl = `${API_HOST}/getdvancedwlorder?opisn=${loginUser.userIdStr}`
const { type, ...formParams } = formValues
Object.assign(params, formParams)
drawerOpen: false,
openDrawer: () => {
set(() => ({
drawerOpen: true
}))
},
closeDrawer: () => {
set(() => ({
drawerOpen: false
}))
},
fetchOrderList: async (formValues, loginUser) => {
let fetchOrderUrl = `${API_HOST}/getwlorder?opisn=${loginUser.userIdStr}&otype=${formValues.type}`
const params = {};
if (formValues.type === 'advance') {
fetchOrderUrl = `${API_HOST}/getdvancedwlorder?opisn=${loginUser.userIdStr}`;
const { type, ...formParams } = formValues;
Object.assign(params, formParams)
}
return fetchJSON(fetchOrderUrl, params)
.then(json => {
if (json.errcode === 0) {
set(() => ({
orderList: json.result.map((order) => { return { ...order, key: order.COLI_ID } }),
}))
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
return fetchJSON(fetchOrderUrl, params).then((json) => {
if (json.errcode === 0) {
const _result = json.result.map((order) => {
return { ...order, key: order.COLI_ID }
})
const _result_unique = uniqWith(_result, (a, b) => a.COLI_SN === b.COLI_SN)
set(() => ({
orderList: _result_unique,
}))
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
fetchOrderDetail: (colisn) => {
return fetchJSON(`${API_HOST}/getorderinfo`, { colisn }).then((json) => {
if (json.errcode === 0 && json.result.length > 0) {
const orderResult = json.result[0]
set(() => ({
orderDetail: { ...orderResult, coli_sn: colisn },
customerDetail: orderResult.contact.length > 0 ? orderResult.contact[0] : {},
// lastQuotation: orderResult.quotes.length > 0 ? orderResult.quotes[0] : {},
// quotationList: orderResult.quotes,
}))
return {
orderDetail: { ...orderResult, coli_sn: colisn },
customerDetail: orderResult.contact.length > 0 ? orderResult.contact[0] : {},
// lastQuotation: orderResult.quotes.length > 0 ? orderResult.quotes[0] : {},
// quotationList: orderResult.quotes,
}
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
appendOrderComment: async (opi_sn, coli_sn, comment) => {
const { fetchOrderDetail } = get()
const formData = new FormData()
formData.append('opi_sn', opi_sn)
formData.append('coli_sn', coli_sn)
formData.append('remark', comment)
return postForm(`${API_HOST}/remark_order`, formData).then((json) => {
if (json.errcode === 0) {
return fetchOrderDetail(coli_sn)
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
generatePayment: async (formValues) => {
const formData = new FormData()
formData.append('descriptions', formValues.description)
formData.append('currency', formValues.currency)
formData.append('lgc', formValues.langauge)
formData.append('amount', formValues.amount)
formData.append('coli_id', formValues.orderNumber)
formData.append('ordertype', formValues.orderType)
formData.append('opisn', formValues.userId)
formData.append('paytype', 'SYT')
formData.append('wxzh', 'cht')
formData.append('fq', 0)
formData.append('onlyusa', 0)
formData.append('useyhm', 0)
return postForm(`${API_HOST}/generate_payment_links`, formData).then((json) => {
if (json.errcode === 0) {
return json.result
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
fetchHistoryOrder: (userId, email, whatsappid = '') => {
return fetchJSON(`${API_HOST}/query_guest_order`, { opisn: userId, whatsappid, email: email }).then((json) => {
if (json.errcode === 0) {
return json.result
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
importEmailMessage: ({ orderId, orderNumber }) => {
return fetchJSON(`${API_HOST}/generate_email_msg`, { coli_sn: orderId, coli_id: orderNumber }).then((json) => {
if (json.errcode === 0) {
return json
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
batchImportEmailMessage: () => {
const { orderList } = get()
const orderPromiseList = orderList.map((order) => {
return new Promise((resolve, reject) => {
fetchJSON(`${API_HOST}/generate_email_msg`, { coli_sn: order.COLI_SN, coli_id: order.COLI_ID }).then((json) => {
if (json.errcode === 0) {
resolve(json)
} else {
reject(json?.errmsg + ': ' + json.errcode)
}
})
})
})
Promise.all(orderPromiseList).then((values) => {
console.log(values)
})
},
fetchOtherEmail: (coli_sn) => {
return fetchJSON(`${EMAIL_HOST}/email_supplier`, { coli_sn }).then((json) => {
if (json.errcode === 0) {
set(() => ({
otherEmailList: json.result.MailInfo ?? [],
}))
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
updateWhatsapp: (coli_sn, number) => {
return postJSON(`${API_HOST_V3}/order_update`, {
coli_sn: coli_sn,
set: {
concat_whatsapp: number,
},
}).then((json) => {
if (json.errcode > 0) {
throw new Error(json?.errmsg + ': ' + json.errcode)
} else {
set((state) => ({
customerDetail: {
...state.customerDetail,
whatsapp_phone_number: number,
},
}))
})
},
fetchOrderDetail: (colisn) => {
return fetchJSON(`${API_HOST}/getorderinfo`, { colisn })
.then(json => {
if (json.errcode === 0 && json.result.length > 0) {
const orderResult = json.result[0]
set(() => ({
orderDetail: orderResult,
customerDetail: orderResult.contact.length > 0 ? orderResult.contact[0] : {},
lastQuotation: orderResult.quotes.length > 0 ? orderResult.quotes[0] : {},
quotationList: orderResult.quotes,
}))
return {
orderDetail: orderResult,
customerDetail: orderResult.contact.length > 0 ? orderResult.contact[0] : {},
lastQuotation: orderResult.quotes.length > 0 ? orderResult.quotes[0] : {},
quotationList: orderResult.quotes,
}
})
},
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
appendOrderComment: async (opi_sn, coli_sn, comment) => {
const { fetchOrderDetail } = get()
const formData = new FormData()
formData.append('opi_sn', opi_sn)
formData.append('coli_sn', coli_sn)
formData.append('remark', comment)
return postForm(`${API_HOST}/remark_order`, formData)
.then(json => {
if (json.errcode === 0) {
return fetchOrderDetail(coli_sn)
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
generatePayment: async (formValues) => {
const formData = new FormData()
formData.append('descriptions', formValues.description)
formData.append('currency', formValues.currency)
formData.append('lgc', formValues.langauge)
formData.append('amount', formValues.amount)
formData.append('coli_id', formValues.orderNumber)
formData.append('wlemail', formValues.notifyEmail)
formData.append('ordertype', formValues.orderType)
formData.append('opisn', formValues.userId)
formData.append('paytype', 'SYT')
formData.append('wxzh', 'cht')
formData.append('fq', 0)
formData.append('onlyusa', 0)
formData.append('useyhm', 0)
return postForm(`${API_HOST}/generate_payment_links`, formData)
.then(json => {
if (json.errcode === 0) {
return json.result
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
fetchOtherEmail: (coli_sn) => {
return fetchJSON(`${EMAIL_HOST}/email_supplier`, { coli_sn })
.then(json => {
if (json.errcode === 0) {
set(() => ({
otherEmailList: json.result.MailInfo ?? [],
}))
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
updateExtraInfo: (coli_sn, extra) => {
const { orderDetail } = get()
},
return postJSON(`${API_HOST_V3}/order_update`, {
coli_sn: coli_sn,
set: {
extra_info: extra,
},
}).then((json) => {
if (json.errcode > 0) {
throw new Error(json?.errmsg + ': ' + json.errcode)
} else {
set((state) => ({
orderDetail: {
...state.orderDetail,
COLI_Introduction: extra,
},
}))
}
})
},
setOrderPropValue: async (colisn, propName, value) => {
setOrderPropValue: async (colisn, propName, value) => {
if (propName === 'orderlabel') {
set((state) => ({
orderDetail: {
...state.orderDetail,
tags: value,
},
}))
if (propName === 'orderlabel') {
set((state) => ({
orderDetail: {
...state.orderDetail,
tags: value
}
}))
}
if (propName === 'orderstatus') {
set((state) => ({
orderDetail: {
...state.orderDetail,
states: value
}
}))
}
if (propName === 'orderstatus') {
set((state) => ({
orderDetail: {
...state.orderDetail,
states: value,
},
}))
return fetchJSON(`${API_HOST}/setorderstatus`, { colisn, stype: propName, svalue: value })
.then(json => {
if (json.errcode > 0) {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
return fetchJSON(`${API_HOST}/setorderstatus`, { colisn, stype: propName, svalue: value }).then((json) => {
if (json.errcode > 0) {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
}),
{ name: 'orderStore' },
),
)
}), { name: 'orderStore' }))
export const OrderLabelDefaultOptions = [
{ value: 240003, label: '重点', emoji: '❣️' },
{ value: 240002, label: '次重点', emoji: '❗' },
{ value: 240001, label: '一般', emoji: '' },
{ value: 240001, label: '一般', emoji: '' }
]
export const OrderLabelDefaultOptionsMapped = OrderLabelDefaultOptions.reduce((acc, cur) => {
return { ...acc, [String(cur.value)]: cur }
}, {})
}, {}) ;
export const OrderStatusDefaultOptions = [
{ value: 1, label: '新订单', emoji: '' },
@ -260,40 +176,15 @@ export const OrderStatusDefaultOptions = [
{ value: 4, label: '等待付订金', emoji: '🛒' },
{ value: 5, label: '成行', emoji: '💰' },
{ value: 6, label: '丢失', emoji: '🎈' },
// { value: 7, label: '取消', emoji: '🚫' }, // 取消要顾问确认后才能执行操作,暂时到 HT 操作
{ value: 8, label: '未报价', emoji: '' },
{ value: 7, label: '取消', emoji: '🚫' },
{ value: 8, label: '未报价', emoji: '' }
]
export const OrderStatusDefaultOptionsMapped = OrderStatusDefaultOptions.reduce((acc, cur) => {
return { ...acc, [String(cur.value)]: cur }
}, {})
/**
* @useage 订单跟踪:高级搜索
*/
export const RemindStateDefaultOptions = [
{ value: '1', label: '一催' },
{ value: '2', label: '二催' },
{ value: '3', label: '三催' },
{ value: '3', label: '三催' }
]
/**
* @useage 订单信息: 标记状态
*/
export const remindStatusOptions = [
{ value: 1, label: '已发一催' },
{ value: 2, label: '已发二催' },
{ value: 3, label: '已发三催' },
{ value: 'important', label: '重点团' },
{ value: 'sendsurvey', label: '已发 travel advisor survey' },
]
export const remindStatusOptionsMapped = remindStatusOptions.reduce((acc, cur) => {
return { ...acc, [String(cur.value)]: cur }
}, {})
/**
* @param {Object} params { coli_sn, remindstate }
*/
export const fetchSetRemindStateAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/SetRemindState`, params)
return errcode === 0 ? result : {}
}

@ -2,7 +2,7 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { fetchJSON, postForm } from '@/utils/request'
import { API_HOST } from '@/config'
import { copy } from '@/utils/commons'
import { isNotEmpty, copy } from '@/utils/commons'
const useSnippetStore = create(devtools((set, get) => ({

@ -1,20 +0,0 @@
class EventEmitterService extends EventTarget {
constructor() {
super();
// console.log('Internal EventEmitterService created.'); // For debugging
}
emit(eventName, detail) {
this.dispatchEvent(new CustomEvent(eventName, { detail }));
}
on(eventName, handler) {
this.addEventListener(eventName, handler);
}
off(eventName, handler) {
this.removeEventListener(eventName, handler);
}
}
export const internalEventEmitter = new EventEmitterService();

@ -119,26 +119,6 @@ export const sortKeys = (obj) =>
.sort()
.reduce((a, k2) => ({ ...a, [k2]: obj[k2] }), {});
export function sortObjectsByKeysMap(objects, keyOrder) {
if (!objects) return {} // Handle null/undefined input
if (!keyOrder || keyOrder.length === 0) return objects
const objectMap = new Map(Object.entries(objects))
const sortedMap = new Map()
for (const key of keyOrder) {
if (objectMap.has(key)) {
sortedMap.set(key, objectMap.get(key))
objectMap.delete(key) // Optimization: Remove from original map after adding
}
}
// Add remaining keys
for (const [key, value] of objectMap) {
sortedMap.set(key, value)
}
return Object.fromEntries(sortedMap)
}
/**
* 数组排序, 给定排序数组
* @param {array} items 需要排序的数组
@ -226,15 +206,6 @@ export function omit(object, keysToOmit) {
return Object.fromEntries(Object.entries(object).filter(([key]) => !keysToOmit.includes(key)));
}
/**
* 去除无效的值: undefined, null, '', []
* * 只删除 null undefined: flush 方法;
*/
export const omitEmpty = _object => {
Object.keys(_object).forEach(key => (_object[key] == null || _object[key] === '' || _object[key].length === 0) && delete _object[key]);
return _object;
};
/**
* 深拷贝
*/
@ -595,83 +566,3 @@ export const loadScript = (src) => {
});
};
export const TagColorStyle = (tag, outerStyle = false) => {
const color = stringToColour(tag);
const outerStyleObj = outerStyle ? { borderColor: `${color}66`, backgroundColor: `${color}0D` } : {};
return { color: `${color}`, ...outerStyleObj };
};
// 数组去掉重复
export function unique(arr) {
const x = new Set(arr);
return [...x];
}
export const uniqWith = (arr, fn) => arr.filter((element, index) => arr.findIndex((step) => fn(element, step)) === index);
/**
* Creates a new tree node object.
* @param {string} key - The unique identifier for the node.
* @param {string} name - The display name of the node.
* @param {string|null} parent - The key of the parent node, or null if it's a root.
* @returns {object} A plain JavaScript object representing the tree node.
*/
function createTreeNode(key, name, parent = null, keyMap={}, _raw={}) {
return {
key: key,
title: name,
parent: parent,
icon: _raw?.icon,
iconIndex: _raw?.[keyMap.iconIndex],
_raw: _raw,
children: [],
parentTitle: '',
parentIconIndex: '',
};
}
/**
* Builds a tree structure from a flat list of nodes.
* @returns {Array<object>} An array of root tree nodes.
*/
export const buildTree = (list, keyMap={ rootKeys: [], ignoreKeys: [] }) => {
if (!list || list.length === 0) {
return []
}
const nodeMap = new Map()
const treeRoots = []
list.forEach((item) => {
const node = createTreeNode(item[keyMap.key], item[keyMap.name], item[keyMap.parent], keyMap, item)
nodeMap.set(item[keyMap.key], node)
})
list.forEach((item) => {
const node = nodeMap.get(item[keyMap.key])
if (keyMap.rootKeys.includes(item[keyMap.parent]) || item[keyMap.parent] === null || item[keyMap.parent] === undefined) {
// This is a root node
treeRoots.push(node)
} else {
const parentNode = nodeMap.get(item[keyMap.parent])
if (keyMap.ignoreKeys.includes(item[keyMap.parent])) {
const grandParentNode = nodeMap.get(parentNode.parent);
node.rawParent = node.parent;
node.parent = parentNode.parent;
node.parentTitle = parentNode.title;
node.parentIconIndex = parentNode.iconIndex;
grandParentNode.children.push(node)
} else if (keyMap.ignoreKeys.includes(item[keyMap.key])) {
//
}
else if (parentNode) {
node.parentTitle = parentNode.title;
node.parentIconIndex = parentNode.iconIndex;
parentNode.children.push(node)
} else {
console.warn(`Parent with key '${item[keyMap.parent]}' not found for node '${item[keyMap.key]}'. This node will be treated as a root.`)
treeRoots.push(node)
}
}
})
return treeRoots
}

@ -1,586 +0,0 @@
import { isEmpty } from './commons';
/**
*
*/
/**
* 数据库版本
* ! 每次涉及indexedDB的更新都要往上+1
* @type {number}
*/
const INDEXED_DB_VERSION = 5;
export const logWebsocket = (message, direction) => {
var open = indexedDB.open('LogWebsocketData', INDEXED_DB_VERSION)
open.onupgradeneeded = function () {
var db = open.result
// 数据库是否存在
if (!db.objectStoreNames.contains('LogStore')) {
var store = db.createObjectStore('LogStore', { keyPath: 'id', autoIncrement: true })
store.createIndex('timestamp', 'timestamp', { unique: false })
} else {
const logStore = open.transaction.objectStore('LogStore')
if (!logStore.indexNames.contains('timestamp')) {
logStore.createIndex('timestamp', 'timestamp', { unique: false })
}
}
}
open.onsuccess = function () {
var db = open.result
var tx = db.transaction('LogStore', 'readwrite')
var store = tx.objectStore('LogStore')
store.put({ direction, message, _date: new Date().toLocaleString(), timestamp: Date.now() })
tx.oncomplete = function () {
db.close()
}
}
};
export const readWebsocketLog = (limit = 20) => {
return new Promise((resolve, reject) => {
let openRequest = indexedDB.open('LogWebsocketData')
openRequest.onupgradeneeded = function () {
var db = openRequest.result
// 数据库是否存在
if (!db.objectStoreNames.contains('LogStore')) {
var store = db.createObjectStore('LogStore', { keyPath: 'id', autoIncrement: true })
store.createIndex('timestamp', 'timestamp', { unique: false })
} else {
const logStore = openRequest.transaction.objectStore('LogStore')
if (!logStore.indexNames.contains('timestamp')) {
logStore.createIndex('timestamp', 'timestamp', { unique: false })
}
}
}
openRequest.onerror = function (e) {
reject('Error opening database.')
}
openRequest.onsuccess = function (e) {
let db = e.target.result
// 数据库是否存在
if (!db.objectStoreNames.contains('LogStore')) {
resolve('Database does not exist.')
return
}
let transaction = db.transaction('LogStore', 'readonly')
let store = transaction.objectStore('LogStore')
const request = store.openCursor(null, 'prev'); // 从后往前
const results = [];
let count = 0;
request.onerror = function (e) {
reject('Error getting records.')
}
request.onsuccess = function (e) {
const cursor = e.target.result
if (cursor) {
if (count < limit) {
results.unshift(cursor.value)
count++
cursor.continue()
} else {
console.log(JSON.stringify(results))
resolve(results)
}
} else {
console.log(JSON.stringify(results))
resolve(results)
}
}
}
})
};
/**
* @deprecated
*/
export const clearWebsocketLog = () => {
let openRequest = indexedDB.open('LogWebsocketData')
openRequest.onerror = function (e) {}
openRequest.onsuccess = function (e) {
let db = e.target.result
if (!db.objectStoreNames.contains('LogStore')) {
return
}
let transaction = db.transaction('LogStore', 'readwrite')
let store = transaction.objectStore('LogStore')
// Clear the store
let clearRequest = store.clear()
clearRequest.onerror = function (e) {}
clearRequest.onsuccess = function (e) {}
}
}
export const createIndexedDBStore = (tables, database) => {
var open = indexedDB.open(database, INDEXED_DB_VERSION)
open.onupgradeneeded = function () {
// console.log('createIndexedDBStore onupgradeneeded', database, )
var db = open.result
// 数据库是否存在
for (const table of tables) {
if (!db.objectStoreNames.contains(table)) {
var store = db.createObjectStore(table, { keyPath: 'key' })
store.createIndex('timestamp', 'timestamp', { unique: false })
} else {
const objectStore = open.transaction.objectStore(table)
if (!objectStore.indexNames.contains('timestamp')) {
objectStore.createIndex('timestamp', 'timestamp', { unique: false })
}
}
}
}
};
export const writeIndexDB = (rows, table, database) => {
var open = indexedDB.open(database, INDEXED_DB_VERSION)
open.onupgradeneeded = function () {
// console.log('readIndexDB onupgradeneeded', table, )
var db = open.result
// 数据库是否存在
if (!db.objectStoreNames.contains(table)) {
var store = db.createObjectStore(table, { keyPath: 'key' })
store.createIndex('timestamp', 'timestamp', { unique: false })
} else {
const objectStore = open.transaction.objectStore(table)
if (!objectStore.indexNames.contains('timestamp')) {
objectStore.createIndex('timestamp', 'timestamp', { unique: false })
}
}
}
open.onsuccess = function () {
var db = open.result
// 数据库是否存在
if (!db.objectStoreNames.contains(table)) {
console.warn(`writeIndexDB > Database does not exist.`, table);
return
}
var tx = db.transaction(table, 'readwrite')
var store = tx.objectStore(table)
rows.forEach(row => {
store.put({ ...row, _date: new Date().toLocaleString(), timestamp: Date.now() })
});
tx.oncomplete = function () {
db.close()
}
}
};
/**
* Reads data from an IndexedDB object store.
* It can read a single record by key, multiple records by an array of keys, or all records.
*
* @param {string|string[]|null} keys - The key(s) to read.
* - If `string`: Reads a single record and returns the data object directly.
* - If `string[]`: Reads multiple records and returns a Map of `rowkey` to `data` objects.
* - If `null` or `undefined` or `empty string/array`: Reads all records and returns a Map of `rowkey` to `data` objects.
* @param {string} table - The name of the IndexedDB object store (table).
* @param {string} database - The name of the IndexedDB database.
* @returns {Promise<any|Map<string, any>>} A promise that resolves with the data.
* - Single key: Resolves with the data object or `undefined` if not found.
* - Array of keys or All records: Resolves with a `Map` where keys are rowkeys and values are data objects.
* The Map will be empty if no records are found.
* - Rejects if there's an error opening the database or during the transaction.
*/
export const readIndexDB = (keys=null, table, database) => {
return new Promise((resolve, reject) => {
let openRequest = indexedDB.open(database)
openRequest.onupgradeneeded = function () {
// console.log('readIndexDB onupgradeneeded', table, )
var db = openRequest.result
// 数据库是否存在
if (!db.objectStoreNames.contains(table)) {
var store = db.createObjectStore(table, { keyPath: 'key' })
store.createIndex('timestamp', 'timestamp', { unique: false })
} else {
const logStore = openRequest.transaction.objectStore(table)
if (!logStore.indexNames.contains('timestamp')) {
logStore.createIndex('timestamp', 'timestamp', { unique: false })
}
}
}
openRequest.onerror = function (e) {
console.error(`Error opening database.`, table, e)
reject('Error opening database.')
}
openRequest.onsuccess = function (e) {
let db = e.target.result
// 数据库是否存在
if (!db.objectStoreNames.contains(table)) {
console.warn(`readIndexDB > Database does not exist.`, table);
resolve();
return
}
let transaction = db.transaction(table, 'readonly')
let store = transaction.objectStore(table)
// read by key
// Handle array of keys
if (Array.isArray(keys) && keys.length > 0) {
const promises = keys.map(key => {
return new Promise((innerResolve) => {
const getRequest = store.get(key);
getRequest.onsuccess = (event) => {
const result = event.target.result;
if (result) {
// console.log(`💾Found record with key ${key}:`, result);
innerResolve([key, result]); // Resolve with [key, data] tuple
} else {
// console.log(`No record found with key ${key}.`);
innerResolve(void 0); // Resolve with undefined for non-existent keys
}
};
getRequest.onerror = (event) => {
console.error(`Error getting record with key ${key}:`, event.target.error);
innerResolve(undefined); // Resolve with undefined on error, or innerReject if you want to fail fast
};
});
});
Promise.all(promises)
.then(results => {
const resultMap = new Map();
results.forEach(item => {
if (item !== undefined) {
resultMap.set(item[0], item[1]); // item[0] is key, item[1] is data
}
});
resolve(resultMap);
})
.catch(error => {
console.error('Error during batch read:', error);
reject(error); // Reject the main promise if Promise.all encounters an error
});
} else if (!isEmpty(keys)) { // Handle single key
const getRequest = store.get(keys);
getRequest.onsuccess = (event) => {
const result = event.target.result;
if (result) {
// console.log(`💾Found record with key ${keys}:`, result);
resolve(result);
} else {
// console.log(`No record found with key ${keys}.`);
resolve();
}
};
getRequest.onerror = (event) => {
console.error(`Error getting record with key ${keys}:`, event.target.error);
reject(event.target.error);
};
} else { // Handle read all
const getAllRequest = store.getAll();
getAllRequest.onsuccess = (event) => {
const allData = event.target.result;
const resultMap = new Map();
if (allData && allData.length > 0) {
allData.forEach(item => {
resultMap.set(item.key, item);
});
// console.log(`💾Found all records:`, resultMap);
resolve(resultMap);
} else {
// console.log(`No records found.`);
resolve(resultMap); // Resolve with an empty Map if no records
}
};
getAllRequest.onerror = (event) => {
console.error(`Error getting all records:`, event.target.error);
reject(event.target.error);
};
}
}
})
};
export const deleteIndexDBbyKey = (keys=null, table, database) => {
return new Promise((resolve, reject) => {
var open = indexedDB.open(database, INDEXED_DB_VERSION)
open.onupgradeneeded = function () {
// var db = open.result
// // 数据库是否存在
// if (!db.objectStoreNames.contains(table)) {
// var store = db.createObjectStore(table, { keyPath: 'id', autoIncrement: true })
// }
}
open.onsuccess = function (e) {
let db = e.target.result
// 数据库是否存在
if (!db.objectStoreNames.contains(table)) {
console.warn('deleteIndexDBbyKey > Database does not exist.', table)
resolve();
return
}
var tx = db.transaction(table, 'readwrite')
var store = tx.objectStore(table)
if (Array.isArray(keys) && keys.length > 0) {
const promises = keys.map((key) => {
return new Promise((innerResolve) => {
const delRequest = store.delete(key)
delRequest.onsuccess = (event) => {
const result = event.target.result
if (result) {
innerResolve()
} else {
innerResolve(void 0) // Resolve with undefined for non-existent keys
}
}
delRequest.onerror = (event) => {
innerResolve(undefined)
}
})
})
Promise.allSettled(promises)
.then((results) => {
resolve(results)
})
.catch((error) => {
reject(error)
})
} else if (!isEmpty(keys)) { // Handle single key
const delRequest = store.delete(keys);
delRequest.onsuccess = (event) => {
const result = event.target.result;
if (result) {
resolve(result);
} else {
resolve();
}
};
delRequest.onerror = (event) => {
reject(event.target.error);
};
} else {
// 删除所有
let clearRequest = store.clear()
clearRequest.onsuccess = function (e) {
resolve(e.target.result)
}
clearRequest.onerror = function (e) {
reject(e.target.error)
}
}
tx.oncomplete = function () {
db.close()
}
}
})
};
function cleanOldData(database, storeNames=[], dateKey = 'timestamp', keySet = { keyPath: 'key' }) {
return function (daysToKeep = 7) {
return new Promise((resolve, reject) => {
let deletedCount = 0
const recordsToDelete = new Set()
let openRequest = indexedDB.open(database, INDEXED_DB_VERSION)
openRequest.onupgradeneeded = function () {
// console.log('----cleanOldData onupgradeneeded----')
var db = openRequest.result
storeNames.forEach(storeName => {
// 数据库是否存在
if (!db.objectStoreNames.contains(storeName)) {
var store = db.createObjectStore(storeName, keySet)
// var store = db.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true })
store.createIndex('timestamp', 'timestamp', { unique: false })
} else {
const logStore = openRequest.transaction.objectStore(storeName)
if (!logStore.indexNames.contains('timestamp')) {
logStore.createIndex('timestamp', 'timestamp', { unique: false })
}
}
})
}
openRequest.onsuccess = function (e) {
let db = e.target.result
// 数据库是否存在
// if (!db.objectStoreNames.contains(storeName)) {
// resolve('Database does not exist.')
// return
// }
// Calculate the cutoff timestamp for "X days ago"
const cutoffTimestamp = Date.now() - daysToKeep * 24 * 60 * 60 * 1000
const objectStoreNames = isEmpty(storeNames) ? db.objectStoreNames : storeNames
if (!isEmpty(objectStoreNames)) {
const objectStores = Array.from(objectStoreNames).map((storeName) => db.transaction([storeName], 'readwrite').objectStore(storeName))
for (const objectStore of objectStores) {
// Identify old data using the date index and primary key ID
if (!objectStore.indexNames.contains(`${dateKey}`)) {
// Clear the store
let clearRequest = objectStore.clear()
console.log(`Cleanup complete. clear ${objectStore.name} records.`)
resolve()
clearRequest.onerror = function (e) {}
clearRequest.onsuccess = function (e) {}
return
}
// Get records older than 'daysToKeep' using the index
const dateIndex = objectStore.index(`${dateKey}`)
const dateRange = IDBKeyRange.upperBound(cutoffTimestamp, false) // Get keys < cutoffTimestamp (strictly older)
const dateCursorRequest = dateIndex.openCursor(dateRange)
dateCursorRequest.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
recordsToDelete.add(cursor.primaryKey) // Add the primary key of the record to the set
cursor.continue()
} else {
const storeName = objectStore.name;
// Delete identified data in a new transaction
const deleteTransaction = db.transaction([storeName], 'readwrite')
const deleteObjectStore = deleteTransaction.objectStore(storeName)
deleteTransaction.oncomplete = () => {
console.log(`Cleanup complete. Deleted ${deletedCount} records in ${database}.${storeName}.`)
resolve(deletedCount)
}
deleteTransaction.onerror = (event) => {
console.error('Deletion transaction error:', event.target.error)
reject(event.target.error)
}
// Convert Set to Array for forEach
Array.from(recordsToDelete).forEach((key) => {
const deleteRequest = deleteObjectStore.delete(key)
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = (event) => {
console.warn(`Failed to delete record with key ${key}:`, event.target.error)
}
})
}
}
dateCursorRequest.onerror = (event) => {
console.error('Error opening date cursor for deletion:', event.target.error)
reject(event.target.error)
}
}
} else {
console.warn('cleanOldData: No data to delete.', database);
}
}
openRequest.onerror = function (e) {
reject('Error opening database:'+database, e)
}
})
}
}
export const clean7DaysWebsocketLog = cleanOldData('LogWebsocketData', ['LogStore'], 'timestamp', { keyPath: 'id', autoIncrement: true });
export const clean7DaysMailboxLog = cleanOldData('mailbox', ['dirs', 'maillist', 'listrow', 'mailinfo', 'draft']);
/**
* 缓存清除策略: 清理7天前的
* - 每次进入
* - 每天半夜
*/
export const LAST_SCHEDULED_CLEANUP_DAY_KEY = 'lastScheduledCleanupDay'; // For tracking scheduling
export const LAST_EXECUTED_CLEANUP_DAY_KEY = 'lastExecutedCleanupDay'; // For tracking actual execution
let cleanupTimeoutId = null; // To store the ID of the setTimeout
/**
* Determines if the cleanup needs to be scheduled for today.
* This is based on when it was *last scheduled* to prevent re-scheduling
* if the app was merely refreshed within the same day.
* @returns {boolean} True if a new schedule for today is needed.
*/
function shouldScheduleForToday() {
const lastScheduledDay = localStorage.getItem(LAST_SCHEDULED_CLEANUP_DAY_KEY);
const today = new Date().toDateString(); // e.g., "Fri Jun 13 2025"
return !lastScheduledDay || lastScheduledDay !== today;
}
/**
* Determines if the cleanup was already *executed* today.
* This is to prevent running the cleanup task multiple times in one day
* if the app stays open past midnight or if it is refreshed.
* @returns {boolean} True if the cleanup has not executed today.
*/
function hasCleanupExecutedToday() {
const lastExecutedDay = localStorage.getItem(LAST_EXECUTED_CLEANUP_DAY_KEY);
const today = new Date().toDateString();
return lastExecutedDay === today;
}
/**
* Executes the cleanup and updates the last execution timestamp.
* This function is designed to be called via requestIdleCallback.
*/
export async function executeDailyCleanupTask() {
// const lastExecutedDay = localStorage.getItem(LAST_EXECUTED_CLEANUP_DAY_KEY)
const today = new Date().toDateString()
if (!hasCleanupExecutedToday()) {
if ('requestIdleCallback' in window) {
// console.log(`[${new Date().toLocaleTimeString()}] Scheduling cleanup via requestIdleCallback for execution.`);
requestIdleCallback(
async (deadline) => {
console.log(`[${new Date().toLocaleTimeString()}] Running scheduled cleanup. Time remaining: ${deadline.timeRemaining().toFixed(2)}ms, Did timeout: ${deadline.didTimeout}`)
try {
await clean7DaysMailboxLog()
await clean7DaysWebsocketLog()
// Mark that cleanup was successfully executed for today
localStorage.setItem(LAST_EXECUTED_CLEANUP_DAY_KEY, today)
console.log('Daily cleanup marked as executed for today.')
} catch (error) {
console.error('Error during scheduled cleanup execution:', error)
}
},
{ timeout: 5000 },
) // Give it up to 5 seconds to find idle time
} else {
console.warn('requestIdleCallback not supported. Executing cleanup directly (might cause jank).')
// Fallback for very old browsers: run directly.
try {
await clean7DaysMailboxLog()
await clean7DaysWebsocketLog()
localStorage.setItem(LAST_EXECUTED_CLEANUP_DAY_KEY, today)
console.log('Daily cleanup marked as executed for today (without rIC).')
} catch (error) {
console.error('Error during direct cleanup execution:', error)
}
}
} else {
console.log(`[${new Date().toLocaleTimeString()}] Cleanup already executed today.`)
}
}
/**
* Initiates or re-initiates the daily midnight cleanup scheduler.
* This function calls itself recursively to set up the next day's schedule.
*/
export function setupDailyMidnightCleanupScheduler() {
if (cleanupTimeoutId) {
clearTimeout(cleanupTimeoutId)
cleanupTimeoutId = null
}
const now = new Date()
const midnight = new Date(now)
// Set to midnight (00:00:00)
midnight.setDate(now.getDate() + 1)
midnight.setHours(0, 0, 0, 0)
const msToMidnight = midnight.getTime() - now.getTime()
console.log(`[${new Date().toLocaleTimeString()}] Scheduling next daily cleanup at ${midnight.toLocaleTimeString()}, in ${msToMidnight / (1000 * 60 * 60)} hours.`)
// Set the timeout for the next midnight
cleanupTimeoutId = setTimeout(async () => {
console.log(`[${new Date().toLocaleTimeString()}] Midnight trigger fired.`)
if (!hasCleanupExecutedToday()) {
await executeDailyCleanupTask()
} else {
console.log(`[${new Date().toLocaleTimeString()}] Cleanup already executed today, skipping re-execution.`)
}
setupDailyMidnightCleanupScheduler()
}, msToMidnight)
}

@ -1,54 +1,19 @@
import { loadScript } from '@/utils/commons'
import { readWebsocketLog } from '@/utils/indexedDB'
import { BUILD_VERSION, BUILD_DATE } from '@/config'
import { loadScript } from '@/utils/commons';
import { BUILD_VERSION } from '@/config'
export const loadPageSpy = (title) => {
if (import.meta.env.DEV || window.$pageSpy) return
const PageSpyConfig = { api: 'page-spy.mycht.cn', project: 'Sales CRM', title: title + '(v' + BUILD_VERSION + ')', autoRender: false, offline: true }
if (import.meta.env.DEV || window.$pageSpy) return
const PageSpySrc = [
'https://page-spy.mycht.cn/page-spy/index.min.js' + `?${BUILD_DATE}`,
'https://page-spy.mycht.cn/plugin/data-harbor/index.min.js' + `?${BUILD_DATE}`,
'https://page-spy.mycht.cn/plugin/rrweb/index.min.js' + `?${BUILD_DATE}`,
]
'https://page-spy.mycht.cn/page-spy/index.min.js',
'https://page-spy.mycht.cn/plugin/data-harbor/index.min.js',
'https://page-spy.mycht.cn/plugin/rrweb/index.min.js',
];
Promise.all(PageSpySrc.map((src) => loadScript(src))).then(() => {
// 注册插件
window.$harbor = new DataHarborPlugin()
window.$rrweb = new RRWebPlugin()
;[window.$harbor, window.$rrweb].forEach((p) => {
PageSpy.registerPlugin(p)
})
window.$pageSpy = new PageSpy(PageSpyConfig)
// PageSpy.registerPlugin(new DataHarborPlugin());
// PageSpy.registerPlugin(new RRWebPlugin());
PageSpy.registerPlugin(new DataHarborPlugin({ maximum: 2 * 1024 * 1024 }));
// 实例化 PageSpy
// window.$pageSpy = new PageSpy({ api: 'page-spy.mycht.cn', project: 'Sales CRM', title: title + '(v' + BUILD_VERSION + ')', autoRender: false, offline: true, });
console.log('[PageSpy]', window.$pageSpy.version)
// window.addEventListener('beforeunload', (e) => {
// e.preventDefault() // If you prevent default behavior in Mozilla Firefox
// e.returnValue = '' // Chrome requires returnValue to be set
// window.$harbor.upload({ clearCache: false, remark: '自动上传' }) // 上传日志 { clearCache: true, remark: '' }
// })
window.onerror = async function (msg, url, lineNo, columnNo, error) {
await readWebsocketLog()
// 上传最近 3 分钟的日志
const now = Date.now()
await window.$harbor.uploadPeriods({
startTime: now - 3 * 60000,
endTime: now,
remark: `\`onerror\`自动上传. ${msg}`,
})
}
})
}
export const uploadPageSpyLog = async () => {
await readWebsocketLog()
// window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
if (window.$pageSpy) {
await window.$harbor.upload() // 上传日志 { clearCache: true, remark: '' }
}
}
window.$pageSpy = new PageSpy({ api: 'page-spy.mycht.cn', project: 'Sales CRM' + BUILD_VERSION, title: title + '(v' + BUILD_VERSION + ')', autoRender: false });
});
};

@ -1,86 +0,0 @@
const persistObject = {}
/**
* G-INT:USER_ID -> userId = 456
* G-STR:LOGIN_TOKEN -> loginToken = 'E6779386E7D64DF0ADD0F97767E00D8B'
* G-JSON:LOGIN_USER -> loginUser = { username: 'test-username' }
*/
export function usingStorage() {
const getStorage = () => {
if (import.meta.env.DEV && window.localStorage) {
return window.localStorage
} else if (window.sessionStorage) {
return window.sessionStorage
} else {
console.error('browser not support localStorage and sessionStorage.')
}
}
const setProperty = (key, value) => {
const webStorage = getStorage()
const typeAndKey = key.split(':')
if (typeAndKey.length === 2) {
const propName = camelCasedWords(typeAndKey[1])
persistObject[propName] = value
if (typeAndKey[0] === 'G-JSON') {
webStorage.setItem(key, JSON.stringify(value))
} else {
webStorage.setItem(key, value)
}
}
}
// USER_ID -> userId
const camelCasedWords = (string) => {
if (typeof string !== 'string' || string.length === 0) {
return string;
}
return string.split('_').map((word, index) => {
if (index === 0) {
return word.toLowerCase()
} else {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
}
}).join('')
}
if (Object.keys(persistObject).length == 0) {
const webStorage = getStorage()
for (let i = 0; i < webStorage.length; i++) {
const key = webStorage.key(i)
const typeAndKey = key.split(':')
if (typeAndKey.length === 2) {
const value = webStorage.getItem(key)
const propName = camelCasedWords(typeAndKey[1])
if (typeAndKey[0] === 'G-INT') {
persistObject[propName] = parseInt(value, 10)
} else if (typeAndKey[0] === 'G-JSON') {
try {
persistObject[propName] = JSON.parse(value)
} catch (e) {
// 如果解析失败,保留原始字符串值
persistObject[propName] = value
console.error('解析 JSON 失败。')
}
} else {
persistObject[propName] = value
}
}
}
}
return {
...persistObject,
setStorage: (key, value) => {
setProperty(key, value)
},
clearStorage: () => {
getStorage().clear()
Object.assign(persistObject, {})
}
}
}

@ -1,7 +1,7 @@
import ErrorBoundary from '@/components/ErrorBoundary'
import useAuthStore from '@/stores/AuthStore'
import { useThemeContext } from '@/stores/ThemeContext'
import useConversationStore from '@/stores/ConversationStore'
import { useThemeContext } from '@/stores/ThemeContext'
import {
App as AntApp,
ConfigProvider,
@ -11,11 +11,11 @@ import {
FloatButton,
theme,
} from 'antd'
import { AudioOutlined, AudioTwoTone, BugOutlined, CustomerServiceOutlined } from '@ant-design/icons'
import { BugOutlined, MailOutlined } from '@ant-design/icons'
import zhLocale from 'antd/locale/zh_CN'
import 'dayjs/locale/zh-cn'
import { useEffect } from 'react'
import { Link, NavLink, Outlet, useHref, useNavigate } from 'react-router-dom'
import { Outlet, useHref, useNavigate } from 'react-router-dom'
import { appendRequestHeader } from '@/utils/request'
import { loadPageSpy } from '@/utils/pagespy'
@ -23,15 +23,7 @@ import AppLogo from '@/assets/highlights_travel_300_300.png'
import '@/assets/App.css'
import 'react-chat-elements/dist/main.css'
import EmailFetch from './Conversations/Online/Components/EmailFetch'
import FetchEmailWorker from './../workers/fetchEmailWorker?worker&url'
import { readWebsocketLog } from '@/utils/indexedDB'
import { useGlobalNotify } from '@/hooks/useGlobalNotify'
import GeneratePaymentDrawer from './Conversations/Online/Components/GeneratePaymentDrawer'
import GenerateAutoDocDrawer from './Conversations/Online/Components/GenerateAutoDocDrawer'
// const fetchEmailWorkerURL = new URL('/src/workers/fetchEmailWorker.js', import.meta.url);
const fetchEmailWorker = new Worker(FetchEmailWorker, { type: 'module' });
import { getEmailFetchAction } from '@/actions/EmailActions'
function AuthApp() {
const navigate = useNavigate()
@ -39,9 +31,7 @@ function AuthApp() {
const [messageApi, contextHolder] = message.useMessage()
const { colorPrimary, borderRadius } = useThemeContext()
const [loginUser, sendNotify] = useAuthStore((state) => [
state.loginUser, state.sendNotify
])
const [loginUser, sendNotify] = useAuthStore((state) => [state.loginUser, state.sendNotify])
const href = useHref()
@ -58,56 +48,29 @@ function AuthApp() {
} else {
Notification.requestPermission()
}
let _fetchEmailWorker;
if (loginUser.userId > 0) {
appendRequestHeader('X-User-Id', loginUser.userId)
loadPageSpy(loginUser.username)
connectWebsocket(loginUser.userId)
fetchInitialData(loginUser)
_fetchEmailWorker = startEmailInterval(loginUser.userId)
startEmailInterval(loginUser.userId)
}
return () => {
disconnectWebsocket()
fetchEmailWorker.postMessage({ command: 'logout' })
if (_fetchEmailWorker) {
_fetchEmailWorker.terminate();
}
}
}, [])
useGlobalNotify();
const startEmailInterval = (userId) => {
// const fetchEmailWorker = new Worker(fetchEmailWorkerURL, { type: 'module' });
fetchEmailWorker.onerror = function(error) {
console.error('There was an error in the worker', error);
};
fetchEmailWorker.onmessage = function(event) {
// console.log('Received message from worker', event.data, event.message);
};
fetchEmailWorker.postMessage({ command: 'fetchEmail', param: { opi_sn: userId } });
return fetchEmailWorker;
setInterval(() => {
getEmailFetchAction({opi_sn: userId})
}, 1000*60)
}
const uploadLog = async () => {
await readWebsocketLog()
const uploadLog = () => {
sendNotify()
if (window.$pageSpy) {
// window.$pageSpy.triggerPlugins('onOfflineLog', 'upload')
try {
// await window.$harbor.upload() // { clearCache: true, remark: '' }
// 1 , upload : 413 Payload Too Large
const now = Date.now()
await window.$harbor.uploadPeriods({
startTime: now - 60 * 60000,
endTime: now,
})
messageApi.info('Success')
// clearWebsocketLog()
sendNotify()
} catch (error) {
messageApi.error('Failure')
}
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload')
messageApi.info('Success')
} else {
messageApi.error('Failure')
}
@ -116,15 +79,6 @@ function AuthApp() {
// /p...
const needToLogin = loginUser.userId === -1 && href.indexOf('/p/') === -1
const isMobileApp =
navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i,
) !== null
const floatButtonLineEnd = isMobileApp ? 0 : 24
const floatTrigger = isMobileApp ? 'click' : null
useEffect(() => {
if (needToLogin) {
navigate('/p/dingding/login?origin_url=' + href)
@ -140,7 +94,7 @@ function AuthApp() {
colorPrimary: colorPrimary,
borderRadius: borderRadius,
fontFamily:
"-apple-system,BlinkMacSystemFont,Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Noto Color Emoji','Apple Color Emoji'",
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Noto Color Emoji','Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol'",
},
algorithm: theme.defaultAlgorithm,
}}
@ -150,15 +104,10 @@ function AuthApp() {
<AntApp>
<ErrorBoundary>
<FloatButton.Group
shape='square'
placement={'left'}
trigger={floatTrigger}
shape="square"
style={{
insetInlineEnd: floatButtonLineEnd,
insetBlockEnd: floatButtonLineEnd,
flexDirection: 'row',
insetInlineEnd: 94,
}}
icon={<CustomerServiceOutlined />}
>
<EmailFetch />
<FloatButton icon={<BugOutlined />} tooltip={<div>上传日志给研发部</div>} onClick={() => uploadLog()} />
@ -189,8 +138,6 @@ function AuthApp() {
</button>
</form>
</dialog>
<GeneratePaymentDrawer />
<GenerateAutoDocDrawer />
</ErrorBoundary>
</AntApp>
</ConfigProvider>

@ -1,5 +1,5 @@
import { useCallback, useState, useEffect } from "react";
import { Grid, Divider, Layout, Flex, Spin, Input, Col, Row, List, Typography, Alert } from "antd";
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";
@ -50,15 +50,9 @@ const CallCenter = props => {
setPhone_number(e.target.value);
}}></Input.Search>
</Col>
<Col md={24} lg={8} xxl={9}>
<Alert message={'不支持拨打中国大陆(+86)号码'} type="info" showIcon />
</Col>
</Row>
<Row gutter={16}>
<Col md={24} lg={8} xxl={9}></Col>
<Col md={24} lg={8} xxl={6}>
</Col>
</Row>
</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>} />
</>

@ -8,15 +8,12 @@ import MessagesList from './Conversations/History/MessagesList';
import ImageAlbumPreview from './Conversations/History/ImageAlumPreview';
import { flush, pick } from '@/utils/commons';
import { fetchConversationsSearch, CONVERSATION_PAGE_SIZE } from '@/actions/ConversationActions';
import EmailDetail from './Conversations/Online/Components/EmailDetail';
import SupplierEmailDrawer from './Conversations/Online/Components/EmailListDrawer';
const { Sider, Content } = Layout;
const Index = (props) => {
const [formValues, setFormValues] = useFormStore((state) => [state.chatHistoryForm, state.setChatHistoryForm]);
const [selectedConversation, setSelectedConversation] = useFormStore((state) => [state.chatHistorySelectChat, state.setChatHistorySelectChat]);
const [EmailList, ] = useFormStore((state) => [state.EmailList, ]);
const [conversationsListLoading, setConversationsListLoading] = useState(false);
const [conversationsList, setConversationsList] = useState([]);
@ -48,36 +45,20 @@ const Index = (props) => {
setSelectedConversation(data[0]);
}
};
const [openEmailDetail, setOpenEmailDetail] = useState(false);
const [emailDetail, setEmailDetail] = useState({});
const [initialPosition, setInitialPosition] = useState({})
const [initialSize, setInitialSize] = useState({})
const [emailItem, setEmailItem] = useState({});
const onOpenEmail = (emailMsg) => {
// setOpenEmailDetail(true);
// setEmailDetail({...emailMsg, order_opi: Number(selectedConversation?.opi_sn || 0)});
// console.log(emailMsg);
setEmailItem({ MAI_SN: emailMsg.msgtext?.email?.mai_sn, MAI_Subject: emailMsg.msgtext?.email?.subject, SenderReceiver: '', MAI_SendDate: '' })
}
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% - 262px)', height: 'calc(100% - 262px)' }}>
<Sider width={300} theme={'light'} className='h-full overflow-y-auto overflow-x-hidden' style={{ maxHeight: 'calc(100vh - 262px)', height: 'calc(100vh - 262px)' }}>
<Layout hasSider className='h-screen chathistory-wrapper chatwindow-wrapper' style={{ maxHeight: 'calc(100% - 300px)', height: 'calc(100% - 300px)' }}>
<Sider width={300} theme={'light'} className='h-full overflow-y-auto overflow-x-hidden' style={{ maxHeight: 'calc(100vh - 300px)', height: 'calc(100vh - 300px)' }}>
<ConversationsList {...{ conversationsListLoading, conversationsList, selectedConversation, handleChatItemClick: setSelectedConversation, onLoadMore: getConversationsList, loadMoreVisible: pageParam.loadNextPage }} />
</Sider>
<Content style={{ maxHeight: 'calc(100vh - 262px)', height: 'calc(100vh - 262px)', minWidth: '360px' }}>
<Content style={{ maxHeight: 'calc(100vh - 300px)', height: 'calc(100vh - 300px)', minWidth: '360px' }}>
<Flex className='h-full relative'>
<MessagesMatchList />
<MessagesList onOpenEmail={onOpenEmail} />
<MessagesList />
</Flex>
<ImageAlbumPreview />
<EmailDetail open={openEmailDetail} setOpen={setOpenEmailDetail} emailMsg={emailDetail} key={`history-email-detail-${emailDetail.id}`} disabled {...{initialPosition, initialSize, setInitialPosition, setInitialSize}} />
<SupplierEmailDrawer showExpandBtn={false} list={EmailList} opi_sn={selectedConversation?.opi_sn} currentConversationID={selectedConversation?.sn} oid={selectedConversation?.coli_sn} emailItem={emailItem} />
</Content>
</Layout>
</>

@ -1,17 +1,17 @@
import { useEffect, useState } from 'react';
import { Layout, Spin, Button } from 'antd';
import { RightCircleOutlined, RightOutlined, ReloadOutlined, MenuFoldOutlined, MenuUnfoldOutlined, LeftOutlined } from '@ant-design/icons';
import { RightCircleOutlined, RightOutlined, ReloadOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
// import { useParams, useNavigate } from 'react-router-dom';
import MessagesHeader from './Conversations/Online/MessagesHeader';
import MessagesWrapper from './Conversations/Online/MessagesWrapper';
import InputComposer from './Conversations/Online/Input/InputComposer';
import ConversationsList from './Conversations/Online/ConversationsList';
import OrderProfile from '@/components/OrderProfile'
import CustomerProfile from './Conversations/Online/order/CustomerProfile';
// import { useAuthContext } from '@/stores/AuthContext';
// import useConversationStore from '@/stores/ConversationStore';
import ReplyWrapper from './Conversations/Online/ReplyWrapper';
import useConversationStore from '@/stores/ConversationStore';
import { useShallow } from 'zustand/react/shallow';
import './Conversations/Conversations.css';
import EmailEditorPopup from './Conversations/Online/Input/EmailEditorPopup';
const { Sider, Content, Header, Footer } = Layout;
@ -21,38 +21,35 @@ const { Sider, Content, Header, Footer } = Layout;
const ChatWindow = () => {
const [collapsedLeft, setCollapsedLeft] = useState(false);
const [collapsedRight, setCollapsedRight] = useState(true);
const currentOrder = useConversationStore(useShallow(state => state.currentConversation?.coli_sn || ""));
const [collapsedRight, setCollapsedRight] = useState(false);
return (
<>
<Layout hasSider className='h-screen chatwindow-wrapper' style={{ maxHeight: 'calc(100% - 166px)', height: 'calc(100% - 166px)' }}>
<Layout hasSider className='h-screen chatwindow-wrapper' style={{ maxHeight: 'calc(100% - 198px)', height: 'calc(100% - 198px)' }}>
<Sider
width={380}
width={300}
theme={'light'}
className='h-full overflow-y-auto h-parent'
style={{ maxHeight: 'calc(100vh - 166px)', height: 'calc(100vh - 166px)' }}
style={{ maxHeight: 'calc(100vh - 198px)', height: 'calc(100vh - 198px)' }}
collapsible={true}
breakpoint='xl'
collapsedWidth={73}
collapsed={false}
collapsed={collapsedLeft}
onBreakpoint={(broken) => {
// setCollapsedLeft(broken)
// setCollapsedRight(broken)
setCollapsedLeft(broken)
setCollapsedRight(broken)
}}
trigger={null}>
<ConversationsList />
</Sider>
<Content style={{ maxHeight: 'calc(100vh - 166px)', height: 'calc(100vh - 166px)', minWidth: '360px' }}>
<Content style={{ maxHeight: 'calc(100vh - 198px)', height: 'calc(100vh - 198px)', minWidth: '360px' }}>
<Layout className='h-full'>
<Header className='px-1 ant-layout-sider-light bg-white ant-card h-auto flex justify-between gap-1 items-center'>
<Header className='px-1 ant-layout-sider-light ant-card h-auto flex justify-between gap-1 items-center'>
{/* <Button type='text' icon={collapsedLeft ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} onClick={() => setCollapsedLeft(!collapsedLeft)} className=' rounded-none rounded-l' /> */}
<MessagesHeader />
{/* <Button type='text' icon={<ReloadOutlined />} onClick={() => setCollapsedRight(!collapsedRight)} className='' title='最新消息记录' /> */}
<Button type='text' icon={collapsedRight ? <RightOutlined /> : <LeftOutlined />} onClick={() => setCollapsedRight(!collapsedRight)} className=' rounded-none rounded-r' />
<Button type='text' icon={collapsedRight ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />} onClick={() => setCollapsedRight(!collapsedRight)} className=' rounded-none rounded-r' />
</Header>
<Content className="flex-grow bg-whatsapp-bg relative" >
<MessagesWrapper />
@ -66,19 +63,18 @@ const ChatWindow = () => {
</Content>
<Sider
width={400}
width={300}
theme={'light'}
className=' overflow-y-auto'
style={{ maxHeight: 'calc(100vh - 166px)', height: 'calc(100vh - 166px)' }}
style={{ maxHeight: 'calc(100vh - 198px)', height: 'calc(100vh - 198px)' }}
collapsible={true}
breakpoint='xl'
collapsedWidth={0}
trigger={null}
collapsed={collapsedRight}>
<OrderProfile coliSN={currentOrder} />
<CustomerProfile />
</Sider>
</Layout>
<EmailEditorPopup key='email-editor-online' />
</>
);
};

@ -21,14 +21,11 @@ function ChatAssign() {
const [opi, setOpi] = useState({});
async function refreshConversationList() {
const _list = await fetchConversationsSearch({ whatsapp_id: whatsappid })
const _list = await fetchConversationsSearch({ whatsapp_id: whatsappid });
if (_list.length > 0) {
const unassignI = _list.findIndex((item) => item.opi_sn === 0)
if (unassignI > -1) {
setCurrentConversation(_list[unassignI])
setConversationid(String(_list[unassignI].conversationid))
setOpi({ label: _list[unassignI].opi_name, value: String(_list[unassignI].opi_sn) })
}
setCurrentConversation(_list[0]);
setConversationid(String(_list[0].conversationid));
setOpi({ label: _list[0].opi_name, value: String(_list[0].opi_sn) });
}
}
@ -42,7 +39,7 @@ function ChatAssign() {
return (
<>
<Layout className='h-full chatwindow-wrapper mobilechat-wrapper' style={{ maxHeight: 'calc(100vh - 32px)', height: 'calc(100vh - 32px)', minWidth: '360px' }}>
<Header className=' px-2 ant-layout-sider-light bg-white ant-card h-auto flex flex-col justify-between gap-1 '>
<Header className=' px-2 ant-layout-sider-light ant-card h-auto flex flex-col justify-between gap-1 '>
<InputAssign className={'block py-2'} initialValues={{ conversationid, whatsappid }} {...{ conversationid, opi }} />
<MessagesHeader />
</Header>

@ -98,8 +98,7 @@
bottom: 0;
}
.chatwindow-wrapper .rce-container-mbox .rce-mbox{
/* max-width: 500px; */
max-width: 70%;
max-width: 500px;
}
.chatwindow-wrapper .rce-citem {
background: transparent;
@ -140,7 +139,7 @@
.chatwindow-wrapper .epr-emoji-native,
.chatwindow-wrapper .ant-input-textarea-affix-wrapper.ant-input-affix-wrapper >textarea.ant-input
{
font-family: 'Open Sans', 'Noto Sans',"Noto Color Emoji", 'Apple Color Emoji', 'Twemoji Mozilla', 'EmojiOne Color', 'Android Emoji', Arial, sans-serif!important;
font-family: 'Open Sans', 'Noto Sans',"Noto Color Emoji", 'Apple Color Emoji', 'Twemoji Mozilla', 'Segoe UI Emoji', 'Segoe UI Symbol', 'EmojiOne Color', 'Android Emoji', Arial, sans-serif!important;
font-weight: 400;
}
.chatwindow-wrapper .failed-msg .rce-mbox-forward{
@ -187,8 +186,7 @@
margin-top: 0;
/* margin-left: 5px; */
padding: 5px;
/* justify-content: flex-start; */
justify-content: center;
justify-content: flex-start;
font-size: 12px;
/* word-wrap: break-word; */
text-wrap: nowrap;
@ -196,10 +194,10 @@
height: 100%;
}
.chatwindow-wrapper .whatsappme-container .rce-mbox{
/* background-color: #ccd5ae; */
background-color: #ccd5ae;
}
.chatwindow-wrapper .whatsappme-container .rce-mbox-right-notch{
/* fill: #ccd5ae; */
fill: #ccd5ae;
}
.chatwindow-wrapper .rce-mbox .rce-mbox-reply {
background-color: rgba(236, 236, 236, 0.7);

@ -21,9 +21,9 @@ const ConversationsList = ({ conversationsListLoading, handleChatItemClick, sele
{...item}
key={item.conversationid}
id={item.conversationid}
letterItem={{ id: item.show_default, letter: (item.show_default).split("@")[0].slice(0, 4).split(' ')[0] }}
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.show_default}
title={item.whatsapp_name || item.whatsapp_phone_number}
subtitle={`${item.OPI_Name || ''} ${item.coli_id || ''}`}
date={item.lasttime || item.lasttime}
dateString={item.dateText}

@ -103,7 +103,7 @@ const MergeConversationTo = ({ currentWAID, opi_sn, ...props }) => {
setOpen(false);
formInstance?.resetFields();
}}
destroyOnHidden
destroyOnClose
onOk={async () => {
try {
const values = await formInstance?.validateFields();

@ -1,18 +1,15 @@
import { useRef, useEffect, useState, forwardRef, memo } from 'react';
import { App, Flex, List, Button, Image } from 'antd';
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, groupBy, isNotEmpty, TagColorStyle } from '@/utils/commons';
import { isEmpty, stringToColour, groupBy, isNotEmpty } from '@/utils/commons';
import { useShallow } from 'zustand/react/shallow';
import MergeConversationTo from './MergeConversationTo';
import BubbleIM from '../Online/Components/BubbleIM';
import BubbleEmail from '../Online/Components/BubbleEmail';
import { ERROR_IMG, POPUP_FEATURES } from '@/config';
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 20;
const MessagesList = ({ ...listProps }) => {
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 100;
const MessagesList = ({ ...props }) => {
const { message: appMessage } = App.useApp();
const [formValues] = useFormStore((state) => [state.chatHistoryForm]);
@ -20,7 +17,6 @@ const MessagesList = ({ ...listProps }) => {
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 [ setEmailList] = useFormStore(useShallow((state) => [ state.setEmailList]));
const [chatItemMessages, setChatItemMessages] = useState([]);
const [messageListPreLoading, setMessageListPreLoading] = useState(false);
@ -35,7 +31,7 @@ const MessagesList = ({ ...listProps }) => {
setChatItemMessages((prevValue) => [].concat(data, prevValue));
const loadPrePage = !(data.length === 0 || data.length < BIG_PAGE_SIZE);
if (data.length > 0) {
setParamsForMsgList({ loadPrePage, pretime: data[0].msgtime });
setParamsForMsgList({ loadPrePage, pretime: data[0].orgmsgtime });
}
};
const getMessagesNext = async (chatItem) => {
@ -47,7 +43,7 @@ const MessagesList = ({ ...listProps }) => {
setChatItemMessages((prevValue) => [].concat(prevValue, data));
const loadNextPage = !(data.length === 0 || data.length < BIG_PAGE_SIZE);
if (data.length > 0) {
setParamsForMsgList({ loadNextPage, lasttime: data[data.length - 1].msgtime });
setParamsForMsgList({ loadNextPage, lasttime: data[data.length - 1].orgmsgtime });
}
};
@ -68,16 +64,15 @@ const MessagesList = ({ ...listProps }) => {
if (isEmpty(selectedConversation.conversationid)) {
return () => {};
}
// opisn: (selectedConversation.opi_sn || 0), whatsappid: selectedConversation.whatsapp_phone_number,
const firstActionPageParams = { conversationid: selectedConversation.conversationid , loadNextPage: true };
const firstActionPageParams = { opisn: (selectedConversation.opi_sn || 0), 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) && !isEmpty(selectedConversation.matchMsgList)) {
firstActionPageParams.pretime = selectedConversation.matchMsgList[0].msgtime;
firstActionPageParams.lasttime = selectedConversation.matchMsgList[0].msgtime;
firstActionPageParams.pretime = selectedConversation.matchMsgList[0].orgmsgtime;
firstActionPageParams.lasttime = selectedConversation.matchMsgList[0].orgmsgtime;
firstActionPageParams.loadPrePage = true;
}
setParamsForMsgList(firstActionPageParams);
@ -108,11 +103,9 @@ const MessagesList = ({ ...listProps }) => {
// ,
useEffect(() => {
if (chatItemMessages.length > 0) {
// setParamsForMsgList({ pretime: chatItemMessages[0].msgtime, lasttime: chatItemMessages[chatItemMessages.length - 1].msgtime });
// 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);
const emailList = chatItemMessages.filter((ele) => ele.msg_source === 'email').map(ele => ({...ele, MAI_SN: ele.msgtext?.email?.mai_sn, MAI_Subject: ele.msgtext?.email?.subject, SenderReceiver: ele.from, MAI_SendDate: ele.msgtime, Direction: ele.msg_direction === 'inbound' ? '收' : '发' })).reverse();
setEmailList(emailList);
}
return () => {};
}, [chatItemMessages]);
@ -177,7 +170,7 @@ const MessagesList = ({ ...listProps }) => {
{headerObj ? (
<div className='text-neutral-500 text-center'>
{'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>}
{'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <Image src={headerObj.parameters[0].image.link} height={100} fallback={ERROR_IMG}></Image>}
{'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <img src={headerObj.parameters[0].image.link} height={100}></img>}
{['document', 'video'].includes((headerObj?.parameters?.[0]?.type || '').toLowerCase()) && (
<a href={headerObj.parameters[0][headerObj.parameters[0].type].link} target='_blank' key={headerObj.format} rel='noreferrer' className='text-sm'>
[&nbsp;{headerObj.parameters[0].type}&nbsp;]
@ -203,9 +196,8 @@ const MessagesList = ({ ...listProps }) => {
// eslint-disable-next-line react/display-name
const MessageBoxWithRef = forwardRef((props, ref) => (
<li ref={ref}>
{['waba', 'wai'].includes((props.msg_source || '').toLowerCase()) && <MessageBox {...props} />}
{props.msg_source === 'email' && <BubbleEmail {...props} reposition='left' onOpenEmail={listProps.onOpenEmail} />}
</li>
<MessageBox {...props} />
</li>
));
const handlePreview = (msg) => {
@ -215,7 +207,7 @@ const MessagesList = ({ ...listProps }) => {
return false;
case 'document':
window.open(msg.data.link || msg.data.uri, msg.data.uri, POPUP_FEATURES);
window.open(msg.data.link || msg.data.uri, '_blank', 'noopener,noreferrer');
return false;
default:
@ -260,17 +252,16 @@ const MessagesList = ({ ...listProps }) => {
title={message.whatsapp_msg_type === 'text' ? '' : message.title}
text={<RenderText str={message?.text || ''} className={message.status === 'failed' ? 'line-through text-neutral-400' : ''} template={message.template} />}
copiableDate={true}
dateString={`${message.wabaName} - ${message.dateString || message.localDate}`}
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' : '',
message.sender === 'me' ? (message.msg_source.toLowerCase() === 'waba' ? `[&_.rce-mbox]:bg-waba-me` : `[&_.rce-mbox]:bg-whatsapp-me`) : '',
].join(' ')}
style={{
// backgroundColor: message.sender === 'me' ? '#ccd4ae' : '#fff',
backgroundColor: message.sender === 'me' ? '#ccd4ae' : '#fff',
}}
{...(message.type === 'meetingLink'
? {
@ -286,10 +277,10 @@ const MessagesList = ({ ...listProps }) => {
}
: {})}
renderAddCmp={
<div key={'msg-prefix'} className='border-dashed border-0 border-t border-slate-300 text-slate-600 space-x-2 emoji ' style={{backgroundColor: 'unset'}}>
<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', ...TagColorStyle(message.senderName, true) }}>
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>

@ -40,13 +40,13 @@ const SearchForm = memo(function ({ initialValues, onSubmit, onReset }) {
<Input placeholder='关键词' allowClear />
</Form.Item>
<Form.Item label='消息渠道' name='mchannel' className='w-52'>
<Select maxTagCount={0} allowClear options={[
{ key: 'WABA', label: 'WA商业号', value: 'WABA' },
<Select mode='multiple' maxTagCount={0} allowClear options={[
{ key: 'waba', label: 'WA商业号', value: 'waba' },
{ key: 'email', label: '邮件', value: 'email' },
{ key: 'wai', label: 'WhatsApp', value: 'wai' },
{ key: 'whatsapp', label: 'WhatsApp', value: 'whatsapp' },
]} className='w-8' />
</Form.Item>
{/* <Form.Item label='' name='mtype' className='w-44'>
<Form.Item label='消息类型' name='mtype' className='w-44'>
<Select mode='multiple' maxTagCount={0} allowClear options={[
{ key: 'image', label: '图片', value: 'image' },
{ key: 'video', label: '视频', value: 'video' },
@ -54,10 +54,9 @@ const SearchForm = memo(function ({ initialValues, onSubmit, onReset }) {
{ key: 'file', label: '文件', value: 'file' },
{ key: 'email', label: '邮件', value: 'email' },
]} className='w-8' />
</Form.Item> */}
</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'>

@ -56,29 +56,28 @@ const BubbleEmail = ({ onOpenEditor, onOpenEmail, ...message }) => {
<>
<b>From: </b>
<span>
{/* {message?.emailOrigin?.fromName}&nbsp;&lt;{message?.emailOrigin.fromEmail}&gt; */}
{message.msgOrigin?.from}
</span>
</> : <><b>To: </b>{message.msgOrigin?.to}</>
}
{/* <b>Subject: </b>{message.msgOrigin.email.subject} */}
</span>
</>
}
// titleColor={message.sender !== 'me' ? '#4f46e5' : ''} // 600
notch={false}
position={message.reposition || (message.sender === 'me' ? 'right' : 'left')}
position={message.sender === 'me' ? 'right' : 'left'}
onReplyClick={() => onOpenEditor(message.msgOrigin)}
onForwardClick={ () => { handleResend(message.msgOrigin); }}
onOpen={() => handlePreview(message)}
onTitleClick={() => handlePreview(message)}
text={<RenderText str={message?.msgOrigin || ''} className={message.status === 'failed' ? 'line-through text-neutral-400' : ''} email={{...message.msgOrigin, coli_id: message.coli_id || message.msgOrigin?.coli_id}} sender={message.sender} />}
text={<RenderText str={message?.msgOrigin || ''} className={message.status === 'failed' ? 'line-through text-neutral-400' : ''} email={message.msgOrigin} sender={message.sender} />}
{...(message.sender === 'me'
? {
styles: { backgroundColor: '#e0e7ff', boxShadow: 'none', border: '1px solid #818cf8' }, // 100 400
// replyButton: ['text', 'document', 'image'].includes(message.whatsapp_msg_type) && message.status !== 'failed' ? true : false, // review: ?
}
: {
styles: { backgroundColor: '#fff', boxShadow: 'none', border: '1px solid #818cf8' },
})}
: {})}
className={[
'whitespace-pre-wrap',
message.sender === 'me' ? 'whatsappme-container' : '',

@ -1,19 +1,13 @@
import { memo } from 'react';
import { App, Button, Image } from 'antd';
import { ExportOutlined, CopyOutlined, PhoneOutlined } from '@ant-design/icons';
import { createContext, useEffect, useState, memo } from 'react';
import { App, Button } from 'antd';
import { MailFilled, MailOutlined, WhatsAppOutlined } from '@ant-design/icons';
import { MessageBox } from 'react-chat-elements';
import { groupBy, isEmpty, TagColorStyle } from '@/utils/commons';
import { groupBy, isEmpty } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore';
import { useShallow } from 'zustand/react/shallow';
import { ReplyIcon } from '@/components/Icons';
import { WABIcon } from '@/components/Icons';
import ChannelLogo from './ChannelLogo';
import { ERROR_IMG } from '@/config';
const outboundStyle = {
'waba': { color: '#ccd4ae' },
'whatsapp': { color: '#d9fdd3' },
'wai': { color: '#d9fdd3' },
}
import { WABAccountsMapped } from '@/channel/bubbleMsgUtils';
const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, setNewChatFormValues, scrollToMessage, focusMsg, ...message }) => {
const { message: appMessage } = App.useApp();
@ -23,13 +17,13 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
setNewChatModalVisible(true);
setNewChatFormValues((prev) => ({ ...prev, phone_number: wa_id, name: wa_name }));
};
const RenderText = memo(function renderText({ str, className, template, message }) {
const RenderText = memo(function renderText({ str, className, template }) {
let headerObj, footerObj, buttonsArr;
if (!isEmpty(template) && !isEmpty(template.components)) {
const componentsObj = groupBy(template.components.concat(template?.components_omit || []), (item) => item.type);
const componentsObj = groupBy(template.components, (item) => item.type);
headerObj = componentsObj?.header?.[0];
footerObj = componentsObj?.footer?.[0];
buttonsArr = componentsObj?.button; // ?.reduce((r, c) => r.concat(c.buttons), []);
buttonsArr = componentsObj?.buttons?.reduce((r, c) => r.concat(c.buttons), []);
}
const parts = str.split(/(https?:\/\/[^\s()]+|\p{Emoji_Presentation}|\d{4,})/gmu).filter((s) => s !== '');
@ -49,13 +43,12 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
}
return prev;
}, []);
return (
<span className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className} ${extraClass}`} key={'msg-text'}>
{headerObj ? (
<div className='text-neutral-500 text-center'>
{'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>}
{'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <Image src={headerObj.parameters[0].image.link} height={100} fallback={ERROR_IMG}></Image>}
{'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <img src={headerObj.parameters[0].image.link} height={100}></img>}
{['document', 'video'].includes((headerObj?.parameters?.[0]?.type || '').toLowerCase()) && (
<a href={headerObj.parameters[0][headerObj.parameters[0].type].link} target='_blank' key={headerObj.format} rel='noreferrer' className='text-sm'>
[&nbsp;{headerObj.parameters[0].type}&nbsp;]
@ -69,72 +62,71 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
<a href={part.key} target='_blank' key={`${part.key}${index}`} rel='noreferrer' className='text-sm'>
{part.key}
</a>
)
);
} else if (part.type === 'number') {
return (
<a key={`${part.key}${index}`} className='text-sm' onClick={() => openNewChatModal({ wa_id: part.key, wa_name: part.key })}>
{part.key}
</a>
)
);
} else {
// if (part.type === 'emoji')
return part.key
return part.key;
}
})}
{footerObj ? <div className=' text-neutral-500'>{footerObj.text}</div> : null}
{buttonsArr && buttonsArr.length > 0 ? (
<div className='flex flex-row gap-1'>
{buttonsArr.map((btn, index) =>
btn.sub_type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={`${btn.sub_type}_${btn.index}`} rel='noreferrer' icon={<ExportOutlined />}>
btn.type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={btn.url} rel='noreferrer'>
{btn.text}
</Button>
) : btn.sub_type.toLowerCase() === 'phone_number' ? (
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer' icon={<PhoneOutlined />}>
) : btn.type.toLowerCase() === 'phone_number' ? (
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer'>
{btn.text} ({btn.phone_number})
</Button>
) : (
<Button className='text-blue-500' size={'small'} key={`${btn.type}_${btn.sub_type}_${btn.index}`} icon={btn.sub_type.toLowerCase() === 'copy_code' ? <CopyOutlined /> : btn.sub_type.toLowerCase() === 'quick_reply' ? <ReplyIcon /> : null}>
{btn.text || btn.sub_type.toUpperCase()}
<Button className='text-blue-500' size={'small'} key={btn.type}>
{btn.text}
</Button>
),
)
)}
</div>
) : null}
</span>
)
);
});
return (
<MessageBox
{...message} titleColor={TagColorStyle(message.title).color}
key={`IM.${message.id}`}
{...message}
key={`${message.sn}.${message.id}`}
position={message.sender === 'me' ? 'right' : 'left'}
onReplyClick={() => setReferenceMsg(message)}
onReplyMessageClick={() => scrollToMessage(message.reply.id)}
onOpen={() => handlePreview(message)}
onTitleClick={() => handlePreview(message)}
// title={<div className='flex justify-around items-center gap-1'><WABIcon />{message.title}</div>}
text={<RenderText str={message?.text || ''} className={message.status === 'failed' ? 'line-through text-neutral-400' : ''} template={message.template} message={message} />}
replyButton={['text', 'document', 'image'].includes(message.whatsapp_msg_type) && message.replyButton}
text={<RenderText str={message?.text || ''} className={message.status === 'failed' ? 'line-through text-neutral-400' : ''} template={message.template} />}
replyButton={['text', 'document', 'image'].includes(message.whatsapp_msg_type)}
{...(message.sender === 'me'
? {
// styles: { backgroundColor: '#ccd4ae' },
notchStyle: { fill: outboundStyle[message.msg_source.toLowerCase()].color },
title: <><ChannelLogo channel={message.msg_source} />{message.wabaName ? ` ${message.wabaName} - ${message.title || ''}` : ` ${message.title || message.from || ''}`}</>,
notchStyle: { fill: '#ccd4ae' }, // todo: channel[WhatsApp] color '#d9fdd3'
replyButton: ['text', 'document', 'image'].includes(message.whatsapp_msg_type) && message.status !== 'failed' ? true : false,
title: <><ChannelLogo channel={message.msg_source} />&nbsp;{WABAccountsMapped[message.from]?.verifiedName}</>,
}
: {
// title: <>&nbsp;<ChannelLogo channel={message.msg_source} />&nbsp;{message.title}</>,
dateString: `${message.wabaName} - ${message.dateString}`,
title: <>&nbsp;<ChannelLogo channel={message.msg_source} />&nbsp;{message.title}</>,
})}
className={[
'whitespace-pre-wrap', '[&_.rce-mbox-reply-message]:line-clamp-3',
'whitespace-pre-wrap',
message.whatsapp_msg_type === 'sticker' ? 'bg-transparent' : '',
// message.sender === 'me' ? 'whatsappme-container' : '',
focusMsg === message.id ? 'message-box-focus' : '',
message.status === 'failed' ? 'failed-msg' : '',
// '*:bg-waba-me'
message.sender === 'me' ? (message.msg_source.toLowerCase() === 'waba' ? `[&_.rce-mbox]:bg-waba-me` : `[&_.rce-mbox]:bg-whatsapp-me`) : '',
message.sender !== 'me' ? (message.msg_source.toLowerCase() === 'waba' ? `[&_.rce-mbox-time:before]:text-waba-600 [&_.rce-mbox-time:before]:font-semibold` : `[&_.rce-mbox-time:before]:text-whatsapp`) : '',
message.sender === 'me' ? '*:!bg-waba-me' : '', // todo: channel color
].join(' ')}
{...(message.type === 'meetingLink'
? {

@ -2,7 +2,7 @@ import React, { } from 'react';
import { WhatsAppOutlined, MailOutlined } from '@ant-design/icons';
import { WABIcon, } from '@/components/Icons';
const ChannelLogo = ({channel, className, ...props}) => {
const ChannelLogo = ({channel}) => {
// if is array, get last
if (Array.isArray(channel)) {
channel = channel[channel.length - 1];
@ -10,16 +10,14 @@ const ChannelLogo = ({channel, className, ...props}) => {
const _channel = (channel || '').toLowerCase();
switch (_channel) {
case 'waba':
return <WABIcon key={channel} className={`text-whatsapp ${className} `} />;
return <WABIcon key={channel} className='text-whatsapp' />;
case 'wa':
case 'wai':
case 'whatsapp':
return <WhatsAppOutlined key={channel} className={`text-whatsapp ${className} `} />;
return <WhatsAppOutlined key={channel} className='text-whatsapp' />;
case 'email':
return <MailOutlined key={channel} className={`text-indigo-500 ${className} `} />
return <MailOutlined key={channel} className='text-indigo-500' />
default:
// return <MailOutlined key={'channel'} className={`text-indigo-500 ${className} `} />
return <WABIcon key={channel} className={`text-whatsapp ${className} `} />;
// return <MailOutlined key={'channel'} className='text-indigo-500' />
return <WABIcon key={channel} className='text-whatsapp' />;
}
}
export default ChannelLogo;

@ -1,13 +1,12 @@
import React, { useState, useEffect } from 'react';
import { Button, Tag, Radio, Popover, Form, Space, Tooltip } from 'antd';
import { isEmpty, objectMapper, TagColorStyle } from '@/utils/commons';
import { Button, Tag, Radio, Popover, Form } from 'antd';
import { isEmpty, objectMapper, stringToColour } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore';
import { OrderLabelDefaultOptions } from '@/stores/OrderStore';
import { FilterOutlined, FilterTwoTone } from '@ant-design/icons';
import { FilterIcon } from '@/components/Icons';
const otypes = [
{ label: 'All', value: '', labelValue: '' },
{ value: 'istoday@1', labelValue: '1', label: '今日' },
...OrderLabelDefaultOptions.filter((o) =>
[240003, 240002].includes(o.value),
).map((o) => ({ ...o, labelValue: o.value, value: `label@${o.value}` })),
@ -15,6 +14,11 @@ const otypes = [
{ value: `intour@1`, labelValue: 1, label: '走团中', },
]
const otypesMapped = otypes.reduce((acc, cur) => ({ ...acc, [cur.value]: cur }), {});
const TagColorStyle = (tag, outerStyle = false) => {
const color = stringToColour(tag);
const outerStyleObj = outerStyle ? { borderColor: `${color}66`, backgroundColor: `${color}0D` } : {};
return { color: `${color}`, ...outerStyleObj };
};
const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
const [
{ tags: selectedTags, otype: selectedOType, search, ...filter },
@ -99,9 +103,9 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
setFilterOtype('');
resetFilter();
form.resetFields();
// if (typeof onFilterChange === 'function') {
// onFilterChange();
// }
if (typeof onFilterChange === 'function') {
onFilterChange();
}
}
useEffect(() => {
@ -117,7 +121,7 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
return (
<>
<div className='my-1 flex justify-between items-center '>
{/* <Radio.Group
<Radio.Group
optionType={'button'}
buttonStyle='solid'
size='small'
@ -126,23 +130,10 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
onChange={(e) => {
setFilterOtype(e.target.value)
}}
/> */}
{tags.slice(0, 3).map((tag, ti) => (
<Tag.CheckableTag
className='mb-1'
key={tag.key}
checked={selectedTags.includes(tag.key)}
onChange={(checked) => handleTagsChange(tag, checked)}
style={TagColorStyle(
tag.label,
selectedTags.includes(tag.key),
)}>
{tag.label}
</Tag.CheckableTag>
))}
/>
<Popover
destroyTooltipOnHide
placement='bottomLeft'
placement='bottom'
overlayClassName='max-w-80'
trigger={'click'}
open={openPopup}
@ -165,14 +156,14 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
initialValues={{}}
onFinish={onFinish}
className='*:mb-2'>
{/* <Form.Item label=''>
<Form.Item label='订单'>
<Tag
key={selectedOType}
closeIcon={selectedOType !== ''}
onClose={() => setFilterOtype('')}>
{otypesMapped[selectedOType]?.label || 'All'}
</Tag>
</Form.Item> */}
</Form.Item>
<Form.Item name={'tags'} label='标签' className='*.div:gap-1'>
{tags.map((tag, ti) => (
<Tag.CheckableTag
@ -190,27 +181,29 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
</Form.Item>
<Form.Item noStyle className='flex justify-center mb-0'>
<Space.Compact>
<Button.Group>
<Button onClick={onReset} type='primary' ghost>
重置
</Button>
{/* <Button htmlType='submit' type='primary'>
确定
</Button> */}
</Space.Compact>
</Button.Group>
</Form.Item>
</Form>
</>
}>
<Tooltip title='更多筛选' >
<Button
icon={
isEmpty(selectedTags) ? <FilterOutlined className='text-neutral-500' /> : <FilterTwoTone />
}
type='text'
size='middle'
/>
</Tooltip>
<Button
icon={
<FilterIcon
className={
isEmpty(selectedTags) ? 'text-neutral-500' : 'text-blue-500'
}
/>
}
type='text'
size='middle'
/>
</Popover>
</div>
</>

@ -1,11 +1,11 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Dropdown, Input, Button, Tag, Popover, Form, Tooltip, Spin } from 'antd';
import { Dropdown, Input, Button, Tag, Popover, Form } from 'antd';
import { CloseCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import { fetchConversationItemClose, fetchConversationsSearch, fetchConversationItemUnread, fetchConversationItemTop, postConversationTags, deleteConversationTags, fetchCleanUnreadMsgCount } from '@/actions/ConversationActions';
import { fetchConversationItemClose, fetchConversationsSearch, fetchConversationItemUnread, fetchConversationItemTop, postConversationTags, deleteConversationTags } from '@/actions/ConversationActions';
import { ChatItem } from 'react-chat-elements';
// import ConversationsNewItem from './ConversationsNewItem';
import { flush, isEmpty, isNotEmpty, stringToColour, TagColorStyle } from '@/utils/commons';
import { flush, isEmpty, isNotEmpty, stringToColour } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore';
import useAuthStore from '@/stores/AuthStore';
import ChannelLogo from './ChannelLogo';
@ -15,25 +15,27 @@ import useStyleStore from '@/stores/StyleStore';
import { OrderLabelDefaultOptionsMapped, OrderStatusDefaultOptionsMapped } from '@/stores/OrderStore';
import { whatsappMsgTypeMapped } from '@/channel/bubbleMsgUtils';
const TagColorStyle = (tag) => {
const color = stringToColour(tag);
return { color: `${color}`, borderColor: `${color}66`, backgroundColor: `${color}0D` }
}
const TagColorStyle_2 = (tag, outerStyle = false) => {
const color = stringToColour(tag);
const outerStyleObj = outerStyle ? { borderColor: `${color}66`, } : {};
return { color: `${color}`, ...outerStyleObj };
};
const OrderSignEmoji = ({ item }) => (
<>
<Tooltip color={'cyan'} title={OrderLabelDefaultOptionsMapped[String(item.order_label_id)]?.label}>{OrderLabelDefaultOptionsMapped[String(item.order_label_id)]?.emoji}</Tooltip>
<Tooltip color={'orange'} title={OrderStatusDefaultOptionsMapped[String(item.order_state_id)]?.label}>{OrderStatusDefaultOptionsMapped[String(item.order_state_id)]?.emoji}</Tooltip>
<Tooltip color={'volcano'} title={'走团中'}>{item.intour === 1 && item.order_state_id === 5 ? '👣' : ''}</Tooltip>
{OrderLabelDefaultOptionsMapped[String(item.order_label_id)]?.emoji}
{OrderStatusDefaultOptionsMapped[String(item.order_state_id)]?.emoji}
{item.intour === 1 && item.order_state_id===5 ? '👣' : ''}
</>
)
const NewTagForm = ({onSubmit,...props}) => {
const [form] = Form.useForm();
const [subLoding, setSubLoding] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus()
return () => {}
}, [])
const onFinish = async (values) => {
// console.log('Received values of form[new_tag]: ', values);
setSubLoding(true);
@ -51,7 +53,7 @@ const NewTagForm = ({onSubmit,...props}) => {
initialValues={{}}
onFinish={onFinish}>
<Form.Item name={'tag_label'} rules={[{ required: true, message: '请输入标签名' }]}>
<Input placeholder='新建并设置' ref={inputRef} />
<Input placeholder='新建并设置' />
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit' loading={subLoding} >
@ -75,13 +77,9 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
const setClosedConversationList = useConversationStore((state) => state.setClosedConversationList);
const [currentHandleChat, setCurrentHandleChat] = useState({});
const [handleLoading, setHandleLoading] = useState(false);
const itemTagsKeys = (item.tags || []).map(t => t.key);
const [tags, addTag] = useConversationStore(state => [state.tags, state.addTag]);
const handleConversationItemClose = async (item) => {
setHandleLoading(true);
await fetchConversationItemClose({ conversationid: item.sn, opisn: item.opi_sn });
delConversationitem(item);
if (String(order_sn) === String(item.coli_sn)) {
@ -89,44 +87,22 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
}
const _clist = await fetchConversationsSearch({ opisn: userId, session_enable: 0 });
setClosedConversationList(_clist);
setCurrentHandleChat({});
setHandleLoading(false);
};
const handleConversationItemUnread = async (item) => {
setHandleLoading(true);
if (item.unread_msg_count < 999) {
await fetchConversationItemUnread({ conversationid: item.sn });
} else {
await fetchCleanUnreadMsgCount({ opisn: item.opi_sn, conversationid: item.sn });
}
await fetchConversationItemUnread({ conversationid: item.sn });
await refreshConversationList(item.lasttime);
setListUpdateFlag(Math.random());
setCurrentHandleChat({});
setHandleLoading(false);
}
const handleConversationItemTop = async (item) => {
setHandleLoading(true);
await fetchConversationItemTop({ conversationid: item.sn, top_state: item.top_state === 0 ? 1 : 0 });
await refreshConversationList(item.lasttime);
setListUpdateFlag(Math.random());
setCurrentHandleChat({});
setHandleLoading(false);
}
const handleConversationItemMuted = async (item) => {
setHandleLoading(true);
await fetchConversationItemTop({ conversationid: item.sn, top_state: item.top_state === -1 ? 0 : -1 });
await refreshConversationList(item.lasttime);
setListUpdateFlag(Math.random());
setCurrentHandleChat({});
setHandleLoading(false);
}
const handleConversationItemTags = async (item, tagKey, tagLabel) => {
const _tags = (item.tags || []).map(t => t.key);
setHandleLoading(true);
if (isNotEmpty(tagKey) && _tags.includes(Number(tagKey))) {
await deleteConversationTags({ conversationid: item.sn, tag_id: tagKey, opisn: userId })
} else {
@ -143,17 +119,12 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
await refreshConversationList(item.lasttime);
setListUpdateFlag(Math.random());
setContextMenuOpen(false);
setCurrentHandleChat({});
setHandleLoading(false);
}
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const handleContextMenuOpenChange = (nextOpen, info, item) => {
const handleContextMenuOpenChange = (nextOpen, info) => {
if (info.source === 'trigger' || nextOpen) {
setContextMenuOpen(nextOpen);
setCurrentHandleChat(nextOpen ? item : {})
} else {
// setCurrentHandleChat({});
}
};
@ -167,9 +138,8 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
}, [contextMenuOpen])
const RenderLastMsg = (msg) => {
const readFromMsg = msg?.originText || msg?.text || '';
// const _text = isEmpty(msg) ? '' : msg.type === 'text' ? msg.text.body : `[${(msg?.type || '').toUpperCase()}]`;
const _text = isEmpty(msg?.type) ? '' : ((whatsappMsgTypeMapped?.[msg.type]?.renderForReply(msg) || {})?.message || readFromMsg)
const _text = isEmpty(msg) ? '' : (whatsappMsgTypeMapped?.[msg.type]?.renderForReply(msg) || {})?.message
return (
<>{_text}</>
);
@ -183,12 +153,11 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
trigger={['contextMenu']}
overlayClassName='z-[998]'
open={contextMenuOpen}
onOpenChange={(nextOpen, info) => handleContextMenuOpenChange(nextOpen, info, item)}
onOpenChange={handleContextMenuOpenChange}
menu={{
items: [
{ label: item.top_state === 1 ? '取消置顶' : '置顶会话', key: 'top' },
{ label: item.unread_msg_count > 998 ? '标为已读' : '标记为未读', key: 'unread' },
// { label: item.top_state === -1 ? '' : '', key: 'mute' },
item.top_state === 1 ? { label: '取消置顶', key: 'top' } : { label: '置顶会话', key: 'top' },
{ label: '标记为未读', key: 'unread' },
{
label: '设置标签',
key: 'tags',
@ -228,9 +197,8 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
},
},
{ label: '编辑联系人', key: 'edit0' },
{ type: 'divider' },
{ label: '移到🗂隐藏列表', key: 'close', danger: true },
{ label: '隐藏会话', key: 'close', danger: true },
],
triggerSubMenuAction: 'click',
openKeys: openTags,
@ -247,17 +215,14 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
}
switch (key) {
case 'top':
setContextMenuOpen(false);
return handleConversationItemTop(item);
case 'mute':
setContextMenuOpen(false);
return handleConversationItemMuted(item);
setContextMenuOpen(false)
return handleConversationItemTop(item)
case 'unread':
setContextMenuOpen(false);
return handleConversationItemUnread(item);
setContextMenuOpen(false)
return handleConversationItemUnread(item)
case 'close':
setContextMenuOpen(false);
return handleConversationItemClose(item);
setContextMenuOpen(false)
return handleConversationItemClose(item)
case 'edit0':
setOpenTags([])
setEditingChat({ ...item, is_new: false })
@ -281,21 +246,16 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
: item.top_state === 1
? 'bg-stone-100'
: '',
'hover:bg-slate-50',
(item.sn) === (currentHandleChat?.sn) ? ' bg-slate-50 text-slate-500' : '',
// String(item.sn) === String(tabSelectedConversation?.sn) ? ' bg-neutral-200' : '',
].join(' ')}>
{/* <div className='pl-4 pt-1 text-xs text-right'>
{tags.map((tag) => <Tag color={tag.color} key={tag.value}>{tag.label}</Tag>)}
</div> */}
<Spin spinning={(item.sn) === (currentHandleChat?.sn) && (props.conversationsListLoading || handleLoading)} size='small'>
<ChatItem
{...item}
key={item.sn}
id={item.sn}
letterItem={ item.session_type === 1 ? void 0 : { id: item.show_default, letter: (item?.show_default || '').split("@")[0].slice(0, 5) }}
avatar={ item.session_type === 1 ? 'https://hiana-crm.oss-accelerate.aliyuncs.com/WAMedia/02ab0228-4c3c-4834-ac73-a6dfcdf81938.png' : void 0}
avatarSize={'small'}
letterItem={{ id: item.show_default, letter: (item?.show_default || '').split("@")[0].slice(0, 5) }}
alt={item.whatsapp_name}
title={
<span>
@ -307,31 +267,29 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
}
// subtitle={item.coli_id}
subtitle={
<>
<div>
{/* <ReadIcon /> */}
{/* <DeliverIcon /> */}
{/* <SentIcon /> */}
{/* <span>{item.coli_id}</span> */}
{/* <span><ReadIcon />最后一条消息</span> */}
<span className='text-xs'>
<span className='text-sm'>
<RenderLastMsg {...item?.last_message} />
</span>
<div className='text-sm'>
<OrderSignEmoji item={item} />
{(item?.tags || [])?.map((tag) => (
<Tag key={tag.label} style={{ ...TagColorStyle(tag.label, true) }} className='text-xs px-0.5 me-0.5'>
<Tag key={tag.label} style={{ ...TagColorStyle(tag.label) }} className='text-xs px-0.5 me-0.5'>
{tag.label}
</Tag>
))}
{/* <span title={'附加备注'}>附加备注</span> */}
</div>
</>
</div>
}
date={item.lasttime || item.last_received_time || item.last_send_time}
dateString='' // : , , dataString
unread={item.unread_msg_count > 99 ? 0 : item.unread_msg_count}
muted={item.top_state === -1}
showMute={item.top_state === -1}
// className={[
// String(item.sn) === String(currentConversation.sn) ? '__active text-primary bg-whatsapp-bg' : '',
// String(item.sn) === String(tabSelectedConversation?.sn) ? ' bg-neutral-200' : '',
@ -340,10 +298,10 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
statusText={
<ChannelLogo
channel={flush([
item?.channels?.phone_number ? 'waba' : null,
item?.channels?.phone_number ? 'phone' : null,
item?.channels?.email ? 'email' : null,
item?.channels?.whatsapp_phone_number ? 'waba' : null,
item?.last_message?.source || null, // wai, WABA, email
item?.last_message?.type === 'email' ? 'email' : null,
])}
/>
}
@ -354,7 +312,6 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
// () => <span key={'tag'} className='self-end>💎💴👑💼🤝💤💔💨🕳🚫🎈🎊🎁📜</span>,
]}
/>
</Spin>
</div>
</Dropdown>
</>

@ -1,188 +0,0 @@
import { useState } from 'react'
import { App, Modal, Button, Table, Form, Row, Col, Input, Checkbox } from 'antd'
import { ApiOutlined } from '@ant-design/icons'
import { isEmpty, cloneDeep } from '@/utils/commons'
import { fetchJSON } from '@/utils/request'
import AdvanceSearchForm from '../../../orders/AdvanceSearchForm'
import { API_HOST } from '@/config'
import dayjs from 'dayjs'
import useAuthStore from '@/stores/AuthStore'
import { fetchEmailBindOrderAction } from '@/actions/EmailActions'
const fetchMyOrderList = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getdvancedwlorder`, params)
return errcode !== 0 ? [] : result
}
const fetchHTOrderList = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/query_order`, params)
return errcode !== 0 ? [] : result
}
export const EmailBindFormModal = ({ mai_sn, conversationid, userId, coliID, onBoundSuccess, ...props }) => {
const [form] = Form.useForm()
const [open, setOpen] = useState(false)
const { userId: loginUserId } = useAuthStore((state) => state.loginUser)
const [loading, setLoading] = useState(false) // bind loading
const [searchLoading, setSearchLoading] = useState(false)
const [searchResult, setSearchResult] = useState([])
const { notification, message } = App.useApp()
const onSearchOrder = async (values) => {
const copyObject = cloneDeep(values)
delete copyObject.type
const allEmpty = Object.values(copyObject).every((val) => {
return val === null || String(val).trim() === '' || val === undefined
})
if (allEmpty) {
notification.warning({
message: '温馨提示',
description: '请输入至少一个条件',
placement: 'top',
duration: 60,
})
return false
}
// values.opisn = loginUserId
values.sourcetype = values.is_biz ? '227002' : '227001'
delete values.is_biz
setLoading(false)
setSearchLoading(true)
setSearchResult([])
const result = await fetchHTOrderList(values)
setSearchResult(result.map(ele => ({...ele, sourcetype: values.sourcetype})))
setSearchLoading(false)
}
//
// 227001:// 227002
const handleBindOrder = async ({ coli_sn, coli_id, sourcetype = '227001' }) => {
setLoading(true)
const success = await fetchEmailBindOrderAction({ mai_sn, coli_sn, coli_id, sourcetype, conversationid })
setLoading(false)
success ? message.success('绑定成功') : message.error('绑定失败')
setOpen(false)
if (typeof onBoundSuccess === 'function') {
onBoundSuccess(coli_sn)
}
}
const paginationProps = {
showQuickJumper: true,
showLessItems: true,
showSizeChanger: true,
showTotal: (total) => {
return `总数:${total}`
},
}
const searchResultColumns = [
{
title: '订单号',
key: 'COLI_ID',
dataIndex: 'COLI_ID',
width: 222,
},
{
title: '顾问',
key: 'OperatorName',
dataIndex: 'OperatorName',
width: 100,
},
{
title: '分配时间',
key: 'COLI_AssignDate',
dataIndex: 'COLI_AssignDate',
width: 100,
},
{
title: '天数',
key: 'COLI_Days',
dataIndex: 'COLI_Days',
width: 100,
},
// {
// title: '',
// key: 'coli_guest',
// dataIndex: 'coli_guest',
// render: (text, record) => {
// let regularText = ''
// if (record.buytime > 0) regularText = '(R' + record.buytime + ')'
// return text + regularText
// },
// },
// {
// title: '',
// key: 'COLI_OrderStartDate',
// dataIndex: 'COLI_OrderStartDate',
// width: 120,
// hidden: false,
// sortDirections: ['ascend', 'descend'],
// sorter: (a, b) => {
// const datejsA = isEmpty(a.COLI_OrderStartDate) ? 0 : new dayjs(a.COLI_OrderStartDate).valueOf()
// const datejsB = isEmpty(b.COLI_OrderStartDate) ? 0 : new dayjs(b.COLI_OrderStartDate).valueOf()
// return datejsA - datejsB
// },
// },
// {
// title: '',
// ellipsis: true,
// key: 'COLI_Introduction',
// dataIndex: 'COLI_Introduction',
// },
{
title: '',
key: 'action',
width: 150,
render: (_, record) => (
<Button type={'text'} className='text-primary' onClick={() => handleBindOrder({ coli_sn: record.COLI_SN, coli_id: record.COLI_ID, sourcetype: record.sourcetype })}>
关联此订单
</Button>
),
},
]
return (
<>
{/* <Button type='primary' onClick={() => setOpen(true)} >
现在关联
</Button> */}
<Button key={'bound'} onClick={() => setOpen(true)} size='small' type='text' icon={<ApiOutlined className='text-red-500' />}>
{props.showBindBtn ? '绑定订单' : '修改绑定'}
</Button>
<Modal
width={window.innerWidth < 700 ? '95%' : '100%'}
open={open}
title={'关联订单'}
footer={false}
onCancel={() => {
setOpen(false)
}}
destroyOnHidden>
{/* <AdvanceSearchForm onSubmit={onSearchOrder} loading={searchLoading} /> */}
<Form
layout={'inline'}
form={form}
initialValues={{
orderLabel: '',
orderStatus: '',
remindState: '',
// ...initialValues,
}}
onFinish={onSearchOrder}>
<Form.Item label='订单号' name='coli_id' initialValue={coliID} rules={[{ required: true, message: '请输入订单号' }]}>
<Input placeholder='订单号' allowClear />
</Form.Item>
<Form.Item name='is_biz' className='' valuePropName='checked'>
<Checkbox>商务订单</Checkbox>
</Form.Item>
<div style={{ flex: '0 1 64px' }} className='flex justify-between'>
<Button type='primary' htmlType='submit' loading={searchLoading}>
搜索
</Button>
</div>
</Form>
<Table key={'advanceOrderTable'} loading={loading} dataSource={searchResult} columns={searchResultColumns} pagination={searchResult.length <= 10 ? false : paginationProps} rowKey={'COLI_SN'} />
</Modal>
</>
)
}
export default EmailBindFormModal

@ -1,146 +0,0 @@
import { POPUP_FEATURES } from '@/config'
import React, { useState, useEffect, useRef } from 'react'
const EmailContent = ({ id, content: MailContent, className='', ...props }) => {
const [iframeHeight, setIframeHeight] = useState(5000) // Initial height
const [content, setContent] = useState(MailContent)
const iframeRef = useRef(null)
const containerRef = useRef(null)
useEffect(() => {
setContent(MailContent)
}, [MailContent])
const setIframeContent = (iframe, content) => {
if (!iframe || !iframe.contentDocument) {
console.error('Iframe not loaded or contentDocument is null')
return
}
const doc = iframe.contentDocument
doc.open()
// doc.write(content)
doc.write(`
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 0;
/*overflow-y: hidden;*/
width: 900px;
max-width: 100%;
}
img {
max-width: 90%;
height: auto;
object-fit: contain;
}
img:not(a img){ cursor: pointer;}
</style>
</head>
<body>
${content}
</body>
</html>
`);
doc.close()
}
const calculateHeight = () => {
try {
if (iframeRef.current && iframeRef.current.contentDocument) {
const doc = iframeRef.current.contentDocument
const body = doc.body
if (body) {
try {
const links = doc.querySelectorAll('a')
links.forEach((link) => {
link.setAttribute('target', '_blank')
})
const imgs = doc.querySelectorAll('img:not(a img)')
imgs.forEach((img) => {
// open img in new tab
img.addEventListener('click', (e) => {
// e.preventDefault()
img.style.cursor = 'pointer'
window.open(img.src, img.src, POPUP_FEATURES)
})
})
} catch (e) {
// console.error('Could not access iframe content due to Same-Origin Policy or other error:', e)
}
requestAnimationFrame(() => {
const newHeight = Math.max(body.scrollHeight, body.offsetHeight, body.clientHeight)
// console.log('body.scrollHeight: ', body.scrollHeight)
// console.log('body.offsetHeight: ', body.offsetHeight)
// console.log('body.clientHeight: ', body.clientHeight)
const addMore = Math.max(Math.ceil(newHeight * 0.05), 120)
// console.log('Calculated height:', newHeight, addMore)
setIframeHeight(newHeight + addMore)
})
return
} else {
console.warn('iframe body is null or undefined')
}
} else {
console.warn('iframeRef.current or contentDocument is null')
}
} catch (error) {
console.error('Error calculating height:', error)
}
// setIframeHeight(200)
}
useEffect(() => {
const handleLoad = () => {
calculateHeight()
}
const currentIframe = iframeRef.current
if (currentIframe) {
currentIframe.addEventListener('load', handleLoad)
setIframeContent(currentIframe, content)
}
return () => {
if (currentIframe) {
currentIframe.removeEventListener('load', handleLoad)
}
}
}, [content])
// useEffect(() => {
// if(iframeRef.current){
// setIframeContent(iframeRef.current, content);
// calculateHeight();
// }
// }, [content])
return (
<div ref={containerRef} className={`space-y-4 w-full ${className}`}>
<div className='w-full relative pt-2'>
<iframe
key={id}
ref={iframeRef}
height={iframeHeight}
style={{
width: '100%',
height: `${iframeHeight}px`,
// border: '1px solid #e5e7eb',
border: 'none',
display: 'block',
}}
sandbox='allow-scripts allow-same-origin allow-popups'
/>
</div>
</div>
)
}
export default EmailContent

@ -1,22 +1,27 @@
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { App, Button, Divider, Avatar } from 'antd'
import { LoadingOutlined, ApiOutlined } from '@ant-design/icons';
import { EditIcon, ReplyIcon, ResendIcon, ShareForwardIcon } from '@/components/Icons'
import { isEmpty, TagColorStyle } from '@/utils/commons'
import { isEmpty, stringToColour } from '@/utils/commons'
import EmailEditorPopup from '../Input/EmailEditorPopup'
import DnDModal from '@/components/DnDModal'
import DnDModal from '@/components/DndModal'
import useStyleStore from '@/stores/StyleStore'
import { useEmailDetail, } from '@/hooks/useEmail';
import { EMAIL_ATTA_HOST } from '@/config';
import EmailBindFormModal from './EmailBind';
import EmailDetailInline from './EmailDetailInline';
import EmailContent from './EmailContent';
const TagColorStyle = (tag) => {
const color = stringToColour(tag)
return {
color: `${color}`,
borderColor: `${color}66`,
backgroundColor: `${color}0D`,
}
}
/**
* @property {*} emailMsg - 邮件数据. { conversationid, actionId, order_opi, coli_sn, msgOrigin: { from, to, id, email: { subject, mai_sn, } } }
* @property {*} disabled - 是否禁用操作: 回复, 转发
* @property {*} emailMsg - 邮件数据. { msgOrigin: { from, to, id, email: { subject, mai_sn, } } }
*/
const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) => {
const EmailDetail = ({ open, setOpen, emailMsg, ...props }) => {
// console.log('emailDetail', emailMsg);
@ -25,16 +30,16 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
const { conversationid, actionId, order_opi, coli_sn } = emailMsg
const { mai_sn, id } = emailMsg.msgOrigin?.email || emailMsg.msgOrigin || {}
const mailID = mai_sn || id
// const [initialPosition, setInitialPosition] = useState({})
// const [initialSize, setInitialSize] = useState({})
const [initialPosition, setInitialPosition] = useState({})
const [initialSize, setInitialSize] = useState({})
function onHandleMove(e) {
const { top, left, width, height } = e
props?.setInitialPosition({ top, left })
setInitialPosition({ top, left })
}
function onHandleResize(e) {
const { top, left, width, height } = e
props?.setInitialPosition({ top, left })
props?.setInitialSize({ width, height })
setInitialPosition({ top, left })
setInitialSize({ width, height })
}
const [action, setAction] = useState('')
@ -44,19 +49,14 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
const onOpenEditor = (msgOrigin, action) => {
const { from, to } = msgOrigin
setOpenEmailEditor(true)
setFromEmail(action === 'edit' ? from : to)
setFromEmail(to)
setAction(action)
setOpen(false)
}
const [mobile] = useStyleStore((state) => [state.mobile])
const { loading, mailData, orderDetail, postEmailResend } = useEmailDetail(mailID)
const [showBindBtn, setShowBindBtn] = useState(false);
useEffect(() => {
setShowBindBtn(isEmpty(mailData.info?.MAI_COLI_SN))
return () => {}
}, [mailData.info?.MAI_COLI_SN])
const { loading, mailData, postEmailResend } = useEmailDetail(mailID)
const handleResend = async () => {
if (isEmpty(mai_sn)) {
@ -80,7 +80,6 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
* * 已保存: []
* * 已发送: 回复, 转发
* * 失败: 重发
* todo: disabled 不显示
*/
const ActionBtns = ({className, ...props}) => {
const { status } = mailData.info
@ -88,9 +87,13 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
let btns = []
// ``
if (showBindBtn) {
btns.push(<EmailBindFormModal key={'bind'} onBoundSuccess={() => setShowBindBtn(false)} {...{conversationid, mai_sn, showBindBtn}} />)
btns.push(<Divider key='divider1' type='vertical' />);
if (isEmpty( mailData.info?.MAI_COLI_SN)) {
btns.push(
<Button key={'bound'} onClick={() => alert('马上安排了!')} size='small' type='text' icon={<ApiOutlined className='text-red-500' />}>
绑定订单
</Button>
);
btns.push(<Divider type='vertical' />);
}
switch (status) {
@ -126,7 +129,7 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
}
return (
<div className={`flex justify-end items-center w-full ${className || ''}`}>
<div className={`flex items-center w-full ${className || ''}`}>
{btns}
</div>
)
@ -143,28 +146,76 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
{mailData.info?.MAI_Subject || emailMsg?.msgOrigin?.email?.subject}
</>
}
initial={{ width: props.initialSize?.width || (window.innerWidth - 740), height: props.initialSize?.height || (window.innerHeight - 100), left: props.initialPosition?.left || (300 + 24), top: props.initialPosition?.top || 74 }}
initial={{ top: 74 }}
onMove={onHandleMove}
onResize={onHandleResize}
footer={<ActionBtns className='w-full !justify-start' />}>
<EmailDetailInline { ...{ mailData, emailMsg, loading, mailID } } />
footer={<ActionBtns className='w-full' />}>
<div className='email-container flex flex-col gap-2 *:p-2 *:rounded-sm *:border-b *:border-gray-200 *:shadow-1md'>
<div className=' font-bold'>{mailData.info?.MAI_Subject || emailMsg?.msgOrigin?.subject}</div>
<div>
<div className={['flex justify-between', window.innerWidth < 600 ? 'flex-row' : 'flex-row'].join(' ')}>
<div className='flex gap-2 mb-2 items-center'>
<Avatar className='' style={TagColorStyle(mailData.info?.MAI_From)}>
{(mailData.info?.MAI_From || '').substring(0, 1)}
</Avatar>
<div className=' flex flex-col'>
<span className=' font-bold text-base'>{mailData.info?.fromName}</span>
<span className='text-neutral-500'>{mailData.info?.MAI_From}</span>
</div>
</div>
<div className='flex flex-col justify-start gap-1 items-end'>
<ActionBtns />
<div className='text-xs '>{mailData.info?.MAI_SendDate || emailMsg.localDate}</div>
</div>
</div>
<div className='text-sm'>
<span className='text-neutral-500 pr-2'>收件人:</span>
{mailData.info?.MAI_To}
</div>
{mailData.info?.cc && (
<div className='text-sm'>
<span className='text-neutral-500 pr-2'>抄送:</span>
{mailData.info.cc}
</div>
)}
{mailData.info?.bcc && (
<div className='text-sm'>
<span className='text-neutral-500 pr-2'>密送:</span>
{mailData.info.bcc}
</div>
)}
{mailData.attachments.length > 0 && (
<div className='mt-2 *:ml-2'>
<span>{mailData.attachments.length}个附件</span>
<div className='flex flex-wrap gap-2'>
{mailData.attachments.map((atta) => (
<a href={`${EMAIL_ATTA_HOST}${atta.ATI_ServerFile}`} key={atta.ATI_SN} target='_blank' rel='noreferrer'>
{atta.ATI_Name}
</a>
))}
</div>
</div>
)}
<Divider className='my-2' />
<div className='mt-2' dangerouslySetInnerHTML={{ __html: mailData.content }}></div>
</div>
</div>
</DnDModal>
{/* <EmailEditorPopup
<EmailEditorPopup
open={openEmailEditor}
setOpen={setOpenEmailEditor}
fromEmail={fromEmail}
fromUser={mailData.info?.MAI_OPI_SN || order_opi}
fromOrder={mailData.info?.MAI_COLI_SN || coli_sn}
conversationid={conversationid}
oid={orderDetail.order_no}
customerDetail={orderDetail.customerDetail}
// emailMsg={ReferEmailMsg}
quoteid={mailID}
initial={{ ...props.initialPosition, ...props.initialSize }}
initial={{ ...initialPosition, ...initialSize }}
mailData={mailData}
action={action}
key={`email-detail-inner-${action}_${mailID}`}
/> */}
key={`email-detail-inner-${action}-popup_${mailID}`}
/>
</>
)
}

@ -1,344 +0,0 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { App, Button, Divider, Avatar, List, Flex, Typography, Tooltip, Empty } from 'antd'
import { LoadingOutlined, ApiOutlined, FilePdfOutlined, FileOutlined, FileWordOutlined, FileExcelOutlined, FileJpgOutlined, FileImageOutlined, FileTextOutlined, FileGifOutlined, GlobalOutlined, FileZipOutlined, DeleteOutlined, ExpandOutlined } from '@ant-design/icons'
import { EditIcon, MailCheckIcon, ReplyAllIcon, ReplyIcon, ResendIcon, ShareForwardIcon, SendPlaneFillIcon, InboxIcon } from '@/components/Icons'
import { isEmpty, TagColorStyle } from '@/utils/commons'
import EmailEditorPopup from '../Input/EmailEditorPopup'
import DnDModal from '@/components/DnDModal'
import useStyleStore from '@/stores/StyleStore'
import { openPopup, useEmailDetail, } from '@/hooks/useEmail'
import { EMAIL_ATTA_HOST, POPUP_FEATURES } from '@/config'
import EmailBindFormModal from './EmailBind'
import EmailContent from './EmailContent'
const extTypeMapped = {
txt: { icon: FileTextOutlined },
zip: { icon: FileZipOutlined, color: '#ffe78f' },
pdf: { icon: FilePdfOutlined, color: '#ad0b00' },
doc: { icon: FileWordOutlined, color: '#103f91' },
docx: { icon: FileWordOutlined, color: '#103f91' },
rtf: { icon: FileWordOutlined, color: '#103f91' },
xls: { icon: FileExcelOutlined, color: '#0c7d0c' },
xlsx: { icon: FileExcelOutlined, color: '#0c7d0c' },
jpg: { icon: FileImageOutlined, color: '#1985ff' },
jpeg: { icon: FileImageOutlined, color: '#1985ff' },
png: { icon: FileImageOutlined, color: '#1985ff' },
gif: { icon: FileGifOutlined, color: '#1985ff' },
html: { icon: GlobalOutlined, color: '#1985ff' },
htm: { icon: GlobalOutlined, color: '#1985ff' },
default: { icon: FileOutlined }, // rtf bmp
}
const FileTypeIcon = ({fileName}) => {
const ext = fileName.split('.').pop() || 'default';
const Com = extTypeMapped[ext]?.icon || FileOutlined;
const color = extTypeMapped[ext]?.color || 'inherit';
return <Com style={{ color: color }} size={36} />;
};
/**
* @property {*} emailMsg - 邮件数据. { conversationid, actionId, order_opi, coli_sn, msgOrigin: { from, to, id, email: { subject, mai_sn, } } }
*/
const EmailDetailInline = ({ mailID, emailMsg = {}, disabled = false, variant, size, autoMark = false, ...props }) => {
// console.log('emailDetail', emailMsg);
const componentRef = useRef(null);
const [compactBtn, setCompactBtn] = useState(size==='small');
useEffect(() => {
if (componentRef.current) {
// console.log(componentRef.current.getBoundingClientRect().width);
setCompactBtn(componentRef.current.offsetWidth < 800);
}
return () => {}
}, [])
// const NEW_EMAIL_CONFIG = useMemo(() => {
// return ['new', 'edit', 'reply', 'replyall', 'forward'].reduce((a, action) => ({...a, [`${action}-${mailID || 0}`]: {
// url: `/email/${action}/${mailID || 0}`,
// name: `${action}-${mailID || 0}`,
// features: POPUP_FEATURES,
// }}), {});
// }, [mailID]);
const { notification, message } = App.useApp()
const { conversationid, actionId, order_opi, coli_sn } = emailMsg
const { mai_sn, id } = emailMsg.msgOrigin?.email || emailMsg.msgOrigin || {}
// const mailID = mai_sn || id
// const [action, setAction] = useState('')
// const [openEmailEditor, setOpenEmailEditor] = useState(false)
// const [fromEmail, setFromEmail] = useState('')
// useEffect(() => {
// setOpenEmailEditor(false)
// return () => {}
// }, [mailID])
const onOpenEditor = (msgOrigin, action='reply') => {
openPopup(`/email/${action}/${mailID || 0}`, `${action}-${mailID || 0}`)
if (typeof props.onOpenEditor === 'function') {
props.onOpenEditor(msgOrigin, action);
} else {
// const { from, to } = msgOrigin
// setOpenEmailEditor(true)
// setFromEmail(action === 'edit' ? from : to)
// setAction(action)
// // setOpen(false)
}
}
const { loading, mailData, orderDetail, postEmailResend } = useEmailDetail(mailID, null, false, autoMark)
const [showBindBtn, setShowBindBtn] = useState(false)
useEffect(() => {
setShowBindBtn(mailID ? isEmpty(mailData.info?.MAI_COLI_SN) : false)
return () => {}
}, [mailID, mailData.info?.MAI_COLI_SN])
const handleView = async () => {
openPopup(`/email/view/${mailID || 0}`, `view-${mailID || 0}`, { fullscreen: true })
};
const handleResend = async () => {
if (isEmpty(mai_sn)) {
return false
}
try {
await postEmailResend({ mai_sn, conversationid, actionId })
// setOpen(false)
} catch (err) {
notification.error({
message: '请求失败',
description: err.message,
placement: 'top',
duration: 3,
})
}
}
const handleDel = async () => {
if (isEmpty(mai_sn)) {
return false
}
try {
//
} catch (err) {
notification.error({
message: '请求失败',
description: err.message,
placement: 'top',
duration: 3,
})
}
}
/**
* 根据状态, 显示操作
* * 已保存: []
* * 已发送: 回复, 转发
* * 失败: 重发
* todo: disabled 不显示
*/
const renderActionBtns = ({ className, ...props }) => {
const { status } = mailData.info
let btns = []
// const showDoneBtn = mailData.info?.MAI_Direction !== 1 ? true : false
// if (showDoneBtn) {
// btns.push(<Button type='text' key={'set-done'} onClick={() => { alert('todo')}} icon={<MailCheckIcon className={'text-yellow-600'} />} size='small'></Button>)
// }
// ``
if (showBindBtn) {
btns.push(<EmailBindFormModal key={'bind'} onBoundSuccess={() => setShowBindBtn(false)} {...{ conversationid, mai_sn, showBindBtn }} />)
btns.push(<Divider key='divider1' type='vertical' className='mx-0' />)
}
switch (status) {
case 'accepted':
break
case 'sent':
case '': //
btns.push(
<Tooltip key='reply-t' title='回复'>
<Button key={'reply'} onClick={() => onOpenEditor(emailMsg.msgOrigin, 'reply')} size='small' type='text' icon={<ReplyIcon className='text-indigo-500' />}>
{compactBtn ? '' : '回复'}
</Button>
</Tooltip>,
)
btns.push(
<Tooltip key='replyall-t' title='回复全部'>
<Button key={'replyall'} onClick={() => onOpenEditor(emailMsg.msgOrigin, 'replyall')} size='small' type='text' icon={<ReplyAllIcon className='text-indigo-500' />} >{compactBtn ? '' : '回复全部'}</Button>
</Tooltip>,
)
btns.push(
<Tooltip key='forward-t' title='转发'>
<Button key={'forward'} onClick={() => onOpenEditor(emailMsg.msgOrigin, 'forward')} size='small' type='text' icon={<ShareForwardIcon className='text-primary' />}>{compactBtn ? '' : '转发'}</Button></Tooltip>,
)
break
case 'failed':
btns.push(
// <Tooltip key='delete-t' title=''>
// <Button key={'delete'} danger onClick={() => handleDel()} size='small' type='text' icon={<DeleteOutlined className='text-red-500' />}>{compactBtn ? '' : ''}</Button></Tooltip>,
<Tooltip key='resend-t' title='发送'>
<Button key={'resend'} onClick={() => handleResend()} size='small' type='text' icon={<SendPlaneFillIcon className='text-orange-500' />}>{compactBtn ? '' : '发送'}</Button></Tooltip>,
)
btns.push(
<Tooltip key='edit-t' title='编辑'>
<Button
key={'edit'}
onClick={() => onOpenEditor({ ...(emailMsg.msgOrigin || {}), content: mailData.content }, 'edit')}
size='small'
type='text'
icon={<EditIcon className='text-indigo-500' />}>{compactBtn ? '' : '编辑'}</Button></Tooltip>,
)
break
default:
break
}
const showFullBtn = variant !== 'full'
if (showFullBtn) {
btns.push(<Divider key='divider2' type='vertical' className='mx-0' />);
btns.push(
<Button key={'view'} onClick={() => handleView()} size='small' type='text' icon={<ExpandOutlined className='text-indigo-500' />}>
最大化
</Button>
)
}
return <div className={`flex justify-end items-center gap-1 ${className || ''}`}>{btns}</div>
}
const renderAttaList = ({ list }) => {
return (
<List
dataSource={list}
size='small'
className='[&_.ant-list-item]:p-1 [&_.ant-list-item]:justify-start'
renderItem={(atta) => (
<List.Item>
<FileTypeIcon fileName={atta.ATI_Name} />
<Typography.Text ellipsis={{ tooltip: { title: atta.ATI_Name, placement: 'left' } }} className='text-inherit'>
<span key={atta.ATI_SN} onClick={() => openPopup(`${EMAIL_ATTA_HOST}${atta.ATI_ServerFile}`, atta.ATI_SN)} size='small' className='text-blue-500 cursor-pointer'>
{atta.ATI_Name}
</span>
</Typography.Text>
</List.Item>
)}
/>
)
};
const variantCls = (variant) => {
switch (variant) {
case 'outline':
return 'h-full border-y-0 border-x border-solid border-neutral-200'
case 'full':
return 'h-[calc(100dvh-16px)]'
default:
return 'h-full'
}
}
return mailID ? (
<>
<div ref={componentRef} className={`email-container flex flex-col gap-0 divide-y divide-neutral-200 divide-solid *:p-2 *:border-0 bg-white ${variantCls(variant)}`}>
<div className=''>
<div className='flex flex-wrap justify-between'>
<span className={(mailData.info?.MAI_ReadState || 0) > 0 ? '' : ' font-bold '}>
{loading ? <LoadingOutlined className='mr-1' /> : null}
{mailData.info?.MAI_Subject || emailMsg?.msgOrigin?.subject}
</span>
{/* <ActionBtns key='actions' className={'ml-auto'} /> */}
{renderActionBtns({ className: 'ml-auto'})}
</div>
<Divider className='my-2' />
<div className={['flex flex-wrap justify-end', window.innerWidth < 800 ? 'flex-col' : 'flex-row '].join(' ')}>
<div className=' flex-auto basis-0 flex flex-wrap gap-2 mb-2 items-center'>
<Avatar className='' style={TagColorStyle(mailData.info?.MAI_From, true)}>
{(mailData.info?.MAI_From || '').substring(0, 1)}
</Avatar>
<div className=' flex flex-col '>
{/* <span className=' font-bold text-base'>{mailData.info?.fromName}</span> */}
<span className='text-neutral-500 text-wrap break-words break-all '>{mailData.info?.MAI_From}</span>
</div>
</div>
<div className=' ml-auto flex flex-col justify-start gap-1 items-end'>
{/* <ActionBtns /> */}
<div className='text-xs '>{mailData.info?.MAI_SendDate || emailMsg.localDate}</div>
</div>
</div>
<div className='text-sm'>
<span className='text-neutral-500 pr-2 w-14 inline-block text-justify' style={{ textAlignLast: 'justify' }}>
收件人
</span>
{mailData.info?.MAI_To}
</div>
{mailData.info?.MAI_CS && (
<div className='text-sm'>
<span className='text-neutral-500 pr-2 w-12 inline-block text-justify' style={{ textAlignLast: 'justify' }}>
抄送
</span>
{mailData.info.MAI_CS}
</div>
)}
{mailData.info?.bcc && (
<div className='text-sm'>
<span className='text-neutral-500 pr-2 w-12 inline-block text-justify' style={{ textAlignLast: 'justify' }}>
密送
</span>
{mailData.info.bcc}
</div>
)}
</div>
<div className='overflow-auto'>
<Flex className={` divide-gray-200 divide-solid gap-0 ${compactBtn === false ? 'divide-y-0 divide-x' : 'flex-col-reverse divide-x-0 '}`}>
{mailData.info?.mailType === 'text/html' ? (
<EmailContent content={mailData.content} id={mailID} key={mailID} />
) : (
<div className='mt-2 whitespace-pre-wrap flex-auto' dangerouslySetInnerHTML={{ __html: mailData.content }}></div>
)}
{mailData.AttachList.length > 0 && (
<div className={`${compactBtn === false ? 'w-48' : 'border-b border-t-0'} overflow-hidden`}>
{mailData.attachments.length > 0 && (
<>
<span>&nbsp;附件 ({mailData.attachments.length})</span>
{renderAttaList({ list: mailData.attachments || []})}
</>
)}
{mailData.insideAttachments.length > 0 && <details>
<summary>
<span className='text-gray-500 italic'>&nbsp;文内附件 ({mailData.insideAttachments.length}) 已在正文显示&nbsp;</span><span className='cursor-pointer underline'>点击展开</span>
</summary>
{renderAttaList({ list: mailData.insideAttachments || []})}
</details>}
</div>
)}
</Flex>
</div>
</div>
{/* <EmailEditorPopup
open={openEmailEditor}
setOpen={setOpenEmailEditor}
fromEmail={fromEmail}
fromUser={mailData.info?.MAI_OPI_SN || order_opi}
fromOrder={mailData.info?.MAI_COLI_SN || coli_sn}
conversationid={conversationid}
oid={orderDetail.order_no}
customerDetail={orderDetail.customerDetail}
// emailMsg={ReferEmailMsg}
quoteid={mailID}
// initial={{ ...initialPosition, ...initialSize }}
mailData={void 0}
action={action}
key={`email-detail-inline-${action}_${mailID}`}
/> */}
</>
) : (
<Empty description={'未打开邮件'} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)
}
export default EmailDetailInline

@ -1,13 +1,41 @@
import { useState } from 'react'
import { FloatButton } from 'antd'
import { useEffect, useState } from 'react'
import { App, Tooltip, Button, FloatButton } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { getEmailFetchAction } from '@/actions/EmailActions'
import useAuthStore from '@/stores/AuthStore'
import useConversationStore from '@/stores/ConversationStore'
import { MailDownloadIcon } from '@/components/Icons'
import { isEmpty } from '@/utils/commons'
const EmailFetch = ({ ...props }) => {
const { notification, message } = App.useApp()
const { userId, emailList } = useAuthStore((state) => state.loginUser)
const [globalNotify, clearGlobalNotify] = useConversationStore((state) => [state.globalNotify, state.clearGlobalNotify]);
useEffect(() => {
if (isEmpty(globalNotify)) {
return () => {}
}
// message.info(globalNotify[0].content, 3)
notification.open({
key: globalNotify[0].id,
message: globalNotify[0].title,
description: globalNotify[0].content,
duration: 3,
placement: 'top',
type: globalNotify[0].type,
onClick: () => {
clearGlobalNotify()
},
})
setTimeout(() => {
clearGlobalNotify()
}, 3030)
return () => {}
}, [globalNotify])
const [getEmailLoading, setEmailLoading] = useState(false)
const [fetchingText, setFetchingText] = useState('立即收件')
const handleGetEmail = async () => {

@ -1,145 +0,0 @@
import { createContext, useEffect, useState, useRef, useMemo } from 'react'
import { App, Button, Card, Empty, Flex, Select, Spin, Typography, Divider, Modal, List, Row, Col, Tag, Drawer, Input, Tooltip } from 'antd'
import { CloseOutlined, DownloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'
import { InboxIcon, SendPlaneFillIcon, ExpandIcon } from '@/components/Icons'
import EmailDetailInline from './EmailDetailInline'
import { debounce, isEmpty } from '@/utils/commons'
import useConversationStore from '@/stores/ConversationStore';
const EmailListDrawer = ({ showExpandBtn=true, title, list: otherEmailList, currentConversationID, opi_sn, oid, emailItem: clickItem, onOpenEditor, ...props }) => {
const [, setEmailMsg] = useConversationStore((state) => [state.emailMsg, state.setEmailMsg]);
const [open, setOpen] = useState(false)
const [selectedEmail, setSelectedEmail] = useState({})
const searchInputRef = useRef(null)
const [dataSource, setDataSource] = useState([])
useEffect(() => {
setOpen(false);
setDataSource(otherEmailList)
// setSelectedEmail({ MAI_SN: -1000 });
return () => {}
}, [otherEmailList])
const onClearSearch = () => {
setDataSource(otherEmailList)
}
const handleSearch = (value) => {
if (isEmpty(value)) onClearSearch()
const res = otherEmailList.filter((ele) => `${ele.MAI_Subject}${ele.SenderReceiver}`.toLowerCase().includes(value.toLowerCase()))
setDataSource(res)
}
const onClickEmailItem = (emailItem) => {
const emailMsg = {
conversationid: currentConversationID,
order_opi: opi_sn,
coli_sn: oid,
id: emailItem.MAI_SN,
MAI_SN: emailItem.MAI_SN,
msgOrigin: {
from: '',
to: '',
...(emailItem?.msgOrigin || {}),
id: emailItem.MAI_SN,
email: { mai_sn: emailItem.MAI_SN, subject: emailItem.MAI_Subject, id: emailItem.MAI_SN },
subject: emailItem.MAI_Subject,
},
}
console.log('emailItem', emailItem);
setSelectedEmail(emailMsg)
setEmailMsg(emailMsg);
};
const [pageCurrent, setPageCurrent] = useState(1);
const onChangePagination = (page, size) => {
setPageCurrent(page)
}
useEffect(() => {
if (!isEmpty(clickItem)) {
const itemIndex = dataSource.findIndex((ele) => ele.MAI_SN === clickItem.MAI_SN);
const page = Math.ceil((itemIndex+1) / 8) || 1;
setPageCurrent(page);
onClickEmailItem({...clickItem, ...dataSource[itemIndex]});
setOpen(true);
}
return () => {}
}, [clickItem]);
return (
<>
{showExpandBtn ? <Button
icon={<ExpandIcon />}
type={'primary'}
className='ml-2'
ghost
size='small'
onClick={() => {
setOpen(true)
}}
/> : null}
<Drawer
zIndex={3}
mask={false}
width={1000}
styles={{ header: {} }}
title={
<>
<Button icon={<CloseOutlined />} onClick={() => setOpen(false)} type='text' size='small' className='text-gray-500' />
<b>{title || '邮件列表'}</b>
<Input.Search
className=''
ref={searchInputRef}
allowClear
onClear={onClearSearch}
onPressEnter={(e) => {
handleSearch(e.target.value)
return false
}}
onSearch={(v, e, { source }) => handleSearch(v)}
placeholder={`输入: 标题/发件人, 回车搜索`}
/>
<List
dataSource={dataSource}
className='h-[30vh] overflow-y-auto'
pagination={false}
renderItem={(emailItem) => (
<List.Item
className={`hover:bg-stone-50 cursor-pointer !py-1 ${selectedEmail.MAI_SN === emailItem.MAI_SN ? 'bg-blue-100 font-bold ' : ''}`}
onClick={() => onClickEmailItem(emailItem)}>
<Flex vertical={false} wrap={false} className='w-full'>
<div className='flex-auto ml-auto min-w-40 line-clamp-2'>
{emailItem.Direction === '收' ? <InboxIcon className='text-indigo-500' /> : <SendPlaneFillIcon className='text-primary' />}
{/* <Tooltip title={emailItem.MAI_Subject}> */}
<Typography.Text>{emailItem.MAI_Subject}</Typography.Text>
{/* </Tooltip> */}
</div>
<div className='ml-1 max-w-40'>
<Typography.Text ellipsis={{ tooltip: emailItem.SenderReceiver }}>{emailItem.SenderReceiver.replaceAll('"', '')}</Typography.Text>
</div>
<div className='ml-1 max-w-20'>
<Typography.Text ellipsis={{ tooltip: emailItem.MAI_SendDate }}>{dayjs(emailItem.MAI_SendDate).format('MM-DD HH:mm')}</Typography.Text>
</div>
</Flex>
</List.Item>
)}
/>
</>
}
classNames={{ header: '!py-1 !px-2 [&_.ant-drawer-title]:font-normal [&_.ant-list-pagination]:m-0', body: '!p-1 [&_.ant-list-pagination]:ms-1' }}
placement='right'
closeIcon={null}
onClose={() => {
setOpen(false)
}}
open={open}>
<EmailDetailInline {...{ mailID: selectedEmail.MAI_SN, emailMsg: selectedEmail, onOpenEditor }} />
</Drawer>
</>
)
}
export default EmailListDrawer

@ -1,93 +0,0 @@
import { useEffect, useState } from 'react'
import { App, Button } from 'antd'
import EmailEditorPopup from '../Input/EmailEditorPopup'
import { useOrderStore } from '@/stores/OrderStore'
import useAuthStore from '@/stores/AuthStore'
import useConversationStore from '@/stores/ConversationStore'
import { getEmailQuotationDraftAction } from '@/actions/EmailActions'
import { isEmpty } from '@/utils/commons'
const EmailQuotation = ({ sfi_sn, ...props }) => {
const {notification} = App.useApp()
const currentConversation = useConversationStore((state) => state.currentConversation)
const { userId, username, emailList, email } = useAuthStore((state) => state.loginUser)
const [orderDetail, customerDetail] = useOrderStore((s) => [s.orderDetail, s.customerDetail])
const emailListOption = emailList?.map((ele) => ({ ...ele, label: ele.email, key: ele.email, value: ele.email })) || []
const emailListAddrMapped = emailListOption?.reduce((r, v) => ({ ...r, [v.email]: v }), {})
const [pickEmail, setPickEmail] = useState({ key: email, email })
useEffect(() => {
const order_opi = Number(orderDetail?.opi_sn || userId)
const find =
emailListOption?.find((ele) => ele.opi_sn === order_opi && ele.default === true) ||
emailListOption?.find((ele) => ele.opi_sn === order_opi && ele.backup === true) ||
emailListOption?.find((ele) => ele.opi_sn === order_opi) ||
emailListOption?.find((ele) => ele.default === true) ||
emailListOption?.find((ele) => ele.backup === true) ||
emailListOption[0]
setPickEmail(find)
return () => {}
}, [orderDetail])
const [draftLoading, setDraftLoading] = useState(false)
const [draft, setDraft] = useState({})
const getEmailDraft = async ({ sfi_sn, coli_sn, lgc = 1 }) => {
if (isEmpty(sfi_sn)) {
return false
}
try {
setDraftLoading(true)
const data = await getEmailQuotationDraftAction({ sfi_sn, coli_sn, lgc })
setDraft(data)
setDraftLoading(false)
} catch (err) {
setDraftLoading(false)
notification.error({
message: '请求失败',
description: err.message || '网络异常',
placement: 'top',
duration: 3,
})
}
}
const [editorOpen, setEditorOpen] = useState(false)
return (
<>
<Button loading={draftLoading}
type='link'
key={'email-now'} size='small'
onClick={async () => {
await getEmailDraft({ sfi_sn, coli_sn: currentConversation.coli_sn })
setEditorOpen(true)
}}>
邮件
</Button>
{/* <EmailEditorPopup
open={editorOpen}
setOpen={setEditorOpen}
fromEmail={pickEmail.key}
fromUser={orderDetail.opi_sn}
toEmail={currentConversation?.channels?.email || customerDetail?.email}
fromOrder={currentConversation.coli_sn}
oid={orderDetail?.order_no}
conversationid={currentConversation.sn}
// emailMsg={ReferEmailMsg}
// quoteid={mailID}
// initial={{ ...initialPosition, ...initialSize }}
// mailData={mailData}
draft={draft}
// customerDetail={customerDetail}
action={'new'}
key={`email-quotation-new-popup_${currentConversation.sn}`}
/> */}
</>
)
}
export default EmailQuotation

@ -1,14 +0,0 @@
import { createContext, useEffect, useState } from 'react'
import { Drawer } from 'antd'
import SnippetList from '@/views/accounts/SnippetList'
import useSnippetStore from '@/stores/SnippetStore'
const GenerateAutoDocDrawer = ({ ...props }) => {
const [openSnippetDrawer, closeSnippetDrawer, snippetDrawerOpen] = useSnippetStore((state) => [state.openDrawer, state.closeDrawer, state.drawerOpen])
return (
<Drawer title='图文集' placement={'top'} size={'large'} onClose={() => closeSnippetDrawer()} open={snippetDrawerOpen}>
<SnippetList></SnippetList>
</Drawer>
)
}
export default GenerateAutoDocDrawer

@ -1,14 +0,0 @@
import { createContext, useEffect, useState } from 'react'
import { Drawer } from 'antd'
import { useOrderStore } from '@/stores/OrderStore'
import GeneratePayment from '@/views/accounts/GeneratePayment'
const GeneratePaymentDrawer = ({ ...props }) => {
const [openPaymentDrawer, closePaymentDrawer, paymentDrawerOpen] = useOrderStore((state) => [state.openDrawer, state.closeDrawer, state.drawerOpen])
return (
<Drawer title='支付链接' placement={'top'} size={'large'} onClose={() => closePaymentDrawer()} open={paymentDrawerOpen}>
<GeneratePayment></GeneratePayment>
</Drawer>
)
}
export default GeneratePaymentDrawer

@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { App, Button, Popover, Tabs, List, Image, Avatar, Card, Flex, Space,Typography } from 'antd';
import { App, Button, Popover, Tabs, List, Image, Avatar, Card, Flex, Space } from 'antd';
import { FileSearchOutlined, LoadingOutlined } from '@ant-design/icons';
import { RotateLeftOutlined, RotateRightOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons'
import { InboxIcon, SendPlaneFillIcon } from '@/components/Icons';
import { groupBy, isEmpty, TagColorStyle as CalColorStyle } from '@/utils/commons';
import { groupBy, isEmpty, stringToColour } from '@/utils/commons';
import { useShallow } from 'zustand/react/shallow';
import EmailDetail from './EmailDetail';
import { MESSAGE_PAGE_SIZE, fetchMessagesHistory } from '@/actions/ConversationActions';
@ -13,10 +13,14 @@ import useStyleStore from '@/stores/StyleStore';
import useAuthStore from '@/stores/AuthStore';
import { sentMsgTypeMapped } from '@/channel/bubbleMsgUtils';
import { v4 as uuid } from 'uuid';
import dayjs from 'dayjs'
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 10;
const CalColorStyle = (tag, outerStyle = true) => {
const color = stringToColour(tag);
const outerStyleObj = outerStyle ? { borderColor: `${color}66`, backgroundColor: `${color}0D` } : {};
return { color: `${color}`, ...outerStyleObj };
};
const getVideoName = (vUrl) => {
if (!vUrl) return '';
const url = new URL(vUrl);
@ -198,7 +202,7 @@ const MessageListFilter = ({ ...props }) => {
<List.Item>
<List.Item.Meta
avatar={
<Avatar size='small' style={CalColorStyle(item.sender, true)}>
<Avatar size='small' style={CalColorStyle(item.sender)}>
{item.senderName}
</Avatar>
}
@ -239,7 +243,7 @@ const MessageListFilter = ({ ...props }) => {
<List.Item actions={[item.localDate]}>
<List.Item.Meta
avatar={
<Avatar size='small' style={CalColorStyle(item.senderName, true)}>
<Avatar size='small' style={CalColorStyle(item.senderName)}>
{item.senderName.substring(0, 5)}
</Avatar>
}
@ -287,7 +291,7 @@ const MessageListFilter = ({ ...props }) => {
<List.Item>
<List.Item.Meta
avatar={
<Avatar size='small' style={CalColorStyle(item.sender, true)}>
<Avatar size='small' style={CalColorStyle(item.sender)}>
{item.senderName}
</Avatar>
}
@ -321,9 +325,6 @@ const MessageListFilter = ({ ...props }) => {
const [openEmailDetail, setOpenEmailDetail] = useState(false);
const [emailDetail, setEmailDetail] = useState({});
const [initialPosition, setInitialPosition] = useState({})
const [initialSize, setInitialSize] = useState({})
const onOpenEmail = (email_detail) => {
setOpenEmailDetail(true);
setEmailDetail(email_detail);
@ -341,36 +342,39 @@ const MessageListFilter = ({ ...props }) => {
renderItem={({ msgtext, ...item }) => (
<List.Item
// actions={[item.localDate]}
className='cursor-pointer !py-1'
className='cursor-pointer'
onClick={() => {
onOpenEmail({ msgtext, ...item });
// setOpenPopup(false);
setOpenPopup(false);
}}>
<Flex vertical={false} wrap={false} className='w-full'>
<div className='flex-auto ml-auto min-w-40 line-clamp-2'>
{item.sender !== 'me' ? <InboxIcon className='text-indigo-500' /> : <SendPlaneFillIcon className='text-primary' />}
{/* <Tooltip title={emailItem.MAI_Subject}> */}
<Typography.Text>{msgtext?.email?.subject}</Typography.Text>
{/* </Tooltip> */}
</div>
<div className='ml-1 max-w-40'>
<Typography.Text ellipsis={{ tooltip: msgtext.to }}>{msgtext.to.replaceAll('"', '')}</Typography.Text>
</div>
<div className='ml-1 max-w-20'>
<Typography.Text ellipsis={{ tooltip: item.localDate }}>{dayjs(item.localDate).format('MM-DD HH:mm')}</Typography.Text>
</div>
</Flex>
<List.Item.Meta
avatar={
item.sender === 'me' ? <SendPlaneFillIcon className='text-primary' /> : <InboxIcon className='text-indigo-500' />
// <Avatar size='small' style={CalColorStyle(item.senderName)}>
// {item.senderName.substring(0, 3)}
// </Avatar>
}
title={msgtext?.email?.subject}
// description={`To: ${msgtext.to}`}
description={
<Flex justify={'space-between'} className='max-w-full overflow-hidden'>
<div className='flex-auto line-clamp-1 break-all pr-2'>{`To: ${msgtext.to}`}</div>
<div className=' basis-32 flex-grow-0 flex-shrink-0'>{item.localDate}</div>
</Flex>
}
/>
{msgtext?.email?.abstract}
</List.Item>
)}
/>
<EmailDetail open={openEmailDetail} setOpen={setOpenEmailDetail} emailMsg={emailDetail} key={`email-detail-1-${emailDetail.id}`} {...{initialPosition, initialSize, setInitialPosition, setInitialSize}} />
<EmailDetail open={openEmailDetail} setOpen={setOpenEmailDetail} emailMsg={emailDetail} key={`email-detail-1-${emailDetail.id}`} />
</>
);
};
return (
<>
<Popover zIndex={3}
<Popover
// destroyTooltipOnHide
placement='bottom'
overlayClassName={[mobile === false ? 'w-2/5' : 'w-full max-h-full', 'max-h-[70%]'].join(' ')}
@ -390,7 +394,7 @@ const MessageListFilter = ({ ...props }) => {
{ key: 'video', label: '视频', children: <Videos /> },
{ key: 'audio', label: '音频', children: <Audios /> },
{ key: 'file', label: '文件', children: <FileList /> },
// { key: 'email', label: '', children: <EmailList /> },
{ key: 'email', label: '邮件', children: <EmailList /> },
]}
/>
</>

@ -126,7 +126,7 @@ export const ConversationBindFormModal = ({ currentConversationID, userId, onBou
onCancel={() => {
setOpen(false);
}}
destroyOnHidden>
destroyOnClose>
<AdvanceSearchForm onSubmit={onSearchOrder} loading={searchLoading} />
<Table
key={'advanceOrderTable'}

@ -7,7 +7,7 @@ import ConversationsNewItem from './ConversationsNewItem';
import { debounce, flush, isEmpty, isNotEmpty, pick } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore';
import useAuthStore from '@/stores/AuthStore';
// import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions } from "@/stores/OrderStore";
import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions } from "@/stores/OrderStore";
import { useVisibilityState } from '@/hooks/useVisibilityState';
import ChatListItem from './Components/ChatListItem';
import ChatListFilter from './Components/ChatListFilter';
@ -25,14 +25,14 @@ const Conversations = () => {
const routerReplace = mobile === false ? true : false; // : true;
const routePrefix = mobile === false ? `/order/chat` : `/m/chat`;
const { state: orderRow } = useLocation();
const { coli_guest_WhatsApp, guest_email } = orderRow || {};
const { coli_guest_WhatsApp } = orderRow || {};
const { order_sn } = useParams();
const navigate = useNavigate();
const userId = useAuthStore((state) => state.loginUser.userId);
const initialState = useConversationStore((state) => state.initialState);
// const [customerDetail] = useOrderStore((s) => [s.customerDetail])
const [customerDetail] = useOrderStore((s) => [s.customerDetail])
const [currentConversation, setCurrentConversation, updateCurrentConversation] = useConversationStore((state) => [state.currentConversation, state.setCurrentConversation, state.updateCurrentConversation]);
const [topList, pageList, conversationsList] = useConversationStore((state) => [state.topList, state.pageList, state.conversationsList]);
@ -70,7 +70,6 @@ const Conversations = () => {
olabel: otypeC === 'label' ? otypeV : '',
ostate: otypeC === 'state' ? otypeV : '',
intour: otypeC === 'intour' ? otypeV : '',
istoday: otypeC === 'istoday' ? otypeV : '',
session_enable: activeList ? 1 : 0,
lastpagetime: current ?
dayjs(current).add(1, 'minutes').format('YYYY-MM-DDTHH:mm:ss')
@ -107,32 +106,26 @@ const Conversations = () => {
}
useEffect(() => {
// console.log('effect []');
if (mobile !== false) {
setCurrentConversation({});
} else
if (order_sn ) {
setCurrentConversation({ coli_sn: Number(order_sn), sn: null })
// updateCurrentConversation({ coli_sn: Number(order_sn) });
}
return () => {};
}, []);
// useEffect(() => {
// if (order_sn && shouldFetchCList) {
// setCurrentConversation({ coli_sn: Number(order_sn) })
// // updateCurrentConversation({ coli_sn: Number(order_sn) });
// } else {
// // setCurrentConversation({ coli_sn: order_sn })
// }
useEffect(() => {
if (order_sn && shouldFetchCList) {
setCurrentConversation({ coli_sn: Number(order_sn) })
// updateCurrentConversation({ coli_sn: Number(order_sn) });
} else {
// setCurrentConversation({ coli_sn: order_sn })
}
// return () => {}
// }, [order_sn])
return () => {}
}, [order_sn])
useEffect(() => {
// console.log('effect isVisible', isVisible);
if (isVisible && initialState) {
refreshConversationList(new Date()); // , loading,
}
@ -141,7 +134,6 @@ const Conversations = () => {
}, [isVisible]);
useEffect(() => {
// console.log('effect activeList');
if (isVisible && initialState) {
refreshConversationList(); // loading
}
@ -158,30 +150,21 @@ const Conversations = () => {
setDataSource([...topList, ...pageList]);
// setDataSource(activeList ? conversationsList: closedConversationsList);
return () => {};
}, [conversationsList, topList, pageList, listUpdateFlag, currentConversation.unread_msg_count]);
}, [conversationsList, listUpdateFlag, currentConversation.unread_msg_count]);
let orderChatRefreshing = false;
useEffect(() => {
// console.log('first', isEmpty(currentConversation.sn) , order_sn , shouldFetchCList , initialState)
// isEmpty(currentConversation.sn) &&
if (order_sn && shouldFetchCList && initialState) {
if (isEmpty(currentConversation.sn) && order_sn && shouldFetchCList && initialState) {
getOrderConversationList(order_sn)
}
return () => {}
}, [order_sn, shouldFetchCList, initialState])
}, [order_sn, initialState, customerDetail.email])
const getOrderConversationList = async (colisn) => {
if (orderChatRefreshing !== false) {
return false;
}
orderChatRefreshing = true;
const { whatsapp_phone_number } = switchToC;
const whatsappID = coli_guest_WhatsApp || whatsapp_phone_number || '';
const _email = guest_email || '';
// if (isEmpty(conversationsList)) {
// return false;
// }
@ -192,21 +175,20 @@ const Conversations = () => {
// let findCurrentOrderChats = conversationsList.filter((item) => `${item.coli_sn}` === `${colisn}`);
// 使opisn + whatsappID , whatsappID,
if (!isEmpty(whatsappID) || !isEmpty(_email)) {
if (!isEmpty(whatsappID) || !isEmpty(customerDetail.email)) {
findCurrentOrderChats = conversationsList.filter(
(item) => `${item.coli_sn}` === `${colisn}` && (`${item.whatsapp_phone_number}` === `${whatsappID}` || `${item.channels?.email}` === `${_email}`)
(item) => `${item.coli_sn}` === `${colisn}` && (`${item.whatsapp_phone_number}` === `${whatsappID}` || `${item.channels?.email}` === `${customerDetail.email}`)
)
findCurrentIndex = isEmpty(findCurrentOrderChats) ? -1 : 0; // findCurrentOrderChats.length-1;
findCurrent = findCurrentOrderChats[findCurrentIndex];
if (findCurrentIndex !== -1) {
addToConversationList(findCurrentOrderChats, 'top');
addToConversationList(findCurrentOrderChats);
} else // if (!isEmpty(whatsappID))
{
try {
setShouldFetchCList(false);
const data = await fetchOrderConversationsList({ opisn: userId, colisn: colisn, whatsappid: whatsappID, email: _email });
const data = await fetchOrderConversationsList({ opisn: userId, colisn: colisn, whatsappid: whatsappID, email: customerDetail.email });
if (!isEmpty(data)) {
addToConversationList(data);
findCurrentIndex = 0; // data.length-1; // data.lastIndexOf((item) => item.coli_sn === Number(colisn));
@ -219,13 +201,12 @@ const Conversations = () => {
}
}
}
else {
// if (isEmpty(whatsappID) && findCurrentIndex === undefined) {
// else
if (isEmpty(whatsappID)) {
//
findCurrentIndex = conversationsList.findIndex((item) => `${item.coli_sn}` === `${colisn}`);
findCurrent = conversationsList[findCurrentIndex];
}
orderChatRefreshing = false;
if (findCurrentIndex >= 0) {
setCurrentConversation(findCurrent);
return findCurrent;
@ -238,8 +219,6 @@ const Conversations = () => {
const onSwitchConversation = async (item) => {
setCurrentConversation(item);
setShouldFetchCList(false);
if (isEmpty(item.coli_sn)) {
navigate(routePrefix, { replace: true });
} else {
@ -318,34 +297,30 @@ const Conversations = () => {
// enterButton={'Filter'}
/>
{/* TODO 这个在完成搜索历史会话后去掉,待讨论查询规则 */}
</div>
<div className="flex gap-1 justify-between items-center shadow">
<div className='mr-auto'>{/* 占位 */}</div>
<ChatListFilter key='chat-list-filter'
<Tooltip key={'conversation-list'} title={activeList ? '隐藏会话' : '活跃会话'}>
<Button onClick={toggleClosedConversationsList} icon={activeList ? <HistoryOutlined className='text-neutral-500' /> : <FireOutlined className=' text-orange-500' />} type='text' />
</Tooltip>
{mobile && (
<AudioTwoTone
onClick={() => {
navigate(`/callcenter/call`)
}}
/>
)}
</div>
<ChatListFilter
onFilterChange={(d) => {
refreshConversationList()
}}
activeList={activeList}
/>
{conversationsListLoading ? (
<div className='text-center py-1 px-2'>
<LoadingOutlined className='text-orange-400 ' />
</div>
) :
<Tooltip key={'conversation-list'} title={activeList ? '🗂已隐藏' : '活跃会话'}>
<Button onClick={toggleClosedConversationsList} icon={activeList ? '🗂' : '📌'} type='text' />
</Tooltip>
}
{mobile && (
<AudioTwoTone className='px-3'
onClick={() => {
navigate(`/callcenter/call`)
}}
/>
)}
</div>
<div className='flex-1 overflow-x-hidden overflow-y-auto relative'>
{/* {mobile && conversationsListLoading && dataSource.length === 0 ? ( */}
{conversationsListLoading && currentLoading ? (
<div className='text-center py-2'>
<LoadingOutlined className='text-primary ' />
</div>
) : null}
<List
itemLayout='vertical'
@ -368,7 +343,6 @@ const Conversations = () => {
onSwitchConversation,
setNewChatModalVisible,
setEditingChat,
conversationsListLoading,
}}
/>
)}

@ -21,7 +21,7 @@ export const ConversationItemForm = ({ initialValues, onFormInstanceReady }) =>
}, []);
useEffect(() => {
if (isEmpty(initialValues.wa_id)) form.setFieldValue('wa_id', formatPhone.trim());
form.setFieldValue('wa_id', formatPhone);
return () => {};
}, [formatPhone]);
@ -38,7 +38,7 @@ export const ConversationItemForm = ({ initialValues, onFormInstanceReady }) =>
layout='horizontal'
form={form}
name='form_in_modal'
initialValues={{ ...fromCurrent, ...initialValues, }}
initialValues={{ ...fromCurrent, ...initialValues, wa_id: formatPhone }}
onValuesChange={onValuesChange}
labelCol={{ span: 4 }}
labelWrap={true}>
@ -59,7 +59,7 @@ export const ConversationItemForm = ({ initialValues, onFormInstanceReady }) =>
<Form.Item label='WhatsApp' name={'wa_id'} dependencies={['phone_number']}>
<Input disabled={!initialValues.is_new && isNotEmpty(initialValues.wa_id)} />
</Form.Item>
<Form.Item hidden
<Form.Item
name={'email'}
label='邮箱'
validateStatus={contactValidateStatus}
@ -67,7 +67,7 @@ export const ConversationItemForm = ({ initialValues, onFormInstanceReady }) =>
rules={[{ type: 'email', message: '请输入正确的邮箱地址' }]}>
<Input disabled={!initialValues.is_new && isNotEmpty(initialValues.email)} />
</Form.Item>
{/* {initialValues.is_current_order && (
{initialValues.is_current_order && (
<>
<Form.Item name={'coli_id'} label={'关联当前订单'} rules={[{ required: true, message: '关联的订单' }]} validateStatus='warning' help='请务必确认关联的订单是否正确'>
<Input placeholder='请先关联订单' disabled={initialValues.is_current_order} />
@ -76,7 +76,7 @@ export const ConversationItemForm = ({ initialValues, onFormInstanceReady }) =>
<Input placeholder='订单SN' />
</Form.Item>
</>
)} */}
)}
{/* <div className=' text-neutral-500 italic'>如果会话已存在, 将直接切换</div> */}
</Form>
)
@ -124,7 +124,7 @@ export const ConversationItemFormModal = ({ open, onCreate, onCancel, initialVal
onCancel();
formInstance?.resetFields();
}}
destroyOnHidden
destroyOnClose
onOk={async () => {
try {
const values = await formInstance?.validateFields();

@ -1,35 +0,0 @@
import { createContext, useEffect, useState } from 'react'
import { Flex } from 'antd'
import InputTemplate from './Template'
import InputEmoji from './Emoji'
import InputMediaUpload from './MediaUpload'
import PaymentlinkBtn from './PaymentlinkBtn'
import SnippestBtn from './SnippestBtn'
import useConversationStore from '@/stores/ConversationStore'
import { isEmpty } from '@/utils/commons'
const ComposerTools = ({ channel, invokeSendUploadMessage, invokeSendMessage, invokeUploadFileMessage, inputEmoji, ...props }) => {
const websocket = useConversationStore((state) => state.websocket)
const websocketOpened = useConversationStore((state) => state.websocketOpened)
const currentConversation = useConversationStore((state) => state.currentConversation)
const talkabled = !isEmpty(currentConversation.sn) && websocketOpened
return (
<>
<Flex gap={4} className='*:text-primary *:rounded-none items-center'>
{['waba', 'wai'].includes(channel) && <InputTemplate key='templates' disabled={!talkabled} invokeSendMessage={invokeSendMessage} channel={channel} />}
<InputEmoji key='emoji' disabled={!talkabled} inputEmoji={inputEmoji} />
{['waba', 'wa', 'wai', 'whatsapp'].includes(channel) && <InputMediaUpload key={'addNewMedia'} disabled={!talkabled} {...{ invokeUploadFileMessage, invokeSendUploadMessage }} />}
<PaymentlinkBtn />
<SnippestBtn />
{/* <Button type='text' className='' icon={<YoutubeOutlined />} size={'middle'} />
<Button type='text' className='' icon={<AudioOutlined />} size={'middle'} /> */}
</Flex>
</>
)
}
export default ComposerTools

@ -0,0 +1,83 @@
import { useEffect, useState, useMemo } from 'react'
import EmailBuilder, {
createYooptaEmailEditor,
// type EmailTemplateOptions,
} from '@yoopta/email-builder'
const templateOptions = {
head: {
styles: [
{
id: 'font',
content: `body { font-family: Verdana, sans-serif; }`,
},
],
meta: [
{ content: 'width=device-width', name: 'viewport' },
{ charset: 'UTF-8' },
{ content: 'IE=edge', httpEquiv: 'X-UA-Compatible' },
{ content: 'telephone=no,address=no,email=no,date=no,url=no', name: 'format-detection' },
{ content: 'light', name: 'color-scheme' },
],
},
body: {
attrs: {
style: {
backgroundColor: '#fafafa',
width: '900px',
margin: '0 auto',
},
},
},
container: {
attrs: {
style: {
width: 800,
margin: '0 auto',
},
},
},
}
function EmailBuilderC() {
const editor = useMemo(() => createYooptaEmailEditor({ template: templateOptions }), [])
const [value, setValue] = useState({})
const uploadImageToServer = async () => {}
const uploadVideoToServer = async () => {}
const uploadPosterToServer = async () => {}
const uploadFileToServer = async () => {}
// media upload handlers
const mediaUploaders = {
image: {
upload: async (file) => {
const url = await uploadImageToServer(file)
return url
},
},
video: {
upload: async (file) => {
const url = await uploadVideoToServer(file)
return url
},
uploadPoster: async (file) => {
const url = await uploadPosterToServer(file)
return url
},
},
file: {
upload: async (file) => {
const url = await uploadFileToServer(file)
return url
},
},
}
return (
<div className='h-96'>
<EmailBuilder view='editor' className='h-96' editor={editor} value={value} onChange={setValue} media={mediaUploaders} header={null} autoFocus={true} placeholder='Write your email here...' />
</div>
)
}
export default EmailBuilderC

@ -1,277 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { App, Button, ConfigProvider, Dropdown, Flex, Select, Input, Tooltip, Form, Alert } from 'antd'
import { DownOutlined, DollarOutlined, ExpandAltOutlined, ExpandOutlined, SendOutlined, } from '@ant-design/icons'
import EmailEditorPopup from './EmailEditorPopup'
import useStyleStore from '@/stores/StyleStore'
import useAuthStore from '@/stores/AuthStore'
// import { isEmpty, } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore'
import { useOrderStore } from '@/stores/OrderStore'
import { EditIcon } from '@/components/Icons'
import { cloneDeep, isEmpty } from '@/utils/commons'
import { v4 as uuid } from 'uuid'
import { postSendEmail } from '@/actions/EmailActions'
import { sentMsgTypeMapped, } from '@/channel/bubbleMsgUtils';
import ComposerTools from './ComposerTools'
const EmailComposer = ({ ...props }) => {
const { notification } = App.useApp()
const [form] = Form.useForm()
const [mobile] = useStyleStore((state) => [state.mobile])
const { userId, username, emailList, email } = useAuthStore((state) => state.loginUser)
// const websocketOpened = useConversationStore((state) => state.websocketOpened);
const currentConversation = useConversationStore((state) => state.currentConversation)
// const talkabled = !isEmpty(currentConversation.sn) && websocketOpened;
const { orderDetail, customerDetail } = useOrderStore()
const disabled = isEmpty(emailList);
const emailListOption = emailList?.map((ele) => ({ ...ele, label: ele.email, key: ele.email, value: ele.email })) || []
const emailListAddrMapped = emailListOption?.reduce((r, v) => ({ ...r, [v.email]: v }), {});
const [pickEmail, setPickEmail] = useState({ key: email, email })
const [fromUser, setFromUser] = useState()
useEffect(() => {
const order_opi = Number(orderDetail?.opi_sn || userId)
setFromUser(order_opi)
const find =
emailListOption?.find((ele) => ele.opi_sn === order_opi && ele.default === true) ||
emailListOption?.find((ele) => ele.opi_sn === order_opi && ele.backup === true) ||
emailListOption?.find((ele) => ele.opi_sn === order_opi) ||
emailListOption?.find((ele) => ele.default === true) ||
emailListOption?.find((ele) => ele.backup === true) ||
emailListOption[0]
setPickEmail(find)
return () => {}
}, [orderDetail])
const [open, setOpen] = useState(false)
const [fromEmail, setFromEmail] = useState('')
const [toEmail, setToEmail] = useState('')
const lastFocusedFieldRef = useRef(null);
const textInputRef = useRef(null)
const websocketOpened = useConversationStore((state) => state.websocketOpened)
const talkabled = !isEmpty(currentConversation.sn) && websocketOpened
const [sendLoading, setSendLoading] = useState(false)
const [quickData, setQuickData] = useState({ suject: '', content: ''});
const handleFocus = (field) => {
lastFocusedFieldRef.current = field;
}
const addEmoji = (emoji) => {
const _field = lastFocusedFieldRef.current || 'mailcontent';
// if (focusedField) {
const fieldValue = form.getFieldValue(_field) || '';
const updatedValue = `${fieldValue}${emoji}`;
// form.setFieldsValue({ [_field]: updatedValue });
form.setFieldValue(_field, updatedValue)
form.focusField(_field)
// }
}
const openEditor = (email_addr) => {
setOpen(true)
setFromEmail(email_addr)
setToEmail(currentConversation?.channels?.email || customerDetail?.email || '')
setQuickData({
subject: form.getFieldValue('subject'),
content: form.getFieldValue('mailcontent'),
})
}
/**
* 保存成功, 推一个气泡
* 再从异步通知更新消息发送状态
*/
const sentOrReceivedNewMessage = useConversationStore((state) => state.sentOrReceivedNewMessage);
const invokeEmailMessage = (msgObj) => {
const msgObjMerge = {
sender: 'me',
senderName: 'me',
// to: currentConversation.whatsapp_phone_number,
date: new Date(),
status: 'waiting', // accepted
...msgObj,
// id: `${currentConversation.sn}.${msgObj.id}`,
// id: `${stickToCid}.${msgObj.id}`,
conversationid: currentConversation.sn,
msg_source: 'email',
};
// olog('invoke upload', msgObjMerge)
const contentToRender = sentMsgTypeMapped[msgObjMerge.type].contentToRender(msgObjMerge);
// console.log(contentToRender, 'contentToRender sendMessage------------------');
sentOrReceivedNewMessage(contentToRender.conversationid, contentToRender);
};
// const [quickValidateHelp, setQuickValidateHelp] = useState('')
const handleSendEmail = async (values) => {
// console.log('invoke email message');
const emailAccount = { opi_sn: fromUser, email: pickEmail.key, mat_sn: '' };
emailAccount.opi_sn = fromUser || emailListAddrMapped?.[emailAccount.email]?.opi_sn || '';
emailAccount.mat_sn = emailListAddrMapped?.[emailAccount.email]?.mat_sn || '';
const stickToCid = currentConversation.sn
const body = {}
body.subject = values.subject
body.mailcontent = values.mailcontent
body.from = emailAccount.email
body.to = currentConversation.channels?.email || customerDetail?.email || ''
// body.attaList = fileList;
body.opi_sn = emailAccount.opi_sn
body.mat_sn = emailAccount.mat_sn
body.coli_sn = currentConversation?.coli_sn || ''
// console.log('body', body, '\n')
body.cc = ''
body.bcc = ''
const msgObj = {
type: 'email',
id: uuid(),
from: body.from,
to: body.to,
cc: '',
bcc: '',
subject: values.subject,
content: body.mailcontent,
email: {
subject: values.subject,
content: body.mailcontent,
},
coli_id: orderDetail?.order_no || '',
}
setSendLoading(true)
body.externalID = stickToCid
body.actionID = `${stickToCid}.${msgObj.id}`
body.contenttype = 'text/plain';
try {
const bubbleMsg = cloneDeep(msgObj)
bubbleMsg.id = `${stickToCid}.${msgObj.id}`
bubbleMsg.content = undefined
// console.log('email message', body, '\n', bubbleMsg)
const result = await postSendEmail(body)
const mailSavedId = result.id || ''
bubbleMsg.email.mai_sn = mailSavedId
invokeEmailMessage(bubbleMsg)
form.resetFields();
} catch (error) {
notification.error({
message: '邮件保存失败',
description: error.message,
placement: 'top',
duration: 3,
})
} finally {
setSendLoading(false)
}
}
return (
<ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}>
<Form
form={form}
preserve={false}
name='email_quick_form'
layout={'inline'}
// initialValues={{}}
onFinish={handleSendEmail}
onFinishFailed={console.log}
className=''
>
{/* <Form.Item name='from' className='hidden'></Form.Item>
<Form.Item name='to' className='hidden'></Form.Item>
<Form.Item name='cc' className='hidden'></Form.Item>
<Form.Item name='bcc' className='hidden'></Form.Item> */}
<Form.Item name='subject' className='w-full' rules={[{ required: true, message: '' }]}>
<Input tabIndex={1}
className='rounded-b-none border-b-0 font-bold text-base text-indigo-600'
placeholder='*主题'
disabled={!talkabled} allowClear
onFocus={() => handleFocus('subject')}
suffix={
<Tooltip title={'全文编辑'}>
<Button
type='text'
size='small'
onClick={() => openEditor(pickEmail.key)}
icon={<ExpandOutlined className='text-indigo-600' />}
/>
</Tooltip>
}
/>
</Form.Item>
<Form.Item name='mailcontent' className='w-full' rules={[{ required: true, message: '' }]}>
<Input.TextArea tabIndex={2}
allowClear
ref={textInputRef}
onFocus={() => handleFocus('mailcontent')}
size='large'
// maxLength={2000}
// showCount={true}
placeholder={!talkabled ? '请先选择会话' : '*纯文本邮件'}
rows={2}
disabled={!talkabled}
className='rounded-none emoji'
autoSize={{ minRows: 2, maxRows: 6 }}
/>
</Form.Item>
<Flex gap={8} className='w-full bg-gray-200 p-1 rounded-b-0' align={'center'} justify={'space-between'}>
<ComposerTools key={'et'} channel={'email'} inputEmoji={addEmoji} />
{/* <span>切换邮箱:</span>*/}
<Flex gap={4} align={'center'}>
<div className='text-red-500'>
{/* <div>{textPlaceHolder}</div> */}
{/* {quickValidateHelp} */}
</div>
<Select className={mobile ? 'w-24' : ''}
disabled={!talkabled} popupMatchSelectWidth={false}
// size={'small'}
options={emailListOption}
labelInValue
value={pickEmail}
onChange={(val) => {
setPickEmail(val)
setFromUser(emailListAddrMapped?.[val.value]?.opi_sn)
}}
// variant={'borderless'}
/>
<Button icon={<SendOutlined />} type='primary' htmlType={'submit'} disabled={!talkabled} loading={sendLoading} tabIndex={3}>
发送
</Button>
{/* <Button icon={<EditIcon />} type='primary' onClick={() => openEditor(pickEmail.key)}>
新邮件
</Button> */}
</Flex>
</Flex>
</Form>
{disabled &&
<Alert message="账户没有配置邮箱地址" description='请 重新登录 获取最新配置' type="warning" showIcon className=' absolute top-0 left-0 right-0 bottom-0' />
}
{/* <EmailEditorPopup
{...{ open, setOpen }}
fromEmail={fromEmail}
fromUser={fromUser}
fromOrder={currentConversation.coli_sn}
oid={orderDetail?.order_no}
conversationid={currentConversation.sn}
toEmail={toEmail}
draft={quickData}
customerDetail={customerDetail}
action='new'
key={'email-new-editor-popup'}
/> */}
</ConfigProvider>
)
}
export default EmailComposer

@ -1,8 +1,8 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { App, ConfigProvider, Button, Form, Input, Flex, Checkbox, Popconfirm, Select, Space, Upload, Divider, Modal, Tabs } from 'antd';
import { useEffect, useState } from 'react';
import { App, ConfigProvider, Button, Form, Input, Flex, Checkbox, Popconfirm, Select, Space, Upload, Divider } from 'antd';
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons';
import '@dckj/react-better-modal/dist/index.css';
import DnDModal from '@/components/DnDModal';
import DnDModal from '@/components/DndModal';
import useStyleStore from '@/stores/StyleStore';
import useConversationStore from '@/stores/ConversationStore';
import useAuthStore from '@/stores/AuthStore';
@ -10,15 +10,14 @@ import useAuthStore from '@/stores/AuthStore';
import LexicalEditor from '@/components/LexicalEditor';
import { v4 as uuid } from 'uuid';
import { cloneDeep, debounce, isEmpty, } from '@/utils/commons';
import { writeIndexDB } from '@/utils/indexedDB';
import { cloneDeep, isEmpty } from '@/utils/commons';
import './EmailEditor.css';
import { postSendEmail } from '@/actions/EmailActions';
import { sentMsgTypeMapped, } from '@/channel/bubbleMsgUtils';
import { EmailBuilder, useEmailDetail, useEmailSignature } from '@/hooks/useEmail';
import { useEmailDetail } from '@/hooks/useEmail';
import useSnippetStore from '@/stores/SnippetStore'
import { useOrderStore } from '@/stores/OrderStore'
import PaymentlinkBtn from './PaymentlinkBtn';
import EmailBuilderC from './Editor/EmailBuilder';
//
// .application, .exe, .app
@ -30,40 +29,55 @@ const getAbstract = (longtext) => {
const abstract = firstLine.substring(0, 20);
return abstract;
};
const parseHTMLText = (html) => {
const parser = new DOMParser()
const dom = parser.parseFromString(html, 'text/html')
// Replace <br> and <p> with line breaks
Array.from(dom.body.querySelectorAll('br, p')).forEach(el => {
el.textContent = '\n' + el.textContent;
});
// Replace <hr> with a line of dashes
Array.from(dom.body.querySelectorAll('hr')).forEach(el => {
el.textContent = '\n------------------------------------------------------------------\n';
});
return dom.body.textContent || '';
}
const generateQuoteContent = (mailData, isRichText = true) => {
const html = `<br><br><hr><p class="font-sans"><b><strong >From: </strong></b><span >${((mailData.info?.MAI_From || '').replace(/</g,'&lt;').replace(/>/g,'&gt;'))} </span></p><p class="font-sans"><b><strong >Sent: </strong></b><span >${mailData.info?.MAI_SendDate || ''}</span></p><p class="font-sans"><b><strong >To: </strong></b><span >${(mailData.info?.MAI_To || '').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</span></p><p class="font-sans"><b><strong >Subject: </strong></b><span >${mailData.info?.MAI_Subject || ''}</span></p><p>${mailData.info?.MAI_ContentType === 'text/html' ? mailData.content : mailData.content.replace(/\r\n/g, '<br>')}</p>`
return isRichText ? html : parseHTMLText(html)
};
const generateMailContent = (mailData) => `<br><br><p>${mailData.content}</p>`
const generateQuoteContent = (mailData) => `<br><br>
<hr>
<p>
<b>
<strong >From: </strong>
</b>
<span >${((mailData.info?.MAI_From || '').replace(/</g,'&lt;').replace(/>/g,'&gt;'))} </span>
</p>
<p>
<b>
<strong >Sent: </strong>
</b>
<span >${mailData.info?.MAI_SendDate || ''}</span>
</p>
<p>
<b>
<strong >To: </strong>
</b>
<span >${(mailData.info?.MAI_To || '').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</span>
</p>
<p>
<b>
<strong >Subject: </strong>
</b>
<span >${mailData.info?.MAI_Subject || ''}</span>
</p>
<p>
${mailData.content}
</p>
`;
const generateMailContent = (mailData) => `
<p>
${mailData.content}
</p>`
/**
* @property {string} fromEmail - 发件人邮箱
* @property {string} fromUser - 发件人用户
* @property {string} fromOrder - 发件订单
* @property {string} fromOrder - 发件订单
* @property {string} toEmail - 收件人邮箱
* @property {string} conversationid - 会话ID
* @property {string} quoteid - 引用邮件ID
* @property {object} draft - 草稿
*/
const EmailEditorPopup = () => {
const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, toEmail, conversationid, quoteid, initial = {}, mailData: _mailData, action = 'reply', ...props }) => {
const { notification, message } = App.useApp();
const [form] = Form.useForm();
const [mobile] = useStyleStore((state) => [state.mobile]);
const [userId, username, emailList] = useAuthStore((state) => [state.loginUser.userId, state.loginUser.username, state.loginUser.emailList]);
const emailListOption = emailList?.map(ele => ({ ...ele, label: ele.email, key: ele.email, value: ele.email })) || [];
@ -72,39 +86,12 @@ const EmailEditorPopup = () => {
const emailListMatMapped = emailListOption?.reduce((r, v) => ({ ...r, [v.mat_sn]: v }), {});
// console.log('emailListMapped', emailListOption, emailListAddrMapped);
const emailEdiorProps = useConversationStore((state) => state.emailEdiorProps);
const [open, setOpen, closeEditor1, currentEditKey, setCurrentEditKey] = useConversationStore((state) => [state.editorOpen, state.setEditorOpen, state.closeEditor1, state.currentEditKey, state.setCurrentEditKey]);
const propsKeysArr = Array.from(emailEdiorProps.keys());
const propsArr = Array.from(emailEdiorProps.values());
const [activeEdit, setActiveEdit] = useState(emailEdiorProps.get(currentEditKey) || {});
// const { fromEmail, fromUser, fromOrder, oid, toEmail, conversationid, quoteid, initial = {}, mailData: _mailData, action = 'reply', draft = {}, receiverName, ...props } = emailEdiorProps.get(currentEditKey) || {};
const onChangeActiveEditor = (key) => {
setCurrentEditKey(key);
const _find = emailEdiorProps.get(key) || {};
setActiveEdit(_find);
};
const onEditTab = (targetKey, action) => {
if (action === 'add') {
//
} else {
if (propsKeysArr.length === 1) {
setOpen(false);
}
closeEditor1(targetKey);
}
};
const mai_sn = activeEdit.quoteid;
const { loading: getLoading, mailData } = useEmailDetail(mai_sn, activeEdit.mailData)
const mai_sn = quoteid;
const { loading: getLoading, mailData } = useEmailDetail(mai_sn, _mailData)
const [stickToProps, setStickToProps] = useState({});
const [propsSerialize, setPropsSerialize] = useState('');
const [newFromEmail, setNewFromEmail] = useState('');
const [newToEmail, setNewToEmail] = useState('');
@ -112,67 +99,56 @@ const EmailEditorPopup = () => {
const [emailOrder, setEmailOrder] = useState('');
const [emailMat, setEmailMat] = useState('');
const stateReset = () => {
setStickToProps({})
setStickToCid('')
setEmailOrder('')
setEmailOPI('')
setNewFromEmail('')
setNewToEmail('')
}
const [contentPrefix, setContentPrefix] = useState('');
// : ID,
// , 使focus, ID
// , , ID,
const [stickToCid, setStickToCid] = useState(activeEdit.conversationid);
const [stickToCid, setStickToCid] = useState(conversationid);
useEffect(() => {
const propsObj = { ...activeEdit, mai: activeEdit.mailData?.info?.MAI_MAT_SN, }
setContentPrefix(activeEdit.oid ? `<p>Dear Mr./Ms. ${activeEdit.receiverName || ''}</p><p>Reference Number: ${activeEdit.oid}</p>` : '');
// console.log('emailEditorPopup effect', open, '\nto', toEmail)
setStickToProps({ fromEmail, fromUser, fromOrder, toEmail, conversationid, quoteid, action });
//
if (isEmpty(activeEdit.quoteid)) {
setStickToProps(propsObj)
setPropsSerialize(JSON.stringify(propsObj))
setStickToCid(conversationid)
setEmailOrder(fromOrder)
setEmailOPI(fromUser)
setNewFromEmail(fromEmail)
setNewToEmail(toEmail)
setStickToCid(activeEdit.conversationid)
setEmailOrder(activeEdit.fromOrder)
setEmailOPI(activeEdit.fromUser)
setNewFromEmail(activeEdit.fromEmail)
setNewToEmail(activeEdit.toEmail)
const _findMat = emailListAddrMapped?.[fromEmail]?.mat_sn
setEmailMat(_findMat)
const _findMat = emailListAddrMapped?.[activeEdit.fromEmail]?.mat_sn
setEmailMat(_findMat)
if (open !== true) {
form.resetFields()
}
// if (open !== true) {
// form.resetFields()
// }
return () => {
setStickToProps({})
setStickToCid('')
setEmailOrder('')
setEmailOPI('')
setNewFromEmail('')
setNewToEmail('')
}
// /, 使
if (mailData.info?.MAI_MAT_SN) {
const reEmailO = mailData.info?.MAI_COLI_SN
const reEmailUser = mailData.info?.MAI_OPI_SN
const reEmailUserMat = mailData.info?.MAI_MAT_SN
setEmailOrder((prev) => reEmailO || prev || activeEdit.fromOrder)
setEmailOPI((prev) => reEmailUser || prev)
setEmailMat((prev) => reEmailUserMat || prev)
const _findMatOld = emailListMatMapped?.[reEmailUserMat]
if (_findMatOld) {
setNewFromEmail(_findMatOld.email)
setEmailOPI(_findMatOld.opi_sn)
setEmailMat(_findMatOld.mat_sn)
}
}, [open])
// /, 使
useEffect(() => {
const reEmailO = mailData.info?.MAI_COLI_SN
const reEmailUser = mailData.info?.MAI_OPI_SN
const reEmailUserMat = mailData.info?.MAI_MAT_SN
setEmailOrder(prev => reEmailO || prev);
setEmailOPI(prev => reEmailUser || prev);
setEmailMat(prev => reEmailUserMat || prev)
const _findMatOld = emailListMatMapped?.[reEmailUserMat]
if (_findMatOld) {
setNewFromEmail(_findMatOld.email)
setEmailOPI(_findMatOld.opi_sn)
setEmailMat(_findMatOld.mat_sn)
}
setShowQuoteContent(false);
setMergeQuote(true);
setQuoteContent('')
return () => {}
}, [open, mailData])
}, [mailData])
const handleSwitchEmail = (labelValue) => {
const { value } = labelValue
@ -182,8 +158,6 @@ const EmailEditorPopup = () => {
setEmailOPI(_findMat?.opi_sn)
};
const { signature } = useEmailSignature(emailOPI)
const [isRichText, setIsRichText] = useState(mobile === false);
// const [isRichText, setIsRichText] = useState(false); //
const [htmlContent, setHtmlContent] = useState('');
@ -205,69 +179,60 @@ const EmailEditorPopup = () => {
setTextContent(textContent);
form.setFieldValue('content', htmlContent);
form.setFieldValue('abstract', getAbstract(textContent));
debouncedSave({htmlContent});
};
const [initialForm, setInitialForm] = useState({});
const [initialContent, setInitialContent] = useState('');
const [showQuoteContent, setShowQuoteContent] = useState(false);
const [mergeQuote, setMergeQuote] = useState(true);
const [quoteContent, setQuoteContent] = useState('');
useEffect(() => {
// console.log('quoteid', quoteid, isEmpty(quoteid), isEmpty(mailData.info));
if (isEmpty(activeEdit.quoteid) && activeEdit.action !== 'new') {
return () => {}
if (isEmpty(quoteid) && action !== 'new') {
return () => {};
}
const { info } = mailData
const { info, } = mailData
// setShowCc(!isEmpty(mailData.info?.MAI_CS));
setShowCc(true);
const signatureBody = generateMailContent({ content: signature })
// const preQuoteBody = generateQuoteContent(mailData)
// const _initialContent = isEmpty(mailData.info) ? signatureBody : signatureBody+preQuoteBody
const preQuoteBody = generateQuoteContent(mailData);
if (!isEmpty(mailData.info) && activeEdit.action !== 'edit') {
setInitialContent(contentPrefix + signatureBody)
if ( !isEmpty(mailData.info) && action !== 'edit') {
setInitialContent(preQuoteBody);
}
const _formValues = {
to: info?.replyToAll || newFromEmail,
to: info?.MAI_From || newFromEmail,
cc: info?.MAI_CS || '',
// bcc: quote.bcc || '',
subject: `Re: ${info.MAI_Subject || ''}`,
}
const forwardValues = { from: newFromEmail, subject: `Fw: ${info.MAI_Subject || ''}` }
if (activeEdit.action === 'reply') {
form.setFieldsValue(_formValues)
setInitialForm(_formValues)
} else if (activeEdit.action === 'forward') {
setStickToCid('0')
form.setFieldsValue(forwardValues)
setInitialForm(forwardValues)
} else if (activeEdit.action === 'edit') {
};
const forwardValues = { subject: `Fw: ${info.MAI_Subject || ''}` };
if (action === 'reply') {
form.setFieldsValue(_formValues);
setInitialForm(_formValues);
} else if (action === 'forward') {
form.setFieldsValue(forwardValues);
setInitialForm(forwardValues);
} else if (action === 'edit') {
const thisFormValues = {
to: info?.MAI_To || '',
cc: info?.MAI_CS || '',
subject: info?.MAI_Subject || '',
}
form.setFieldsValue(thisFormValues)
setInitialForm(thisFormValues)
const thisBody = generateMailContent(mailData)
};
form.setFieldsValue(thisFormValues);
setInitialForm(thisFormValues);
const thisBody = generateMailContent(mailData);
// console.log('thisBody', thisBody);
setInitialContent(thisBody)
} else if (activeEdit.action === 'new') {
const newEmail = { to: newToEmail, subject: activeEdit.draft?.subject || '' }
form.setFieldsValue(newEmail)
setInitialForm(newEmail)
setInitialContent((activeEdit.draft?.content || contentPrefix || '') + signatureBody)
} else if (action === 'new') {
const newEmail = { to: newToEmail, }
form.setFieldsValue(newEmail);
setInitialForm(newEmail);
}
return () => {}
}, [propsSerialize, mailData.info, signature, newToEmail, newFromEmail]);
return () => {};
}, [stickToProps, mailData.info, newToEmail, newFromEmail]);
const [openPlainTextConfirm, setOpenPlainTextConfirm] = useState(false);
const handlePlainTextOpenChange = ({ target }) => {
@ -330,67 +295,17 @@ const EmailEditorPopup = () => {
newFileList.splice(index, 1);
setFileList(newFileList);
},
onPreview: (file) => {
// console.log('pn preview', file);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = (e) => {
if (file.size > 1.5 * 1024 * 1024) {
message.info('附件太大,无法预览')
// message.info(', ')
// var downloadLink = document.createElement('a');
// downloadLink.href = e.target.result;
// downloadLink.download = file.name;
// downloadLink.click();
resolve(e.target.result);
return;
}
var win = window.open("", "_blank");
win.document.body.style.margin = '0';
if (file.type.startsWith('image/')) {
win.document.write("<img src='" + e.target.result + "' style=\"max-width: 100%;\" />");
} else if (file.type.startsWith('text/') || file.type === 'application/html' || file.type === 'application/xhtml+xml') {
var iframe = win.document.createElement('iframe');
iframe.srcdoc = e.target.result;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
win.document.body.appendChild(iframe);
win.document.body.style.margin = '0';
} else if (file.type === 'application/pdf') {
// win.document.write("<iframe src='" + e.target.result + "' width='100%' height='100%' frameborder=\"0\"></iframe>");
win.document.write("<embed src='" + e.target.result + "' width='100%' height='100%' style=\"border:none\"></embed>");
win.document.body.style.margin = '0';
} else if (file.type.startsWith('audio/')) {
win.document.write("<audio controls src='" + e.target.result + "' style=\"max-width: 100%;\"></audio>");
} else if (file.type.startsWith('video/')) {
win.document.write("<video controls src='" + e.target.result + "' style=\"max-width: 100%;\"></video>");
} else {
win.document.write("<h2>Preview not available for this file type</h2>");
}
// win.document.write("<iframe src='" + dataURL + "' width='100%' height='100%' style=\"border:none\"></iframe>");
resolve(reader.result)
};
if (file.type.startsWith('text/') || file.type === 'application/html' || file.type === 'application/xhtml+xml') {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
// reader.readAsDataURL(file);
reader.onerror = (error) => reject(error);
})
},
};
/**
* 保存成功, 推一个气泡
* 再从异步通知更新消息发送状态
* 先推到消息记录上面, 再发送
*/
const sentOrReceivedNewMessage = useConversationStore((state) => state.sentOrReceivedNewMessage);
const invokeEmailMessage = (msgObj) => {
const msgObjMerge = {
sender: 'me',
senderName: 'me',
msg_source: 'email',
// to: currentConversation.whatsapp_phone_number,
date: new Date(),
status: 'waiting', // accepted
@ -398,7 +313,6 @@ const EmailEditorPopup = () => {
// id: `${currentConversation.sn}.${msgObj.id}`,
// id: `${stickToCid}.${msgObj.id}`,
conversationid: stickToCid,
msg_source: 'email',
};
// olog('invoke upload', msgObjMerge)
const contentToRender = sentMsgTypeMapped[msgObjMerge.type].contentToRender(msgObjMerge);
@ -412,6 +326,7 @@ const EmailEditorPopup = () => {
// console.log('onSend callback', '\nisRichText', isRichText);
// console.log(form.getFieldsValue());
const body = structuredClone(form.getFieldsValue());
body.mailcontent = isRichText ? htmlContent : textContent;
body.from = newFromEmail;
body.attaList = fileList;
body.opi_sn = emailOPI;
@ -419,8 +334,6 @@ const EmailEditorPopup = () => {
body.coli_sn = emailOrder || '';
// console.log('body', body, '\n', emailOrder);
const values = await form.validateFields();
const preQuoteBody = activeEdit.quoteid ? (quoteContent ? quoteContent : generateQuoteContent(mailData, isRichText)) : '';
body.mailcontent = isRichText ? EmailBuilder({ subject: values.subject, content: htmlContent+preQuoteBody }) : textContent+preQuoteBody
body.cc = values.cc || '';
body.bcc = values.bcc || '';
const msgObj = {
@ -436,12 +349,10 @@ const EmailEditorPopup = () => {
subject: values.subject,
content: body.mailcontent,
},
coli_id: stickToProps.oid || (emailOrder ? `{${emailOrder}}` : ''),
}
setSendLoading(true);
body.externalID = stickToCid;
body.actionID = `${stickToCid}.${msgObj.id}`;
body.contenttype = isRichText ? 'text/html' : 'text/plain';
try {
const bubbleMsg = cloneDeep(msgObj);
bubbleMsg.id = `${stickToCid}.${msgObj.id}`;
@ -449,13 +360,9 @@ const EmailEditorPopup = () => {
bubbleMsg.content = undefined;
// invokeEmailMessage(bubbleMsg);
// console.log('postSendEmail', body, '\n', msgObj);
// return;
const result = await postSendEmail(body);
const mailSavedId = result.id || '';
bubbleMsg.email.mai_sn = mailSavedId;
// console.log('invokeEmailMessage', bubbleMsg);
invokeEmailMessage(bubbleMsg);
// setSendLoading(false);
@ -478,32 +385,6 @@ const EmailEditorPopup = () => {
const [openDrawerSnippet] = useSnippetStore((state) => [state.openDrawer])
const [openPaymentDrawer] = useOrderStore((state) => [state.openDrawer])
const [bakData, setBakData] = useState({});
const idleCallbackId = useRef(null);
const debouncedSave = useCallback(
debounce((data) => {
idleCallbackId.current = window.requestIdleCallback(() => {
console.log('Saving data (idle, debounced):', data);
if (currentEditKey) writeIndexDB([{ ...data, key: currentEditKey }], 'draft', 'EmailEditor')
});
}, 1500), // 1.5s
[]
);
const onEditChange = (changedValues, allValues) => {
console.log('onEditChange', changedValues,'\n', allValues)
// const { name, value } = e.target;
// setBakData(prevData => ({ ...prevData, [name]: value }));
// debouncedSave(bakData);
};
useEffect(() => {
return () => {
if (idleCallbackId.current && window.cancelIdleCallback) {
window.cancelIdleCallback(idleCallbackId.current);
}
};
}, [debouncedSave]);
return (
<>
<ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}>
@ -511,19 +392,17 @@ const EmailEditorPopup = () => {
rootClassName='email-editor-wrapper !border-indigo-300 '
open={open}
setOpen={setOpen}
// 300 + 24
// window.innerWidth - 600
initial={{ width: 880, height: window.innerHeight - 40, left: 20, top: 20 }}
// initial={{ top: isEmpty(quoteid) ? 20 : 74 }}
initial={{ width: window.innerWidth-40, height: window.innerHeight-40, left: 20, top: 20 }}
maximizeButton={<></>}
initialStage={'FULLSCREEN'}
onCancel={() => {
form.resetFields()
stateReset()
form.resetFields();
}}
titleClassName={`!pl-0 !pt-0 !pb-0`}
title={
<>
{/* {getLoading ? <LoadingOutlined className='mr-1' /> : null} */}
{/* {initialForm.subject || `${!isEmpty(quoteid) ? '回复: ' : '写邮件: '} ${newFromEmail || ''}`} */}
<Tabs editable type={'editable-card'} activeKey={currentEditKey} onChange={onChangeActiveEditor} className='[&_.ant-tabs-nav]:mb-0' items={propsArr.map(ele=>({...ele, label: (!isEmpty(activeEdit.quoteid) ? '回复: ' : '新邮件: ')+ele.subject}))} onEdit={onEditTab} hideAdd />
{getLoading ? <LoadingOutlined className='mr-1' /> : null}
{initialForm.subject || `${!isEmpty(quoteid) ? '回复: ' : '写邮件: '} ${newFromEmail || ''}`}
</>
}
footer={
@ -531,22 +410,23 @@ const EmailEditorPopup = () => {
<Button type='primary' onClick={onHandleSend} loading={sendLoading}>
发送
</Button>
<Popconfirm
description='切换内容为纯文本格式将丢失信件和签名的格式, 确定使用纯文本?'
onConfirm={confirmPlainText}
open={openPlainTextConfirm}
onCancel={() => setOpenPlainTextConfirm(false)}>
<Popconfirm description='切换内容为纯文本格式将丢失信件和签名的格式, 确定使用纯文本?' onConfirm={confirmPlainText} open={openPlainTextConfirm} onCancel={() => setOpenPlainTextConfirm(false)}>
<Checkbox checked={!isRichText} onChange={handlePlainTextOpenChange}>
纯文本
</Checkbox>
</Popconfirm>
<Select labelInValue options={emailListOption} value={{ key: newFromEmail, value: newFromEmail, label: newFromEmail }} onChange={handleSwitchEmail} variant={'borderless'} />
<Select labelInValue
options={emailListOption}
value={newFromEmail}
onChange={handleSwitchEmail}
variant={'borderless'}
/>
</div>
}>
<Form
form={form} onValuesChange={onEditChange}
form={form}
preserve={false}
name={`email_max_form-${Date.now().toString(32)}`}
name='conversation_filter_form'
size='small'
layout={'inline'}
variant={'borderless'}
@ -591,26 +471,30 @@ const EmailEditorPopup = () => {
}
/> */}
</Form.Item>
<Form.Item label='抄&nbsp;&nbsp;&nbsp;&nbsp;送' name={'cc'} hidden={!showCc} className='w-full pt-1' >
<Form.Item label='抄送' name={'cc'} hidden={!showCc} className='w-full pt-1'>
<Input />
</Form.Item>
<Form.Item label='密&nbsp;&nbsp;&nbsp;&nbsp;送' name={'bcc'} hidden={!showBcc} className='w-full pt-1'>
<Form.Item label='密送' name={'bcc'} hidden={!showBcc} className='w-full pt-1'>
<Input />
</Form.Item>
<Form.Item label='主&nbsp;&nbsp;&nbsp;&nbsp;题' name={'subject'} rules={[{ required: true }]} className='w-full pt-1'>
<Form.Item label='主题' name={'subject'} rules={[{ required: true }]} className='w-full pt-1'>
<Input />
</Form.Item>
<Form.Item name='atta' label='' className='w-full py-1 border-b-0' valuePropName='fileList' getValueFromEvent={normFile}>
<Form.Item
name='atta'
label=''
className='w-full py-1 border-b-0'
valuePropName='fileList'
getValueFromEvent={normFile}
>
<Flex justify='space-between'>
<Upload {...uploadProps} name='file' className='w-full'>
<Upload {...uploadProps} name='file' className='w-full' >
<Button icon={<UploadOutlined />}>附件</Button>
</Upload>
<Flex align={'center'} className='absolute right-0'>
<Divider type='vertical' />
<Button type={'link'} onClick={() => openDrawerSnippet()}>
图文集
</Button>
<PaymentlinkBtn type={'link'} />
<Button type={'link'} onClick={() => openDrawerSnippet()}>图文集</Button>
<Button type={'link'} onClick={() => openPaymentDrawer()}>支付链接</Button>
{/* 更多工具 */}
{/* <Popover
content={
@ -630,23 +514,12 @@ const EmailEditorPopup = () => {
<Input />
</Form.Item>
</Form>
<LexicalEditor {...{ isRichText }} onChange={handleEditorChange} defaultValue={initialContent} />
{activeEdit.quoteid && !showQuoteContent && (
<div className='flex justify-start items-center ml-2'>
<Button className='flex gap-2 ' type='link' onClick={() => setShowQuoteContent(!showQuoteContent)}>
显示引用内容
</Button>
{/* <Button className='flex gap-2 ' type='link' danger onClick={() => {setMergeQuote(false);setShowQuoteContent(false)}}>
删除引用内容
</Button> */}
</div>
)}
{showQuoteContent && (
<blockquote contentEditable className='border-0 outline-none' onBlur={(e) => setQuoteContent(`<blockquote>${e.target.innerHTML}</blockquote>`)} dangerouslySetInnerHTML={{ __html: generateQuoteContent(mailData) }}></blockquote>
)}
{/* <LexicalEditor {...{ isRichText }} onChange={handleEditorChange} defaultValue={initialContent} /> */}
<EmailBuilderC />
</DnDModal>
</ConfigProvider>
</>
)
);
};
export default EmailEditorPopup;

@ -0,0 +1,98 @@
import { useState, useEffect } from 'react'
import { Button, ConfigProvider, Dropdown, Flex, Select } from 'antd'
import { DownOutlined } from '@ant-design/icons'
import EmailEditorPopup from './EmailEditorPopup'
import useStyleStore from '@/stores/StyleStore'
import useAuthStore from '@/stores/AuthStore'
// import { isEmpty, } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore'
import { useOrderStore } from '@/stores/OrderStore'
import { EditIcon } from '@/components/Icons'
const EmailNewBtn = ({ ...props }) => {
const [mobile] = useStyleStore((state) => [state.mobile])
const { userId, username, emailList, email } = useAuthStore((state) => state.loginUser)
// const websocketOpened = useConversationStore((state) => state.websocketOpened);
const currentConversation = useConversationStore((state) => state.currentConversation)
// const talkabled = !isEmpty(currentConversation.sn) && websocketOpened;
const { orderDetail, customerDetail } = useOrderStore()
const emailListOption = emailList?.map((ele) => ({ ...ele, label: ele.email, key: ele.email, value: ele.email })) || []
const [pickEmail, setPickEmail] = useState({ key: email, email })
const [fromUser, setFromUser] = useState()
useEffect(() => {
const order_opi = Number(orderDetail?.opi_sn || userId)
setFromUser(order_opi)
const find =
emailListOption?.find((ele) => ele.opi_sn === order_opi && ele.default === true) ||
emailListOption?.find((ele) => ele.opi_sn === order_opi && ele.backup === true) ||
emailListOption?.find((ele) => ele.opi_sn === order_opi) ||
emailListOption?.find((ele) => ele.default === true) ||
emailListOption?.find((ele) => ele.backup === true) ||
emailListOption[0]
setPickEmail(find)
return () => {}
}, [orderDetail])
const [open, setOpen] = useState(false)
const [fromEmail, setFromEmail] = useState('')
const [toEmail, setToEmail] = useState('')
const openEditor = (email_addr) => {
setOpen(true)
setFromEmail(email_addr)
setToEmail(currentConversation?.channels?.email || customerDetail?.email || '')
}
return (
<ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}>
<Flex gap={8} className='p-2 bg-gray-200 rounded rounded-b-none border-gray-300 border-solid border border-b-0 border-x-0' align={'center'} justify={'flex-end'}>
{/* <span>新邮件:</span> */}
{/* <Dropdown.Button
// disabled={!talkabled}
menu={{
selectable: true,
items: emailListOption,
onClick: ({ key }) => {
const find = emailListOption?.find((ele) => ele.email === key)
setPickEmail(find)
// openEditor(key);
},
selectedKeys: [pickEmail?.email],
}}
// onClick={() => openEditor(pickEmail.key)}
type='primary'
className='w-auto'
icon={<DownOutlined />}>
<>{pickEmail?.email}</>
</Dropdown.Button> */}
{/* <span>:</span>
<Select
options={emailListOption}
labelInValue
value={pickEmail}
onChange={(val) => { setPickEmail(val); setFromUser(val.opi_sn)}}
// variant={'borderless'}
/> */}
<Button icon={<EditIcon />} type='primary' onClick={() => openEditor(pickEmail.key)}>
新邮件
</Button>
<EmailEditorPopup
{...{ open, setOpen }}
fromEmail={fromEmail}
fromUser={fromUser}
fromOrder={currentConversation.coli_sn}
conversationid={currentConversation.sn}
toEmail={toEmail}
action='new'
key={'email-new-editor-popup'}
/>
</Flex>
</ConfigProvider>
)
}
export default EmailNewBtn

@ -15,10 +15,7 @@ const InputTemplate = ({ disabled = false, inputEmoji }) => {
<>
<Popover
overlayClassName='p-0'
placement='top'
arrow={false}
align={{offset: [-6, mobile ? -92 : -100] }}
// placement={mobile === false ? 'left' : 'top'}
placement={mobile === false ? 'right' : 'top'}
overlayInnerStyle={{ padding: 0, borderRadius: '8px' }}
forceRender={true}
content={<EmojiPicker skinTonesDisabled={true} emojiStyle='native' onEmojiClick={handlePickEmoji} className='chatwindow-wrapper' />}

@ -1,34 +1,39 @@
import React, { useState, useRef, useEffect } from 'react';
import { App, Input, Flex, Button, Image, Alert } from 'antd';
import { App, Input, Flex, Button, Image, Tooltip } from 'antd';
import PropTypes from 'prop-types';
// import { Input } from 'react-chat-elements';
import useAuthStore from '@/stores/AuthStore';
import useConversationStore from '@/stores/ConversationStore';
import { SendOutlined, CloseCircleOutlined, LoadingOutlined, FileOutlined } from '@ant-design/icons'
import {
SendOutlined,
CloseCircleOutlined,
LoadingOutlined, FileOutlined,
DollarOutlined
} from '@ant-design/icons';
import { isEmpty, } from '@/utils/commons';
import { v4 as uuid } from 'uuid';
import { sentMsgTypeMapped, whatsappSupportFileTypes, uploadProgressSimulate, WABAccountsMapped } from '@/channel/bubbleMsgUtils';
import { OSS_URL as aliOSSHost, DEFAULT_WABA } from '@/config';
import { sentMsgTypeMapped, whatsappSupportFileTypes, uploadProgressSimulate } from '@/channel/bubbleMsgUtils';
import InputTemplate from './Template';
import InputEmoji from './Emoji';
import InputMediaUpload from './MediaUpload';
import { OSS_URL as aliOSSHost } from '@/config';
import { postUploadFileItem } from '@/actions/CommonActions';
import dayjs from 'dayjs';
import useStyleStore from '@/stores/StyleStore';
import ComposerTools from './ComposerTools';
import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions } from "@/stores/OrderStore";
import { postSendMsg } from '@/actions/WaiAction';
import { useOrderStore } from '@/stores/OrderStore'
const ButtonStyleClsMapped =
{
'waba': 'bg-waba shadow shadow-waba-300 hover:!bg-waba-400 active:bg-waba-400 focus:bg-waba-400',
'whatsapp': 'bg-whatsapp shadow shadow-whatsapp-300 hover:!bg-whatsapp-400 active:bg-whatsapp-400 focus:bg-whatsapp-400',
'wai': 'bg-whatsapp shadow shadow-whatsapp-300 hover:!bg-whatsapp-400 active:bg-whatsapp-400 focus:bg-whatsapp-400',
'wa': 'bg-whatsapp shadow shadow-whatsapp-300 hover:!bg-whatsapp-400 active:bg-whatsapp-400 focus:bg-whatsapp-400',
};
const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
const { message: appMessage, notification: appNotification } = App.useApp();
const InputComposer = ({ channel }) => {
const [mobile] = useStyleStore((state) => [state.mobile]);
const {userId, whatsAppBusiness, whatsAppNo} = useAuthStore((state) => state.loginUser);
const [customerDetail] = useOrderStore((s) => [s.customerDetail])
const {userId, whatsAppBusiness} = useAuthStore((state) => state.loginUser);
const [openPaymentDrawer] = useOrderStore((state) => [state.openDrawer])
const websocket = useConversationStore((state) => state.websocket);
const websocketOpened = useConversationStore((state) => state.websocketOpened);
@ -36,13 +41,11 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
const [referenceMsg, setReferenceMsg] = useConversationStore((state) => [state.referenceMsg, state.setReferenceMsg]);
const [complexMsg, setComplexMsg] = useConversationStore((state) => [state.complexMsg, state.setComplexMsg]);
const sentOrReceivedNewMessage = useConversationStore((state) => state.sentOrReceivedNewMessage);
const updateMessageItem = useConversationStore((state) => state.updateMessageItem);
const talkabled = !isEmpty(currentConversation.sn) && websocketOpened;
const isExpired = !isEmpty(currentConversation.conversation_expiretime) ? dayjs(currentConversation.conversation_expiretime).add(8, 'hours').isBefore(dayjs()) : true;
const gt24h = !isEmpty(currentConversation.last_received_time) ? dayjs().diff(dayjs(currentConversation.last_received_time), 'hour') > 24 : true;
// const lt24h = !isEmpty(currentConversation.last_received_time) ? dayjs().diff(dayjs(currentConversation.last_received_time), 'hour') <= 24 : false;
const lt24h = !isEmpty(lastWABAMsg.date) ? dayjs().diff(dayjs(lastWABAMsg.date), 'hour') <= 24 : false;
const lt24h = !isEmpty(currentConversation.last_received_time) ? dayjs().diff(dayjs(currentConversation.last_received_time), 'hour') <= 24 : false;
// lt24h || !isExpired
const textabled = talkabled; // && (lt24h || !isExpired); // ,
const textabled0 = talkabled && (lt24h || !isExpired); // ,
@ -61,75 +64,43 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
? 'Enter 发送, Shift+Enter 换行'
: 'Enter 换行';
const [toIM, setToIM] = useState('');
const [fromIM, setFromIM] = useState(DEFAULT_WABA);
const [fromIM, setFromIM] = useState('');
useEffect(() => {
switch (channel) {
case 'waba':
setFromIM(whatsAppBusiness || DEFAULT_WABA)
setFromIM(whatsAppBusiness)
break
case 'wa':
case 'wai':
case 'whatsapp':
setFromIM(whatsAppNo)
setFromIM('') // todo: WhatsApp
break
default:
setFromIM(DEFAULT_WABA)
break
}
return () => {}
}, [channel, whatsAppBusiness, whatsAppNo])
}, [channel, whatsAppBusiness])
useEffect(() => {
const _to = currentConversation.whatsapp_phone_number || currentConversation.channel?.whatsapp_phone_number || currentConversation.channel?.phone_number // || customerDetail.whatsapp_phone_number
setToIM(_to);
return () => {}
}, [currentConversation, customerDetail])
const textInputRef = useRef(null);
const [textContent, setTextContent] = useState('');
const [sendBtnLoading, setSendBtnLoading] = useState(false);
const invokeSendMessage = async (msgObj) => {
if (isEmpty(toIM)) {
appNotification.warning({ message: '缺少WhatsApp号码', description: '请先在会话列表右键菜单编辑联系人, 补充WhatsApp号码', placement: 'bottom' });
return false
}
const invokeSendMessage = (msgObj) => {
const msgObjMerge = {
sender: 'me',
senderName: 'me',
to: toIM,
to: currentConversation.whatsapp_phone_number,
from: fromIM,
date: new Date(),
status: 'waiting',
...(referenceMsg.id ? { context: { message_id: referenceMsg.id }, message_origin: referenceMsg, } : {}),
...(referenceMsg.id ? { context: { message_id: referenceMsg.id }, message_origin: referenceMsg } : {}),
...msgObj,
id: `${currentConversation.sn}.${uuid()}`,
msg_source: channel,
wabaName: channel === 'waba' ? WABAccountsMapped[fromIM]?.verifiedName : '',
externalId: currentConversation.sn || ''
};
// console.log('sendMessage------------------', msgObjMerge)
// olog('sendMessage------------------', msgObjMerge)
const contentToSend = sentMsgTypeMapped[msgObjMerge.type].contentToSend(msgObjMerge);
// console.log('content to send-------------------------------------', contentToSend);
if (channel === 'wai') {
try {
setSendBtnLoading(true);
await postSendMsg({...contentToSend, });
setSendBtnLoading(false);
} catch (error) {
setSendBtnLoading(false);
appNotification.error({ message: '发送失败', description: error.message, placement: 'bottom', duration: 6, });
// appMessage.error(error.message || '');
return false;
}
} else if (channel === 'waba') {
websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn || '', conversationid: currentConversation.sn, });
}
websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn, conversationid: currentConversation.sn, });
const contentToRender = sentMsgTypeMapped[msgObjMerge.type].contentToRender(msgObjMerge);
// console.log(contentToRender, 'contentToRender sendMessage------------------');
sentOrReceivedNewMessage(contentToRender.conversationid, contentToRender);
@ -146,14 +117,12 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
const msgObjMerge = {
sender: 'me',
senderName: 'me',
to: toIM,
to: currentConversation.whatsapp_phone_number,
date: new Date(),
status: 'waiting',
...(referenceMsg.id ? { context: { message_id: referenceMsg.id }, message_origin: referenceMsg } : {}),
...msgObj,
id: `${currentConversation.sn}.${msgObj.id}`,
msg_source: channel,
wabaName: channel === 'waba' ? WABAccountsMapped[fromIM]?.verifiedName : '',
};
// olog('invoke upload', msgObjMerge)
const contentToRender = sentMsgTypeMapped[msgObjMerge.type].contentToRender(msgObjMerge);
@ -161,44 +130,24 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
sentOrReceivedNewMessage(contentToRender.conversationid, contentToRender);
};
const invokeSendUploadMessage = async (msgObj) => {
if (isEmpty(toIM)) {
appNotification.warning({ message: '缺少WhatsApp号码', description: '请先在会话列表右键菜单编辑联系人, 补充WhatsApp号码', placement: 'bottom' });
return false
}
const invokeSendUploadMessage = (msgObj) => {
const msgObjMerge = {
sender: 'me',
senderName: 'me',
to: toIM,
to: currentConversation.whatsapp_phone_number,
from: fromIM,
date: new Date(),
status: 'waiting',
...(referenceMsg.id ? { context: { message_id: referenceMsg.id }, message_origin: referenceMsg } : {}),
...msgObj,
id: `${currentConversation.sn}.${msgObj.id}`,
msg_source: channel,
wabaName: channel === 'waba' ? WABAccountsMapped[fromIM]?.verifiedName : '',
externalId: currentConversation.sn || ''
};
const contentToSend = sentMsgTypeMapped[msgObjMerge.type].contentToSend(msgObjMerge);
// olog('invoke upload send +++ ', contentToSend)
if (channel === 'wai') {
try {
setSendBtnLoading(true);
await postSendMsg({...contentToSend, });
setSendBtnLoading(false);
} catch (error) {
setSendBtnLoading(false);
updateMessageItem({ conversationid: currentConversation.sn || '', id: msgObjMerge.id, actionId: contentToSend.actionId, status: 'failed', replyButton: false, dateString: '发送失败 ❌' })
appNotification.error({ message: '发送失败', description: error.message, placement: 'bottom', duration: 6, });
// appMessage.error(error.message || '');
return false;
}
} else if (channel === 'waba') {
websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn || '', conversationid: currentConversation.sn, });
}
websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn, conversationid: currentConversation.sn, });
};
const { message: appMessage } = App.useApp();
const [pastedUploading, setPastedUploading] = useState(false);
const readPasted = async (file) => {
const fileTypeSupport = Object.keys(whatsappSupportFileTypes).find((msgType) => whatsappSupportFileTypes[msgType].types.includes(file.type));
@ -220,7 +169,7 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
type: fileTypeSupport,
name: file.name,
uploadStatus: 'loading',
data: { dataUri: '', link: '', width: '100%', height: 150, loading: 0 },
data: { dataUri: '', link: '', width: '100%', height: 150, loading: uploadProgressSimulate() },
id: uuid(),
};
//
@ -299,30 +248,19 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
}
};
const [wabaWarning, setWabaWarning] = useState('');
useEffect(() => {
if (currentActive) focusInput();
if (channel === 'waba' && !isEmpty(referenceMsg) && referenceMsg.waba.replace('+', '') !== fromIM.replace('+', '')) {
setWabaWarning('注意: 回复的消息与当前使用的WABA账户不一致. 请到个人资料页面切换WABA商业号身份.')
} else {
setWabaWarning('');
}
return () => {
setWabaWarning('');
};
}, [referenceMsg, complexMsg, currentActive]);
focusInput();
return () => {};
}, [referenceMsg, complexMsg]);
return (
<div>
{wabaWarning && <Alert message={wabaWarning} type="error" showIcon /> }
{isEmpty(toIM) && currentConversation.sn && <Alert message="当前客人没有设置WhatsApp号码, 请先在会话列表右键菜单编辑联系人设置" type="warning" showIcon /> }
{referenceMsg.id && (
<Flex justify='space-between' className='reply-to bg-gray-100 p-1 rounded-none text-slate-500'>
<div className='flex flex-col referrer-msg border-l-3 border-y-0 border-r-0 border-slate-300 border-solid pl-2 pr-1 py-1'>
<span className=' text-primary pr-1 italic align-top'>{referenceMsg.senderName}</span>
{referenceMsg.type === 'photo' && <Image width={100} src={referenceMsg.data.uri} />}
<span className='px-1 whitespace-pre-wrap line-clamp-3 overflow-hidden text-ellipsis break-words break-all'>{referenceMsg.originText}</span>
<span className='px-1 whitespace-pre-wrap'>{referenceMsg.originText}</span>
</div>
<Button type='text' title='取消引用' className=' rounded-none text-slate-500' icon={<CloseCircleOutlined />} size={'middle'} onClick={() => setReferenceMsg({})} />
</Flex>
@ -378,7 +316,21 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
autoSize={{ minRows: 2, maxRows: 6 }}
/>
<Flex justify={'space-between'} className=' bg-gray-200 p-1 rounded-b-0'>
<ComposerTools key={'wt'} channel={channel} inputEmoji={addEmoji} {...{ invokeUploadFileMessage, invokeSendUploadMessage, invokeSendMessage }} />
<Flex gap={4} className='*:text-primary *:rounded-none items-center'>
{channel==='waba' && <InputTemplate key='templates' disabled={!talkabled} invokeSendMessage={invokeSendMessage} />}
<InputEmoji key='emoji' disabled={!textabled} inputEmoji={addEmoji} />
<InputMediaUpload key={'addNewMedia'} disabled={!textabled} {...{ invokeUploadFileMessage, invokeSendUploadMessage }} />
<Tooltip title={<><div>支付链接</div></>} >
<Button type='text' onClick={() => openPaymentDrawer()} icon={<DollarOutlined className='text-orange-500' />} size={'middle'} />
</Tooltip>
{/* <Button type='text' className='' icon={<YoutubeOutlined />} size={'middle'} />
<Button type='text' className='' icon={<AudioOutlined />} size={'middle'} />
<Button type='text' className='' icon={<FolderAddOutlined />} size={'middle'} />
<Button type='text' className='' icon={<CloudUploadOutlined />} size={'middle'} />
<Button type='text' className='' icon={<FilePdfOutlined />} size={'middle'} /> */}
</Flex>
<Flex gap={4} align={'center'}>
<div className='text-neutral-400 text-sm'>
{/* <ExpireTimeClock expireTime={currentConversation.conversation_expiretime} /> */}
@ -389,7 +341,6 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
onClick={handleSendText}
type='primary'
size='middle'
loading={sendBtnLoading}
icon={<SendOutlined />}
disabled={!textabled || pastedUploading}
className={ButtonStyleClsMapped[channel]
@ -402,6 +353,6 @@ const InputComposer = ({ channel, currentActive, lastWABAMsg = {} }) => {
);
};
InputComposer.propTypes = { channel: PropTypes.oneOf(['waba', 'wai']) };
InputComposer.PropTypes = { channel: PropTypes.oneOf(['waba', 'wa']) };
export default InputComposer;

@ -68,7 +68,7 @@ const ImageUpload = ({ disabled, invokeUploadFileMessage, invokeSendUploadMessag
onChange={({file}) => {
if (file.status === 'done') {
const { file_url } = file.response.result;
invokeSendUploadMessage({...fileObj, data: { ...fileObj.data, link: file_url, dataUri: file_url, uri: file_url, loading: 0 }});
invokeSendUploadMessage({...fileObj, data: { ...fileObj.data, link: file_url, dataUri: file_url, uri: file_url, loading: 1 }});
}
if (file.status === 'error') {
message.error(`添加失败`);

@ -1,28 +0,0 @@
import { createContext, useEffect, useState } from 'react'
import { useOrderStore } from '@/stores/OrderStore'
import { Tooltip, Button } from 'antd'
import GeneratePaymentDrawer from '../Components/GeneratePaymentDrawer'
const PaymentlinkBtn = ({ type, ...props }) => {
const [openPaymentDrawer] = useOrderStore((state) => [state.openDrawer])
return (
<>
{/* <GeneratePaymentDrawer /> */}
<Tooltip title='支付链接'>
{/* <Button type='text' onClick={() => openPaymentDrawer()} icon={<DollarOutlined className='text-orange-500' />} size={'middle'} /> */}
{type === 'link' ? (
<Button type={'link'} onClick={() => openPaymentDrawer()}>
支付链接
</Button>
) : (
<Button type='text' onClick={() => openPaymentDrawer()} size={'middle'} className='px-1'>
💲
</Button>
)}
</Tooltip>
</>
)
}
export default PaymentlinkBtn

@ -1,17 +0,0 @@
import { createContext, useEffect, useState } from 'react'
import useSnippetStore from '@/stores/SnippetStore'
import { Tooltip, Button } from 'antd'
const SnippestBtn = ({ ...props }) => {
const [openSnippetDrawer] = useSnippetStore((state) => [state.openDrawer])
return (
<Tooltip title='图文集'>
<Button type='text' onClick={() => openSnippetDrawer()} size={'middle'} className='px-1'>
📝
</Button>
</Tooltip>
)
}
export default SnippestBtn

@ -1,10 +1,10 @@
import { useState, useRef, useEffect, memo, useMemo, useCallback } from 'react';
import { App, Popover, Flex, Button, List, Input, Tabs, Tag, Alert, Divider } from 'antd';
import { useState, useRef, useEffect } from 'react';
import { App, Popover, Flex, Button, List, Input } from 'antd';
import { MessageOutlined, SendOutlined } from '@ant-design/icons';
import useAuthStore from '@/stores/AuthStore';
import useConversationStore from '@/stores/ConversationStore';
import { cloneDeep, flush, getNestedValue, groupBy, objectMapper, removeFormattingChars, sortArrayByOrder, sortObjectsByKeysMap, TagColorStyle } from '@/utils/commons';
import { replaceTemplateString, whatsappTemplateBtnParamTypesMapped } from '@/channel/bubbleMsgUtils';
import { cloneDeep, getNestedValue, objectMapper, removeFormattingChars, sortArrayByOrder } from '@/utils/commons';
import { replaceTemplateString } from '@/channel/bubbleMsgUtils';
import { isEmpty } from '@/utils/commons';
import useStyleStore from '@/stores/StyleStore';
@ -22,201 +22,21 @@ const splitTemplate = (template) => {
}, []);
return obj;
};
// UTILITY
// MARKETING
const templateCaterogyText = { 'UTILITY': '跟进', 'MARKETING': '营销' }
const templateCaterogyTipText = { 'UTILITY': '触达率高', 'MARKETING': '' }
const CategoryList = ({ dataSource, handleSendTemplate, valueMapped, onInput, activeInput }) => {
const currentConversation = useConversationStore((state) => state.currentConversation);
const renderForm = ({ tempItem }, key = 'body') => {
const templateText = tempItem.components?.[key]?.[0]?.text || ''
const tempArr = splitTemplate(templateText)
const keys = (templateText.match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, ''))
const paramsVal = keys.reduce((r, k) => ({ ...r, [k]: getNestedValue(valueMapped, [k]) }), {})
if (key === 'header' && tempItem.components?.header?.[0]?.example?.header_url) {
const headerImg = { key: 'header_url', placeholder: '头图地址' };
tempArr.unshift(headerImg);
}
if (key === 'buttons' && (tempItem.components?.buttons?.[0]?.buttons || []).findIndex(btn => btn.type === 'COPY_CODE') !== -1) {
const btnCode = { key: 'copy_code', placeholder: '复制条码' };
tempArr.push(btnCode);
}
if (key === 'buttons' ) {
(tempItem.components?.buttons?.[0]?.buttons || []).filter(btn0 => btn0.type === 'URL').forEach((btn) => {
const hasParam = Object.prototype.hasOwnProperty.call(btn, "example");
const templateUrl = btn.url || ''
const urlParamKeys = (templateUrl.match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, '')).map(key => ({ key }))
hasParam ? tempArr.push(...urlParamKeys) : false;
});
}
return (
<>
{tempArr.map((ele, i) =>
typeof ele === 'string' ? (
<span key={ele.trim()} className=' text-wrap'>
{ele.replace(/\n+/g, '\n')}
</span>
) : ele.key.includes('free') || ele.key.includes('detail') ? (
<Input.TextArea
key={`${ele.key}_${i}`}
rows={2}
onChange={(e) => {
onInput(tempItem, ele.key, e.target.value, paramsVal)
}}
className={` w-11/12 `}
size={'small'}
title={ele.key}
placeholder={`${paramsVal[ele.key] || ele.key} 按Tab键跳到下一个空格\n注意: 模板消息无法输入换行`}
value={activeInput[tempItem.name]?.[ele.key] || paramsVal[ele.key] || ''}
// onPressEnter={() => handleSendTemplate(tempItem)}
/>
) : (
<Input
key={`${ele.key}_${i}`}
onChange={(e) => {
onInput(tempItem, ele.key, e.target.value, paramsVal)
}}
className={ele.key.includes('free') || ele.key.includes('detail') ? `w-full block ` : `w-auto ${paramsVal[ele.key] ? 'max-w-24' : 'max-w-60'}`}
size={'small'}
title={ele.key}
placeholder={`${paramsVal[ele.key] || ele.key} ${ele?.placeholder || '按Tab键跳到下一个空格'}`}
value={activeInput[tempItem.name]?.[ele.key] || paramsVal[ele.key] || ''}
// onPressEnter={() => handleSendTemplate(tempItem)}
/>
),
)}
</>
)
};
const renderHeader = ({ tempItem }) => {
if (isEmpty(tempItem.components.header)) {
return null;
}
const headerObj = tempItem.components.header[0];
return (
<div className='pb-1'>
{'text' === headerObj.format.toLowerCase() && <div>{renderForm({ tempItem }, 'header')}</div>}
{'image' === headerObj.format.toLowerCase() && (
<div className='flex items-center'>
<img src={headerObj.example.header_url} height={100} className='mr-1'></img>
{renderForm({ tempItem }, 'header')}
</div>
)}
{['document', 'video'].includes(headerObj.format.toLowerCase()) && (
<a href={headerObj.example.header_url} target='_blank' key={headerObj.format} rel='noreferrer' className='text-sm'>
[&nbsp;{headerObj.format}&nbsp;]({headerObj.example.header_url})
</a>
)}
</div>
)
}
const renderButtons = ({ tempItem }) => {
if (isEmpty(tempItem.components.buttons)) {
return null;
}
const buttons = tempItem.components.buttons.reduce((r, c) => r.concat(c.buttons), []);
return (
<div className='flex gap-1 pt-1'>
{buttons.map((btn, index) =>
<>
{btn.type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={btn.url} rel='noreferrer'>
{btn.text}
</Button>
) : btn.type.toLowerCase() === 'phone_number' ? (
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer'>
{btn.text} ({btn.phone_number})
</Button>
)
: btn.type.toLowerCase() === 'copy_code' ? null
// (
// <span>{renderForm({ tempItem }, 'buttons')}</span>
// )
: (
<Button className='text-blue-500' size={'small'} key={`${btn.type}_${index}`} rel='noreferrer'>
{btn.text}
</Button>
)}
{Object.prototype.hasOwnProperty.call(btn, "example") ? (<span key={`${btn.type}_${index}`}>{renderForm({ tempItem }, 'buttons')}</span>) : null}
</>
)}
</div>
);
}
return (
<List
className=' h-[90%] overflow-y-auto text-slate-900'
itemLayout='horizontal'
dataSource={dataSource}
rowKey={'key'}
pagination={dataSource.length < 4 ? false : { position: 'bottom', pageSize: 3, align: 'start', size: 'small' }}
renderItem={(item, index) => (
<List.Item key={`${currentConversation.sn}_${item.key}`}>
<List.Item.Meta
className=' '
title={
<Flex justify={'space-between'}>
<span>
{item.components.header?.[0]?.text || (item.displayName)}
<Tag style={{ ...TagColorStyle(item.language.toUpperCase(), true) }} className='ml-1'>
{item.language.toUpperCase()}
</Tag>
{/* <Tag style={{...TagColorStyle(item.category.toUpperCase(), true)}}>{templateCaterogyText[item.category]}</Tag> */}
{templateCaterogyTipText[item.category] && <Tag style={{ ...TagColorStyle(item.category.toUpperCase(), true) }}>{templateCaterogyTipText[item.category]}</Tag>}
</span>
<Button onClick={() => handleSendTemplate(item)} size={'small'} type='link' key={'send'} icon={<SendOutlined />}>
Send
</Button>
</Flex>
}
description={
<>
<div className=' max-h-32 overflow-y-auto divide-dashed divide-x-0 divide-y divide-gray-300'>
{renderHeader({ tempItem: item })}
<div className='text-slate-500 py-1 whitespace-pre-wrap'>{renderForm({ tempItem: item })}</div>
{item.components?.footer?.[0] ? <div className=''>{item.components.footer[0].text || ''}</div> : null}
{renderButtons({ tempItem: item })}
</div>
</>
}
/>
</List.Item>
)}
/>
)
}
const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
const [mobile] = useStyleStore((state) => [state.mobile]);
const searchInputRef = useRef(null);
const { notification } = App.useApp();
const loginUser = useAuthStore((state) => state.loginUser);
const { whatsAppBusiness } = loginUser;
loginUser.usernameEN = (loginUser.accountList[0]?.OPI_NameEN || '').split(' ')?.[0] || loginUser.username;
loginUser.usernameEN = loginUser.accountList[0].OPI_NameEN.split(' ')?.[0] || loginUser.username;
const currentConversation = useConversationStore((state) => state.currentConversation);
const templates = useConversationStore((state) => state.templates);
const [openTemplates, setOpenTemplates] = useState(false);
const [dataSource, setDataSource] = useState(templates);
const [templateCMapped, setTemplateCMapped] = useState({});
const [templateLangMapped, setTemplateLangMapped] = useState({});
const [searchContent, setSearchContent] = useState('');
// : customer, agent
const valueMapped = { ...cloneDeep(currentConversation), ...objectMapper(loginUser, { usernameEN: [{ key: 'agent_name' }, { key: 'your_name' }, { key: 'your_name1' }, { key: 'your_name2' }] }), ...{ order_number: currentConversation.coli_id } };
const valueMapped = { ...cloneDeep(currentConversation), ...objectMapper(loginUser, { usernameEN: [{ key: 'agent_name' }, { key: 'your_name' }, { key: 'your_name1' }, { key: 'your_name2' }] }) };
useEffect(() => {
setDataSource([]);
// setDataSource(templates);
const mappedByCategory = groupBy(templates, 'category');
const mappedByLang = sortObjectsByKeysMap(groupBy(templates, 'displayLanguage'), ['en']); // todo:
setTemplateCMapped(mappedByCategory);
setTemplateLangMapped(mappedByLang);
setDataSource(templates);
return () => {};
}, [templates]);
@ -227,23 +47,22 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
}, [currentConversation.sn])
const [openTemplates, setOpenTemplates] = useState(false);
const [dataSource, setDataSource] = useState(templates);
const [searchContent, setSearchContent] = useState('');
const handleSearchTemplates = (val) => {
if (val.toLowerCase().trim() !== '') {
const res = templates.filter(
(item) =>
item.name.includes(val.toLowerCase().trim()) ||
item.displayName.includes(val.toLowerCase().trim()) ||
item.components_origin.some((itemc) => (itemc?.text || '').toLowerCase().includes(val.toLowerCase().trim())),
)
(item) => item.name.includes(val.toLowerCase().trim()) || item.components_origin.some((itemc) => (itemc?.text || '').toLowerCase().includes(val.toLowerCase().trim()))
);
setDataSource(res);
return false;
}
setDataSource([]);
setDataSource(templates);
};
const handleSendTemplate = (fromTemplate) => {
const mergeInput = { ...cloneDeep(valueMapped), ...activeInput[fromTemplate.name] };
// console.log('----------------------------------------------', mergeInput)
let valid = true;
const msgObj = {
type: 'whatsappTemplate',
@ -251,83 +70,26 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
template: {
name: fromTemplate.name,
language: { code: fromTemplate.language },
components: sortArrayByOrder(fromTemplate.components_origin.reduce((r, citem) => {
components: sortArrayByOrder(fromTemplate.components_origin.map((citem) => {
const keys = ((citem?.text || '').match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, ''));
const params = keys.map((v) => ({ type: 'text', text: getNestedValue(mergeInput, [v]) }));
const notTextKeys = [];
const paramNotText = [];
if (citem.type.toLowerCase() === 'header' && (citem?.format || 'text').toLowerCase() !== 'text') {
params[0] = { type: citem.format.toLowerCase(), [citem.format.toLowerCase()]: { link: mergeInput?.header_url || citem.example.header_url[0] } };
//
// notTextKeys.push('header_url');
// mergeInput?.header_url ? paramNotText.push(mergeInput?.header_url || '') : false;
}
let buttonsComponents;
if (citem.type.toLowerCase() === 'buttons' ) {
buttonsComponents = citem.buttons.map((btn, i) => {
const hasParam = Object.prototype.hasOwnProperty.call(btn, "example"); // whatsappTemplateBtnParamTypesMapped
let fillBtn = {};
const paramKey = whatsappTemplateBtnParamTypesMapped[btn.type.toLowerCase()];
if (paramKey) {
params[0] = { type: paramKey, [paramKey]: mergeInput?.[btn.type.toLowerCase()] || '' };
notTextKeys.push(paramKey);
mergeInput?.[btn.type.toLowerCase()] ? paramNotText.push(mergeInput?.[btn.type.toLowerCase()] || '') : false;
fillBtn = { text: btn.text || `${btn.type}:${mergeInput?.[btn.type.toLowerCase()] || ''}` };
}
if (btn.type.toLowerCase() === 'url') {
const templateUrl = btn.url || '';
const urlParamKeys = (templateUrl.match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, ''));
const urlParams = urlParamKeys.map(key => ({ type: 'text', text: getNestedValue(mergeInput, [key]) }))
params.push(...urlParams);
notTextKeys.push(...urlParamKeys);
urlParamKeys.forEach(key => {
if (getNestedValue(mergeInput, [key])) {
paramNotText.push(getNestedValue(mergeInput, [key]));
}
});
fillBtn = hasParam ? { text: btn.text, url: replaceTemplateString(templateUrl, paramNotText) } : {};
}
// if (hasParam) {
// notTextKeys.push(paramKey);
// mergeInput?.[btn.type.toLowerCase()] ? paramNotText.push(mergeInput?.[btn.type.toLowerCase()] || '') : false;
// }
return hasParam ? { type: 'button', index: i, sub_type: btn.type.toLowerCase(), parameters: params, ...fillBtn } : null;
})
buttonsComponents = flush(buttonsComponents);
params[0] = { type: citem.format.toLowerCase(), [citem.format.toLowerCase()]: { link: citem.example.header_url[0] } };
}
// console.log('******', buttonsComponents, '\n', notTextKeys, '\n', paramNotText)
const paramText = keys.length ? params.map((p) => p.text) : [];
const fillTemplate = paramText.length ? replaceTemplateString(citem?.text || '', paramText) : citem?.text || '';
valid = keys.length !== paramText.filter((s) => s).length ? false : valid;
valid = notTextKeys.length !== paramNotText.filter((s) => s).length ? false : valid;
const _components = ['body', 'header'].includes(citem.type.toLowerCase()) ? [{
return ['body', 'header'].includes(citem.type.toLowerCase()) ? {
type: citem.type.toLowerCase(),
parameters: params,
text: fillTemplate,
}] : ['buttons'].includes(citem.type.toLowerCase()) ? buttonsComponents : [{...citem, type: citem.type.toLowerCase(),}];
return r.concat(_components);
}, []), 'type', ['body', 'header', 'footer', 'button', 'buttons'] ),
components_omit: fromTemplate.components_origin.reduce((r, citem) => {
const _componentItems =
citem.type.toLowerCase() === 'buttons'
? citem.buttons.map((btn, index) => ({ ...btn, index, type: 'button', sub_type: btn.type.toLowerCase() }))
: [{ ...citem, type: citem.type.toLowerCase() }]
const staticComponents = _componentItems.filter((item) => !item.example && item.type.toLowerCase() !== 'body')
return r.concat(staticComponents)
}, []),
} : {...citem, type: citem.type.toLowerCase(),};
}), 'type', ['header', 'body', 'footer', 'buttons'] ),
},
template_origin: fromTemplate,
};
const plainTextMsgObj = {
type: 'text',
text: msgObj.template.components.filter(com => com.type.toLowerCase() === 'body').map((citem) => citem.text).join(''),
};
if (valid !== true) {
notification.warning({
message: '提示',
@ -338,8 +100,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
});
return false;
}
// console.log('------------------------------------------------------------------------------', msgObj );
invokeSendMessage(channel === 'waba' ? msgObj : plainTextMsgObj);
invokeSendMessage(msgObj);
setOpenTemplates(false);
setActiveInput({});
};
@ -352,6 +113,80 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
});
};
const renderHeader = ({ tempItem }) => {
if (isEmpty(tempItem.components.header)) {
return null;
}
const headerObj = tempItem.components.header[0];
return (
<div className='pb-1'>
{'text' === headerObj.format.toLowerCase() && <div>{renderForm({ tempItem }, 'header')}</div>}
{'image' === headerObj.format.toLowerCase() && <img src={headerObj.example.header_url} height={100}></img>}
{['document', 'video'].includes(headerObj.format.toLowerCase()) && (
<a href={headerObj.example.header_url} target='_blank' key={headerObj.format} rel='noreferrer' className='text-sm'>
[&nbsp;{headerObj.format}&nbsp;]({headerObj.example.header_url})
</a>
)}
</div>
);
}
const renderButtons = ({ tempItem }) => {
if (isEmpty(tempItem.components.buttons)) {
return null;
}
const buttons = tempItem.components.buttons.reduce((r, c) => r.concat(c.buttons), []);
return (
<div className='flex gap-1 pt-1'>
{buttons.map((btn, index) =>
btn.type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={btn.url} rel='noreferrer'>
{btn.text}
</Button>
) : btn.type.toLowerCase() === 'phone_number' ? (
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer'>
{btn.text} ({btn.phone_number})
</Button>
) : (
<Button className='text-blue-500' size={'small'} key={btn.type} rel='noreferrer'>
{btn.text}
</Button>
)
)}
</div>
);
}
const renderForm = ({ tempItem }, key = 'body') => {
const templateText = tempItem.components?.[key]?.[0]?.text || '';
const tempArr = splitTemplate(templateText);
const keys = (templateText.match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, ''));
const paramsVal = keys.reduce((r, k) => ({ ...r, [k]: getNestedValue(valueMapped, [k]) }), {});
return (
<>
{tempArr.map((ele) =>
typeof ele === 'string' ? (
<span key={ele.trim()} className=' text-wrap'>
{ele}
</span>
) : (
<Input
key={ele.key}
onChange={(e) => {
onInput(tempItem, ele.key, e.target.value, paramsVal);
}}
className={ele.key.includes('free') || ele.key.includes('detail') ? `w-full block ` : `w-auto ${paramsVal[ele.key] ? 'max-w-24' : 'max-w-60'}`}
size={'small'}
title={ele.key}
placeholder={paramsVal[ele.key] || ele.key}
value={activeInput[tempItem.name]?.[ele.key] || paramsVal[ele.key] || ''}
// onPressEnter={() => handleSendTemplate(tempItem)}
/>
)
)}
</>
);
};
return (
<>
<Popover
@ -359,25 +194,9 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
fresh
forceRender
destroyTooltipOnHide={true}
title={
<div className='flex justify-between mt-0 gap-4 items-center'>
<Input.Search prefix={'💬'}
ref={searchInputRef}
onSearch={handleSearchTemplates}
allowClear
value={searchContent}
onChange={(e) => {
setSearchContent(e.target.value);
handleSearchTemplates(e.target.value);
}}
placeholder='搜索名称'
/>
<Button size='small' onClick={() => setOpenTemplates(false)}>&times;</Button>
</div>
}
content={
<>
{/* <div className='flex justify-between mt-2 gap-4 items-center'>
<div className='flex justify-between mt-2 gap-4 content-center'>
<Input.Search prefix={'🙋'}
ref={searchInputRef}
onSearch={handleSearchTemplates}
@ -390,22 +209,8 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
placeholder='搜索名称'
/>
<Button size='small' onClick={() => setOpenTemplates(false)}>&times;</Button>
</div> */}
{isEmpty(dataSource) && isEmpty(searchContent) ? (
<Tabs items={[
// { key: 'marketing', label: '', children: <CategoryList key={'utility-templates'} dataSource={templateCMapped?.MARKETING || []} {...{ handleSendTemplate, activeInput, onInput, valueMapped}} />},
// { key: 'utility', label: '', children: <CategoryList key={'utility-templates'} dataSource={templateCMapped?.UTILITY || []} {...{ handleSendTemplate, activeInput, onInput, valueMapped}} />},
...(Object.keys(templateLangMapped).map(lang => ({
key: lang, label: lang.toUpperCase(), children: <CategoryList key={'lang-templates-'+lang} dataSource={templateLangMapped[lang]} {...{ handleSendTemplate, activeInput, onInput, valueMapped}} />
})))
]} defaultActiveKey='utility' tabBarExtraContent={{right: <Alert type='info' message={channel==='waba'?'请优先使用"触达率高"模板': '模板消息将用纯文本发送'} showIcon className='py-0' />, }} size='small' />
) :
(
// Search result
<CategoryList {...{ handleSendTemplate, activeInput, onInput, valueMapped }} dataSource={dataSource} key='search-templates' />
)}
{/* <List
</div>
<List
className='h-4/6 overflow-y-auto text-slate-900'
itemLayout='horizontal'
dataSource={dataSource}
@ -417,7 +222,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
className=' '
title={
<Flex justify={'space-between'}>
<span>{item.components.header?.[0]?.text || item.name}<Tag color='blue' className='ml-1'>{item.language.toUpperCase()}</Tag></span>
<>{item.components.header?.[0]?.text || item.name}</>
<Button onClick={() => handleSendTemplate(item)} size={'small'} type='link' key={'send'} icon={<SendOutlined />}>
Send
</Button>
@ -436,7 +241,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
/>
</List.Item>
)}
/> */}
/>
</>
}
// title={
@ -444,11 +249,11 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
// <div>🙋</div>
// <Button size='small' onClick={() => setOpenTemplates(false)}>&times;</Button>
// </div>}
trigger='click' arrow={false}
trigger='click'
open={openTemplates}
onOpenChange={(v) => {
setOpenTemplates(v);
// setActiveInput({});
setActiveInput({});
}}>
<Button type='text' className='' icon={<MessageOutlined />} size={'middle'} disabled={disabled} />
</Popover>

@ -1,6 +1,6 @@
import useConversationStore from '@/stores/ConversationStore';
import useAuthStore from '@/stores/AuthStore'
import { Flex, Typography, Avatar, Alert, Button, Tooltip, Spin, Space } from 'antd';
import { Flex, Typography, Avatar, Alert, Button, Tooltip, Spin } from 'antd';
import { ReloadOutlined, ApiOutlined } from '@ant-design/icons';
import { LoadingOutlined } from '@ant-design/icons';
import ExpireTimeClock from './ExpireTimeClock';
@ -34,15 +34,16 @@ const MessagesHeader = () => {
{websocketRetrying && websocketRetrytimes > 0 && <Alert type={'warning'} message={`正在重连, ${websocketRetrytimes}次...`} banner icon={<LoadingOutlined />} />}
<Flex gap={16} className='p-1 flex-auto'>
{/* {currentConversation.customer_name && <Avatar src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${currentConversation.customer_name}`} />} */}
<Flex flex={'1'} justify='space-between' align='center'>
<Flex vertical={false} gap={12} justify='space-between' align='center'>
<Flex flex={'1'} justify='space-between'>
<Flex vertical={false} gap={12} justify='space-between'>
{(currentConversation.coli_sn || currentConversation.sn) ? (
<>
<Typography.Text strong>{currentConversation.show_default}</Typography.Text>
{currentConversation.sn ? (
<div className='flex flex-col'>
<Typography.Text>{currentConversation.session_type === 1 ? '' : currentConversation?.channels?.whatsapp_phone_number}</Typography.Text>
</div>
<>
<Typography.Text>{currentConversation?.channels?.whatsapp_phone_number}</Typography.Text>
<Typography.Text>{currentConversation?.channels?.email}</Typography.Text>
</>
) : (
<Typography.Text strong type='danger'>
没有WhatsApp号码

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save