Compare commits

...

228 Commits

Author SHA1 Message Date
Lei OT 84e9ada0e7 style: 3 days ago
Lei OT 6a3da2a537 # 3 days ago
Lei OT 535aa38775 perf: 邮件内容: 图片点击预览 3 days ago
Lei OT 22674fe498 . 3 days ago
Lei OT eabb53e0a9 perf: 邮件列表: 搜索结果 3 days ago
LiaoYijun 9ed85c81b2 perf:优化订单展开、收缩按钮位置 4 days ago
LiaoYijun 6baff26720 doc:prerealse 1.5.0-1 4 days ago
LiaoYijun 0a06f6c16a perf: 精简查找邮件目录数量 4 days ago
LiaoYijun 038db199e9 feat: 删除已读设置,增加未读。删除无用代码 4 days ago
Lei OT 210d3e7263 fix: Listener 数据对象 5 days ago
Lei OT 945f1b3651 perf: 邮箱目录更新 5 days ago
Lei OT 378c864277 perf: email quote, text/plain 5 days ago
Lei OT cbf63c5e86 feat: 查找邮件: 更新 5 days ago
Lei OT 01997b7d23 Merge remote-tracking branch 'origin/main' 5 days ago
LiaoYijun d5a522e88f feat: 增加【查找邮件】界面 5 days ago
Lei OT e0950bb773 style: 6 days ago
Lei OT 74a0e0c14e conf: build chunk 6 days ago
Lei OT 068a02ff64 perf: email quote, text/plain 6 days ago
Lei OT 2abd149655 perf: email 不在在线聊天页面通知 6 days ago
Lei OT 2b344eec43 perf: email quote 6 days ago
LiaoYijun 7365ae08ee perf: 修改 WA 后更新 State 6 days ago
LiaoYijun 5b5ebe896c perf:编辑附加信息初始值 6 days ago
LiaoYijun cd5dbb3be5 feat:增加 WA、附加信息设置 7 days ago
LiaoYijun d0fbf179fd feat: 增加设置 WA 界面 1 week ago
Lei OT 6b32601fc7 style: 固定mail list 的header 1 week ago
Lei OT 403cb4f7c7 perf: 模板 1 week ago
Lei OT c51df7fa03 style: 会话筛选 1 week ago
Lei OT 26b709a7dc Merge branch 'dev/2025a' 1 week ago
Lei OT b29b927b5c # 1 week ago
Lei OT 15acbf4f2f update: antd Modal props 1 week ago
Lei OT 8eccf74c61 style: 1 week ago
Lei OT 6211d275d1 perf: 订单信息: 催信状态 1 week ago
Lei OT 4fb5041179 feat: 历史记录: +渠道搜索 1 week ago
Lei OT 7ec91716a7 style: 格式刷图标 1 week ago
Lei OT 2d7f269a27 perf: 邮件编辑页面: 切换账号 1 week ago
Lei OT 7e84b9cb5a 邮箱默认文件夹 2 weeks ago
Lei OT f78130544a perf: 邮件详情: 最大化窗口 2 weeks ago
Lei OT 674b3cc591 邮件查找接口 2 weeks ago
Lei OT 29cc138b33 # 2 weeks ago
Lei OT 1977b8b404 perf: 邮件: 编辑草稿 2 weeks ago
Lei OT aabc409f6d feat: 邮件: 删除; perf: 更新数量 2 weeks ago
Lei OT d9686b3ef5 # 2 weeks ago
Lei OT acf2c02063 style: 2 weeks ago
Lei OT 88958977c4 perf: 邮件目录保持展开的树; style: MailBox title 2 weeks ago
Lei OT 812bf19c26 feat: 邮箱目录: 数量 2 weeks ago
Lei OT 3f9cc81b30 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 2 weeks ago
Lei OT 701a6a00c0 perf: 新邮件: 前缀[称呼+订单号] 2 weeks ago
LiaoYijun f8246c10a3 fix: 试用 Router-Link 跳转在线聊天 2 weeks ago
LiaoYijun 95d6e7dd92 feat:完成订单新增字段显示 2 weeks ago
Lei OT ca5dcc0705 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 2 weeks ago
Lei OT 70a7f25b07 feat: 查找邮箱/订单: 订单仅当前账号下 2 weeks ago
Lei OT b0d72e0f7b Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 2 weeks ago
LiaoYijun 890027563c feat: 完成表单信息添加 2 weeks ago
Lei OT f081aa46e9 perf: 邮件绑定订单: 支持商务订单 2 weeks ago
Lei OT 696832eba1 # 2 weeks ago
Lei OT b1db77bbe9 conf: 增加缓存: 附件, 优化图片加载 2 weeks ago
Lei OT afeef17e28 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 2 weeks ago
Lei OT be07ab175b feat: 查找邮箱/订单 2 weeks ago
LiaoYijun bf0f85b17a perf: 优化移动端获取当前订单 2 weeks ago
LiaoYijun 7cd6e1b2aa perf: 在线聊天共用订单信息组件 2 weeks ago
Lei OT 52e6307769 perf: 邮件详情: 自动标记; 附件处理; 2 weeks ago
Lei OT 2d4edd6c64 查找订单→邮箱目录 2 weeks ago
Lei OT 318c2256b3 perf: 编辑会话: 邮箱地址 2 weeks ago
Lei OT 5471345cf1 fix: email detail btns key 2 weeks ago
Lei OT f7f9500413 feat: 设为`已处理`后, 更新列表 2 weeks ago
LiaoYijun 95a1b16085 perf: 调整邮件列表按钮大小、文字 2 weeks ago
Lei OT e9e409ed0d perf: 订单跟踪: 默认折叠`订单信息` 2 weeks ago
Lei OT 36f237a14a feat: 新窗口打开邮件详情 2 weeks ago
Lei OT d18b8fd5e5 perf: 在线聊天: 不显示邮箱地址 2 weeks ago
Lei OT f9999e7d06 模板接口命名 2 weeks ago
Lei OT 49fede675e perf: 催信模板标题 2 weeks ago
Lei OT df4932f325 # 2 weeks ago
Lei OT 06209430da Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a
# Conflicts:
#	src/hooks/useEmail.js
#	src/views/orders/components/MailBox.jsx
2 weeks ago
Lei OT db14ce64af feat: 邮件: 打开详情: 设为`已读` 2 weeks ago
Lei OT 5f657b2618 feat: 数据更新: 广播; 事件; `已读`, `已处理` 不刷新请求, 仅更新缓存 2 weeks ago
LiaoYijun 06a3e95e1a perf:调整邮件列表样式和代码 2 weeks ago
LiaoYijun 095a8f3d3b perf: 增加 WA 跳转到在线聊天 2 weeks ago
LiaoYijun d293e3a1e5 perf: 完成订单状态、催信设置 2 weeks ago
Lei OT 10e6b56446 feat: 缓存策略: 自动清除7天前的, 每天检查 3 weeks ago
Lei OT 5d41b44270 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT 4dd404167b fix: 邮件详情 3 weeks ago
LiaoYijun 17993f348c perf: 已读、已处理后更新邮件列表 3 weeks ago
LiaoYijun 645c85a59a feat: 增加设置邮件已读、已处理 3 weeks ago
LiaoYijun 85c2622213 perf: 删除拨打电话链接 3 weeks ago
Lei OT cc471d93c8 conf: api v3 地址 3 weeks ago
Lei OT 05a22161cd feat: 保存草稿: 附件处理 3 weeks ago
Lei OT aeb0672002 conf: indexedDB version upgrade 3 weeks ago
Lei OT 1a0328303d perf: 邮件的订单 3 weeks ago
Lei OT 46fa96694f perf: 模板按钮 3 weeks ago
Lei OT fdfe4d3083 # 3 weeks ago
Lei OT e38d136cc4 perf: maillist 缓存 3 weeks ago
Lei OT 01f0f9bd9d Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT 5ce589654a perf: maillist 缓存 3 weeks ago
LiaoYijun 7d7334ffe2 perf: 不能通过号码拨打电话 3 weeks ago
LiaoYijun ad1f934d3b perf: 下线 Vonage Voice,价格没有谈成 3 weeks ago
Lei OT 8f3fdef2e6 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT e51581202d 加载模板 3 weeks ago
LiaoYijun d265f03a6e perf: 调整选中样式、附件图标 3 weeks ago
LiaoYijun 7a45245437 feat: 增加多选、全选功能 3 weeks ago
Lei OT 1393bf9899 perf: 邮件详情: 附件的显示 3 weeks ago
Lei OT be2f85a0da style: 3 weeks ago
Lei OT 0529cce11b Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT fe3cde0c89 feat: 保存草稿; 3 weeks ago
LiaoYijun 0b37f1a9a0 perf: 调整手机、WA 样式 3 weeks ago
Lei OT 01dbcabdd7 perf: 邮箱目录 3 weeks ago
Lei OT d55d55a3aa Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT 4d4e6fe1d3 style: 邮件详情 3 weeks ago
Lei OT e113c33fc6 新窗口: 邮件编辑 3 weeks ago
LiaoYijun dd9e6e9e3a perf:调整表单信息顺序;增加骨架屏 3 weeks ago
LiaoYijun d733303ec3 fix: 修复合并出错 3 weeks ago
LiaoYijun e9d7bd1e8f feat:根据订单读取客人、表单信息;去除报价列表 3 weeks ago
Lei OT ef3e55eefb perf: 邮件详情 3 weeks ago
Lei OT c4dd6b0147 feat: useEmailList + refresh 3 weeks ago
Lei OT af5dd4efdc perf: 邮箱目录操作; perf: 邮件详情的附件; 3 weeks ago
Lei OT 496861bcaa perf: MailBox 参数 3 weeks ago
Lei OT 1863983d02 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT f1bc44da07 perf: 待办目录: 处理订单重复 3 weeks ago
LiaoYijun 12b793d277 feat: 邮件列表增加分页功能 3 weeks ago
Lei OT fdfd633ecf perf: 待办目录+邮箱目录; `催信` 3 weeks ago
Lei OT d881778d78 feat: mailbox 缓存清除策略 3 weeks ago
Lei OT f4f956fd5e Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT c7d72d01f3 perf: 待办目录+邮箱目录 3 weeks ago
Lei OT a61ed7eb82 perf: 邮箱目录: 查询不需`出发日期` 3 weeks ago
LiaoYijun 0da3ea58af perf: 增加邮件列表分页,暂时显示第一页 3 weeks ago
Lei OT 1e9f84665e perf: email list 字段不需要重命名 3 weeks ago
Lei OT 0860a7054d perf: mailbox 参数 3 weeks ago
Lei OT c622138c7d style: 3 weeks ago
Lei OT d9082a1203 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT 3df24ab6ba perf: 邮箱目录: 保留选中 3 weeks ago
LiaoYijun 9a7db21d74 perf: 显示邮件总数 3 weeks ago
LiaoYijun ad142df232 feat: 选择订单,邮件不显示订单号;面包屑和树一致 3 weeks ago
LiaoYijun 6db05883de perf: 没有团号、国籍时隐藏 3 weeks ago
Lei OT a6cb136ccf perf: 邮箱目录: 固定的目录不触发事件 3 weeks ago
Lei OT 716776f96b perf: 计算mailbox 的title 3 weeks ago
Lei OT 74bd58529e Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 3 weeks ago
Lei OT 32fd97ce91 style: 订单跟踪: 订单信息的缩放 3 weeks ago
LiaoYijun 62615cfb98 perf: 增加高级搜索切换 3 weeks ago
Lei OT 1286a0c20a Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a
# Conflicts:
#	src/views/orders/components/MailBox.jsx
3 weeks ago
Lei OT efae99e81e feat: useEmailList hooks 3 weeks ago
LiaoYijun 05de1791ed perf: 取消高级搜索 Drawer;调整邮件项为两行 4 weeks ago
Lei OT 1d513ec038 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a
# Conflicts:
#	src/views/orders/components/MailBox.jsx
4 weeks ago
LiaoYijun c8ca19954f feat:增加分页按钮及数量 4 weeks ago
LiaoYijun 9823214e4b perf:增加面包屑;调整邮件单选框位置 4 weeks ago
Lei OT 2a629df2ed feat: 邮箱文件夹接口; 邮件列表接口 4 weeks ago
LiaoYijun 3519c4a414 perf:调整邮件列表样式,精简字体大小、行间距 4 weeks ago
Lei OT 9d31e69db9 feat: 邮箱文件夹接口; 邮件列表接口 4 weeks ago
Lei OT 9100e4b19d fix: 会话列表时间 4 weeks ago
Lei OT f3ead963cc fix: 会话列表时间 4 weeks ago
Lei OT 36068565f9 . 4 weeks ago
LiaoYijun a778bc475f perf: 整合原来 CustomerProfile 界面;调整批量操作按钮 4 weeks ago
Lei OT ab3c763238 feat: 邮箱文件夹 4 weeks ago
Lei OT 6bc5faa3f8 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 4 weeks ago
Lei OT 428524b232 fix: 合并覆盖 4 weeks ago
LiaoYijun e4fc6f79c0 feat: 独立邮件箱组件 4 weeks ago
LiaoYijun f4a64f0a03 feat: 独立订单信息组件 4 weeks ago
LiaoYijun 25722eff77 fix: 解决合并冲突后错误 4 weeks ago
LiaoYijun 8b6326380d perf:更新催信模板菜单;
feat:增加树节点选择显示
1 month ago
Lei OT b6903c7a9d 邮件文件夹 1 month ago
Lei OT 55e787b97f 邮件相关接口 1 month ago
Lei OT dfb7016240 feat: 订单跟踪: 邮件详情 1 month ago
Lei OT 0505313830 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 1 month ago
Lei OT 99862de593 # 1 month ago
Lei OT e88fa38991 feat: 订单跟踪: 邮件详情 1 month ago
Lei OT 04c71b1ff0 邮件详情; `已处理`操作 1 month ago
Lei OT 1ea4dabbc2 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 1 month ago
Lei OT 41948fed6f 新窗口: 邮件编辑 1 month ago
LiaoYijun ab36a85b22 perf:删除原有表格和无用代码 1 month ago
LiaoYijun b1efa2f4f3 feat: 增加邮件批量操作工具栏 1 month ago
Lei OT 92e5cab823 perf(前端): 历史记录: 查询`邮件`渠道; 类型 1 month ago
Lei OT ce8494fe26 perf(前端): 在线聊天: 供应商邮件; 1 month ago
Lei OT 63ec5c0a28 perf: 邮件附件预览 1 month ago
Lei OT c51012f88e Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 1 month ago
Lei OT fc87029d7a perf: 消息发送失败的 错误信息 1 month ago
LiaoYijun 8d7d5e32f4 perf: 删除非必要字段 wlemail 1 month ago
Lei OT 2ac04974a0 perf: 图文集; 支付链接 1 month ago
Lei OT df89722804 perf: 支付链接: 删除通知邮箱 1 month ago
Lei OT 5d73d04009 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 1 month ago
Lei OT c021cd162b # 1 month ago
Lei OT ba6e017ffd feat: 编辑器: 清除格式 1 month ago
LiaoYijun b61c2baf9f perf: 删除提醒中心 1 month ago
LiaoYijun 2b6b0f9961 feat: 增加新窗口回复邮件例子 1 month ago
LiaoYijun b6efb24a87 perf: 使用四列布局订单跟踪 1 month ago
LiaoYijun 7449ad9e44 perf: 使用 Layout 布局 1 month ago
Lei OT c2f94e3e81 perf: add Icon: new mail 1 month ago
Lei OT d1fff21159 perf(前端): 语音全局入口 1 month ago
Lei OT 7b0bb05e89 test: 编辑邮件窗口
# Conflicts:
#	src/utils/commons.js
1 month ago
Lei OT 0d9dd3ad8c feat: 编辑器: 格式刷 1 month ago
Lei OT 3e8cda6700 feat: 编辑器: 字号 1 month ago
Lei OT 96daa64eb1 perf: 会话列表 顶部 1 month ago
Lei OT e126dec2ca Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 1 month ago
LiaoYijun c395ea1e8d perf: Dev 不发送 PageSpy;初步开发订单跟踪 1 month ago
Lei OT 4d3c4979c8 Merge branch 'main' into dev/2025a 1 month ago
Lei OT 1dfd2f28e1 1.4.10 1 month ago
LiaoYijun 1f972f417b perf:更新 router,zustand 小版本 1 month ago
LiaoYijun 1696d13c31 perf: 更新 Antd 小版本 1 month ago
Lei OT 8277a7b8ac style: 邮件详情 1 month ago
Lei OT 5278dc6030 perf(前端): 日志清除策略 1 month ago
Lei OT c61795ea97 perf(前端): 上传日志, 读取最近的websocket记录 1 month ago
Lei OT a7d478b667 perf(前端): 在线聊天: 会话的订单筛选; `已处理`改为`隐藏`; 会话列表每页100; 不操作`邮件`; 默认折叠`订单信息`; 隐藏供应商邮件; 隐藏报价历史; 在线聊天只能看订单信息 1 month ago
Lei OT 569039c311 style: 1 month ago
Lei OT 769fa76831 perf(WAI): outbound Status 1 month ago
Lei OT d7f1af1d49 style: 1 month ago
Lei OT 5446b7ca07 perf: 邮件列表: 无分页 2 months ago
Lei OT 4640801a53 fix: 推送错误,不提示了 2 months ago
Lei OT 0b4e02cfe1 1.4.9 2 months ago
Lei OT 2dd356ab3b fix: 推送错误 2 months ago
Lei OT 868a6441c9 feat: 邮件列表; 在线 2 months ago
Lei OT 75132e14eb feat: 邮件列表 2 months ago
Lei OT 98d885400f perf: 邮件列表组件 2 months ago
Lei OT 29a605cc11 test: # 2 months ago
Lei OT 54dac8e4ed 1.4.8 2 months ago
Ycc dcf86595fc Merge branch 'main' of github.com:hainatravel/global-sales 2 months ago
Ycc affd439f99 删除打印信息 2 months ago
hainatravel b7fb0264f3
Merge pull request #1 from hainatravel/master
已回复数据统计
2 months ago
Ycc 86ae19598a 已回复数据统计 2 months ago
Lei OT 081815fb69 perf(前端): 供应商平台的邮件正文, 处理换行 2 months ago
Lei OT 50109ab629 perf: 供应商邮件 2 months ago
Lei OT 96d0d2bdc0 perf(前端): 邮件正文 2 months ago
Lei OT 6ce6ae1492 revert: 不要处理 html 格式的文本邮件 2 months ago
Lei OT 15874bf229 perf(前端): 邮件详情 2 months ago
Lei OT b7fa9490a7 perf: 邮件绑定订单. 绑定到其他人订单 2 months ago
Lei OT c9b44f233d perf(前端): 供应商平台的邮件正文, 处理换行 2 months ago
Lei OT 35ac6c6c48 perf(前端): 邮件详情: 记住窗口位置 2 months ago
Lei OT 056d075a7c perf(前端): 邮件正文 2 months ago
Lei OT 4a9ea4311b style: 供应商邮件 2 months ago
Lei OT 099edef821 style: 2 months ago
Lei OT 9933fa7460 perf(前端): 邮件正文 2 months ago
Lei OT 888cc8214a perf(前端): 模板消息, 查看头部大图 2 months ago

1
.gitignore vendored

@ -38,3 +38,4 @@ schema*
vonage-client* vonage-client*
**/test **/test
*.bak

@ -1,7 +1,7 @@
{ {
"name": "global-sales", "name": "global-sales",
"private": true, "private": true,
"version": "1.4.7", "version": "1.5.0-1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -13,7 +13,7 @@
"@dckj/react-better-modal": "^0.1.2", "@dckj/react-better-modal": "^0.1.2",
"@lexical/react": "^0.20.0", "@lexical/react": "^0.20.0",
"@vonage/client-sdk": "^2.0.0", "@vonage/client-sdk": "^2.0.0",
"antd": "^5.22.2", "antd": "^5.25.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dingtalk-jsapi": "^3.0.41", "dingtalk-jsapi": "^3.0.41",
"emoji-picker-react": "^4.12.0", "emoji-picker-react": "^4.12.0",
@ -21,10 +21,10 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-chat-elements": "^12.0.17", "react-chat-elements": "^12.0.17",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.30.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zustand": "^4.5.5" "zustand": "^4.5.7"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 491 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 555 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 640 B

@ -28,6 +28,7 @@ export const fetchTemplates = async (params) => {
const scNames = ['trip_planner_showcase', 'showcase_different', 'order_status_updated']; const scNames = ['trip_planner_showcase', 'showcase_different', 'order_status_updated'];
// 客运 // 客运
const crNames = [ const crNames = [
'notification_of_next_trip_planning',
// 'notification_of_following_up_by_cr_v3', // 'notification_of_following_up_by_cr_v3',
'notification_of_one_day_before_ending_the_trip_by_cr_v2', 'notification_of_one_day_before_ending_the_trip_by_cr_v2',
'one_day_after_payment_by_yuni', 'one_day_after_payment_by_yuni',
@ -90,7 +91,7 @@ const templatesDisplayNameMap = {
'agent_intro_with_update_v1': 'quick_update_v1', 'agent_intro_with_update_v1': 'quick_update_v1',
}; };
export const CONVERSATION_PAGE_SIZE = 50; export const CONVERSATION_PAGE_SIZE = 100;
/** /**
* *
* @param {object} params { opisn } * @param {object} params { opisn }

@ -1,12 +1,36 @@
import { fetchJSON, postForm } from '@/utils/request'; import { fetchJSON, postForm, postJSON } from '@/utils/request';
import { API_HOST, EMAIL_HOST } from '@/config'; 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';
const parseHTMLString = (html) => { export const parseHTMLString = (html, needText = false) => {
const parser = new DOMParser() const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html') const doc = parser.parseFromString(html, 'text/html')
const bodyContent = doc.body.innerHTML 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
}
return 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)
} }
/** /**
@ -17,7 +41,8 @@ export const getSalesSignatureAction = async (params) => {
try { try {
const { result } = await fetchJSON(`${EMAIL_HOST}/email_sign`, params) const { result } = await fetchJSON(`${EMAIL_HOST}/email_sign`, params)
const { SignContent: html } = result const { SignContent: html } = result
return parseHTMLString(html); const bodyContent = parseHTMLString(html);
return bodyContent;
} catch (error) { } catch (error) {
return ''; return '';
} }
@ -57,10 +82,10 @@ const encodeEmailInfo = (info) => {
const encodeQuote = (str = '') => str.replace(/"/g, ''); //.replace(/</g,'&lt;').replace(/>/g,'&gt;') 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 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); 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 replyTo = info.MAI_Direction === 1 ? info.MAI_To : info.MAI_From;
const replyToAll = (tosClean.length > 1) ? const replyToAll = (tosClean.length > 1) ?
(info.MAI_Direction === 1 ? tosClean.join(',') : `${tosClean.join(',')}, ${info.MAI_From}`) (info.MAI_Direction === 1 ? tosClean.join(',') : [...tosClean, info.MAI_From].join(','))
: (info.MAI_Direction === 1 ? info.MAI_To : info.MAI_From) : (info.MAI_Direction === 1 ? info.MAI_To : info.MAI_From)
return { return {
...info, ...info,
@ -76,10 +101,37 @@ const encodeEmailInfo = (info) => {
* @param {object} { mai_sn } * @param {object} { mai_sn }
*/ */
export const getEmailDetailAction = async (params) => { 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); const { result } = await fetchJSON(`${EMAIL_HOST}/getmail`, params);
const mailType = result.MailInfo?.[0]?.MAI_ContentType || ''; 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');
return { info: encodeEmailInfo(result.MailInfo?.[0] || {}), content: mailType === 'text/html' ? parseHTMLString((result.MailContent || '').replace(/\r\n/g, '')) : (result.MailContent || ''), attachments: result?.AttachList || [] }; 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 getEmailOrderAction = async ({ colisn }) => { export const getEmailOrderAction = async ({ colisn }) => {
@ -117,3 +169,323 @@ export const fetchEmailBindOrderAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/mailinfo_bindorder`, params) const { errcode, result } = await fetchJSON(`${API_HOST}/mailinfo_bindorder`, params)
return errcode === 0 ? true : false; 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
};

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 262 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 442 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 555 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 347 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 460 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 328 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 504 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 372 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 539 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 572 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 640 B

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

After

Width:  |  Height:  |  Size: 130 B

@ -541,6 +541,8 @@ export const receivedMsgTypeMapped = {
getMsg: (result) => result, getMsg: (result) => result,
contentToRender: () => null, contentToRender: () => null,
contentToUpdate: (msgcontent) => { contentToUpdate: (msgcontent) => {
if (isEmpty(msgcontent)) return null;
if (isEmpty(msgcontent.error)) return null;
let apiErrorCode, let apiErrorCode,
apiErrorMsg = ''; apiErrorMsg = '';
const waCode = msgcontent.error.message.match(/\(#(\d+)\)/); const waCode = msgcontent.error.message.match(/\(#(\d+)\)/);
@ -858,14 +860,14 @@ export const parseRenderMessageList = (messages) => {
msgContent.template = msg.msgtype === 'template' ? { ...msgContent.template, ...template } : {}; msgContent.template = msg.msgtype === 'template' ? { ...msgContent.template, ...template } : {};
// const parseMethod = msgContent.bizType === 'whatsapp' ? cloneDeep(whatsappMsgTypeMapped) : {}; // const parseMethod = msgContent.bizType === 'whatsapp' ? cloneDeep(whatsappMsgTypeMapped) : {};
let waCode, waError = ''; let waCode, waError = '';
if ((msgContent?.status || 'accepted') === 'failed' && msgContent.errorMessage && msg.msg_direction === 'outbound') { if ((msgContent?.status || 'accepted') === 'failed' && (msgContent.errorMessage || msg.errors_code) && msg.msg_direction === 'outbound') {
(waCode = msgContent.errorMessage.match(/\(#(\d+)\)/)); (waCode = (msgContent.errorMessage || msg.errors_code).match(/\(#(\d+)\)/));
(waError = (whatsappError?.[waCode?.[1]] || whatsappError?.[msgContent.errorCode] || msgContent.errorMessage)); (waError = (whatsappError?.[waCode?.[1]] || whatsappError?.[msgContent.errorCode] || msgContent.errorMessage || whatsappError?.[msg.errors_code]));
if (!isEmpty(msgContent.whatsappApiError)) { if (!isEmpty(msgContent.whatsappApiError)) {
waError = whatsappError?.[msgContent.whatsappApiError.code] || msgContent.whatsappApiError.message; waError = whatsappError?.[msgContent.whatsappApiError.code] || msgContent.whatsappApiError.message;
// waError += `\n[${msgContent.errorCode}] ${whatsappError?.[msgContent.errorCode] || msgContent.errorMessage}`; // waError += `\n[${msgContent.errorCode}] ${whatsappError?.[msgContent.errorCode] || msgContent.errorMessage}`;
} }
if (msgContent.errorMessage.includes('Invalid E.146 phone number')) { if ((msgContent.errorMessage || msg.errors_code).includes('Invalid E.146 phone number')) {
waError = whatsappError.INVALID_PHONE_NUMBER; waError = whatsappError.INVALID_PHONE_NUMBER;
} }
} }
@ -945,7 +947,7 @@ export const whatsappError = {
'100': '参数错误, 请联系技术组', '100': '参数错误, 请联系技术组',
'FORBIDDEN': '[FORBIDDEN] ', 'FORBIDDEN': '[FORBIDDEN] ',
'4': '[4] 无法连接WhatsApp.\n请稍后重试', // (#4) Application request limit reached '4': '[4] 无法连接WhatsApp.\n请稍后重试', // (#4) Application request limit reached
'131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用邮件联系', '131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用 邮件/个人WhatsApp 联系',
'131047': '[131047] 会话未激活. \n请使用模板消息💬发送', '131047': '[131047] 会话未激活. \n请使用模板消息💬发送',
'131053': '[131053] 文件上传失败.', '131053': '[131053] 文件上传失败.',
'131048': '[131048] 账户被风控.', // 消息发送太多, 达到垃圾数量限制 '131048': '[131048] 账户被风控.', // 消息发送太多, 达到垃圾数量限制

@ -2,7 +2,7 @@ import { webSocket } from 'rxjs/webSocket';
import { of, timer, concatMap, EMPTY, takeWhile, concat } from 'rxjs'; import { of, timer, concatMap, EMPTY, takeWhile, concat } from 'rxjs';
import { filter, buffer, map, tap, retryWhen, retry, delay, take, catchError } from 'rxjs/operators'; import { filter, buffer, map, tap, retryWhen, retry, delay, take, catchError } from 'rxjs/operators';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { logWebsocket } from '@/utils/commons'; import { logWebsocket } from '@/utils/indexedDB';
export class RealTimeAPI { export class RealTimeAPI {
constructor(param, onOpenCallback, onCloseCallback, onRetryCallback) { constructor(param, onOpenCallback, onCloseCallback, onRetryCallback) {

@ -46,7 +46,7 @@ const DnDModal = ({ children, open, setOpen, onCancel, onMove, onResize, initial
maskClosable={false} maskClosable={false}
// theme='dark' // theme='dark'
// className={'!border !border-solid !border-indigo-500 rounded !p-2' } // 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' titleBarClassName={`!bg-neutral-100 !rounded !rounded-b-none !border-none !p-3 !font-bold !text-slate-600 ${props.titleClassName}`}
contentClassName='!p-2' contentClassName='!p-2'
footerClassName='!p-2' footerClassName='!p-2'
className={`!rounded-t !rounded-b-none !border !border-solid !shadow-heavy ${props.rootClassName}`} className={`!rounded-t !rounded-b-none !border !border-solid !shadow-heavy ${props.rootClassName}`}

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

@ -1,7 +1,7 @@
import Icon from '@ant-design/icons'; import Icon from '@ant-design/icons';
import ReplyLineSVG from '@/assets/icons/reply-line.svg?react'; import ReplyLineSVG from '@/assets/icons/reply-line.svg?react';
import ReplyAllLineSVG from '@/assets/icons/reply-all-line.svg?react'; import ReplyAllLineSVG from '@/assets/icons/reply-all-fill.svg?react';
import AttachmentLineSVG from '@/assets/icons/attachment-line.svg?react'; import AttachmentLineSVG from '@/assets/icons/attachment-line.svg?react';
import AttachmentFillSVG from '@/assets/icons/attachment-fill.svg?react'; import AttachmentFillSVG from '@/assets/icons/attachment-fill.svg?react';
// import ShareForwardFillSVG from '@/assets/icons/share-forward-fill.svg?react'; // import ShareForwardFillSVG from '@/assets/icons/share-forward-fill.svg?react';
@ -13,6 +13,12 @@ import SendPlaneLineSVG from '@/assets/icons/send-plane-line.svg?react';
import ResendLineSVG from '@/assets/icons/reset-left-line.svg?react'; import ResendLineSVG from '@/assets/icons/reset-left-line.svg?react';
import EditLineSVG from '@/assets/icons/quill-pen-line.svg?react'; import EditLineSVG from '@/assets/icons/quill-pen-line.svg?react';
import MailDownloadLineSVG from '@/assets/icons/mail-download-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} />; export const ReplyIcon = (props) => <Icon component={ReplyLineSVG} {...props} />;
@ -27,6 +33,13 @@ export const SendPlaneLineIcon = (props) => <Icon component={SendPlaneLineSVG} {
export const ResendIcon = (props) => <Icon component={ResendLineSVG} {...props} />; export const ResendIcon = (props) => <Icon component={ResendLineSVG} {...props} />;
export const EditIcon = (props) => <Icon component={EditLineSVG} {...props} />; export const EditIcon = (props) => <Icon component={EditLineSVG} {...props} />;
export const MailDownloadIcon = (props) => <Icon component={MailDownloadLineSVG} {...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 = () => ( const WABSvg = () => (
<svg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' width='16' height='16'> <svg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' width='16' height='16'>

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

@ -0,0 +1,27 @@
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');

@ -0,0 +1,86 @@
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;

@ -0,0 +1,267 @@
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,6 +15,7 @@ import {
$isRangeSelection, $isRangeSelection,
$createParagraphNode, $createParagraphNode,
$getNodeByKey, $getNodeByKey,
$isTextNode,
} from 'lexical'; } from 'lexical';
import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link'; import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { import {
@ -25,12 +26,14 @@ import {
// $wrapNodes, // $wrapNodes,
$isAtNodeEnd, $isAtNodeEnd,
} from '@lexical/selection'; } from '@lexical/selection';
import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils'; import { $findMatchingParent, $getNearestNodeOfType, $getNearestBlockElementAncestorOrThrow, mergeRegister } from '@lexical/utils';
import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, $isListNode, ListNode } from '@lexical/list'; import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, $isListNode, ListNode } from '@lexical/list';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from '@lexical/rich-text'; import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
import { $createCodeNode, $isCodeNode, getDefaultCodeLanguage, getCodeLanguages } from '@lexical/code'; import { $createCodeNode, $isCodeNode, getDefaultCodeLanguage, getCodeLanguages } from '@lexical/code';
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; 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 DropDown, { DropDownItem } from './../ui/DropDown';
import DropdownColorPicker from '../ui/DropdownColorPicker'; import DropdownColorPicker from '../ui/DropdownColorPicker';
import { import {
@ -39,6 +42,7 @@ import {
// InsertImagePayload, // InsertImagePayload,
} from './ImagesPlugin'; } from './ImagesPlugin';
import {InsertInlineImageDialog} from './InlineImagePlugin'; import {InsertInlineImageDialog} from './InlineImagePlugin';
import FormatPainterToolbarButton from './FormatPaint/FormatPainterToolbarButton';
import useModal from './../hooks/useModal'; import useModal from './../hooks/useModal';
@ -70,16 +74,19 @@ const FONT_FAMILY_OPTIONS = [
const FONT_SIZE_OPTIONS = [ const FONT_SIZE_OPTIONS = [
['10px', '10px'], ['10px', '10px'],
['11px', '11px'], // ['11px', '11px'],
['12px', '12px'], ['12px', '12px'],
['13px', '13px'], ['13px', '13px'],
['14px', '14px'], ['14px', '14px'],
['15px', '15px'], // ['15px', '15px'],
['16px', '16px'], ['16px', '16px'],
['17px', '17px'], // ['17px', '17px'],
['18px', '18px'], ['18px', '18px'],
['19px', '19px'], // ['19px', '19px'],
['20px', '20px'], ['20px', '20px'],
['24px', '24px'],
['32px', '32px'],
// ['48px', '48px'],
]; ];
const ELEMENT_FORMAT_OPTIONS = { const ELEMENT_FORMAT_OPTIONS = {
@ -91,6 +98,62 @@ const ELEMENT_FORMAT_OPTIONS = {
start: { icon: 'left-align', iconRTL: 'right-align', name: 'Start Align' }, 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) { function dropDownActiveClass(active) {
if (active) { if (active) {
return 'active dropdown-item-active'; return 'active dropdown-item-active';
@ -492,12 +555,13 @@ function FontDropDown({ editor, value, style, disabled = false }) {
<DropDown <DropDown
disabled={disabled} disabled={disabled}
buttonClassName={'toolbar-item ' + style} buttonClassName={'toolbar-item ' + style}
// buttonLabel={value} buttonLabel={value}
buttonIconClassName={style === 'font-family' ? 'icon block-type font-family' : ''} buttonIconClassName={style === 'font-family' ? 'icon block-type font-family' : ''}
buttonAriaLabel={buttonAriaLabel}> buttonAriaLabel={buttonAriaLabel}>
{(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map(([option, text]) => ( {(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map(([option, text]) => (
<DropDownItem <DropDownItem
className={`item font-m-${option.replace(/\s+/g, '_')} ${dropDownActiveClass(value === option)} ${style === 'font-size' ? 'fontsize-item' : ''}`} 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)} onClick={() => handleClick(option)}
key={option}> key={option}>
<span className='text'>{text}</span> <span className='text'>{text}</span>
@ -605,6 +669,7 @@ export default function ToolbarPlugin() {
const [isStrikethrough, setIsStrikethrough] = useState(false); const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false); const [isCode, setIsCode] = useState(false);
const [fontFamily, setFontFamily] = useState('Arial'); const [fontFamily, setFontFamily] = useState('Arial');
const [fontSize, setFontSize] = useState('16px');
const [fontColor, setFontColor] = useState('#000'); const [fontColor, setFontColor] = useState('#000');
const [bgColor, setBgColor] = useState('#fff'); const [bgColor, setBgColor] = useState('#fff');
const [elementFormat, setElementFormat] = useState('left'); const [elementFormat, setElementFormat] = useState('left');
@ -692,6 +757,9 @@ export default function ToolbarPlugin() {
setFontFamily( setFontFamily(
$getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'), $getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'),
); );
setFontSize(
$getSelectionStyleValueForProperty(selection, 'font-size', '16px'),
);
let matchingParent; let matchingParent;
if ($isLinkNode(parent)) { if ($isLinkNode(parent)) {
// If node is a link, we need to fetch the parent paragraph node to set format // If node is a link, we need to fetch the parent paragraph node to set format
@ -785,6 +853,14 @@ export default function ToolbarPlugin() {
aria-label='Redo'> aria-label='Redo'>
<i className='format redo' /> <i className='format redo' />
</button> </button>
<FormatPainterToolbarButton />
<button type='button'
onClick={() => clearFormatting(activeEditor)}
className='toolbar-item'
title="清除格式"
aria-label='Clear'>
<i className='format clear' />
</button>
<Divider /> <Divider />
{supportedBlockTypes.has(blockType) && ( {supportedBlockTypes.has(blockType) && (
<> <>
@ -814,6 +890,12 @@ export default function ToolbarPlugin() {
value={fontFamily} value={fontFamily}
editor={editor} editor={editor}
/> />
<FontDropDown
disabled={!isEditable}
style={'font-size'}
value={fontSize}
editor={editor}
/>
<Divider /> <Divider />
<button type='button' <button type='button'
onClick={() => { onClick={() => {

@ -80,7 +80,7 @@
.editor-input { .editor-input {
min-height: 150px; min-height: 150px;
resize: none; resize: none;
font-size: 15px; font-size: 16px;
caret-color: rgb(5, 5, 5); caret-color: rgb(5, 5, 5);
position: relative; position: relative;
tab-size: 1; tab-size: 1;
@ -404,6 +404,7 @@ pre::-webkit-scrollbar-thumb {
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
vertical-align: middle; vertical-align: middle;
word-break: keep-all;
} }
.toolbar button.toolbar-item:disabled { .toolbar button.toolbar-item:disabled {
@ -430,7 +431,8 @@ pre::-webkit-scrollbar-thumb {
} }
.toolbar button.toolbar-item.active { .toolbar button.toolbar-item.active {
background-color: rgba(223, 232, 250, 0.3); /* background-color: rgba(223, 232, 250, 0.8); */
background-color: #eef2ff;
} }
.toolbar button.toolbar-item.active i { .toolbar button.toolbar-item.active i {
@ -475,12 +477,12 @@ pre::-webkit-scrollbar-thumb {
.toolbar .toolbar-item .text { .toolbar .toolbar-item .text {
display: flex; display: flex;
line-height: 20px; line-height: 20px;
width: 200px; /* width: 200px; */
vertical-align: middle; vertical-align: middle;
font-size: 14px; font-size: 14px;
color: #777; color: #777;
text-overflow: ellipsis; text-overflow: ellipsis;
width: 70px; width: 3rem;
overflow: hidden; overflow: hidden;
height: 20px; height: 20px;
text-align: left; text-align: left;
@ -604,7 +606,8 @@ i.chevron-down {
background-size: contain; background-size: contain;
} }
button.item.dropdown-item-active { button.item.dropdown-item-active {
background-color: #dfe8fa4d; /* background-color: #dfe8fa4d; */
background-color: #eef2ff;
} }
.dropdown .item:first-child { .dropdown .item:first-child {
@ -761,6 +764,9 @@ i.undo {
i.redo { i.redo {
background-image: url(/images/icons/arrow-clockwise.svg); background-image: url(/images/icons/arrow-clockwise.svg);
} }
i.clear{
background-image: url(/images/icons/eraser-line.svg);
}
.icon.paragraph { .icon.paragraph {
background-image: url(/images/icons/text-paragraph.svg); background-image: url(/images/icons/text-paragraph.svg);
@ -836,6 +842,10 @@ 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") 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 { i.bold {
background-image: url(/images/icons/type-bold.svg); background-image: url(/images/icons/type-bold.svg);
} }

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

@ -0,0 +1,356 @@
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}>
{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,9 +6,10 @@
// export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_144'; // prod: Slave // export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_144'; // prod: Slave
// debug: // debug:
// export const API_HOST = 'http://202.103.68.144:8889/v2'; export const API_HOST = 'http://202.103.68.144:8889/v2';
export const API_HOST_V3 = 'http://202.103.68.144:8889/v3';
// export const WS_URL = 'ws://202.103.68.144:8888'; // export const WS_URL = 'ws://202.103.68.144:8888';
// export const EMAIL_HOST = 'http://202.103.68.231:888/service-mail'; export const EMAIL_HOST_v3 = 'http://202.103.68.144:888/service-mail';
// export const WAI_HOST = 'http://47.83.248.120/api/v1'; // 香港服务器 // 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://47.254.53.81/api/v1'; // 美国服务器
// export const WAI_HOST = 'http://localhost:3031/api/v1'; // 美国服务器 // export const WAI_HOST = 'http://localhost:3031/api/v1'; // 美国服务器
@ -18,14 +19,16 @@ export const EMAIL_ATTA_HOST = 'https://p9axztuwd7x8a7.mycht.cn/attachment'; //
// prod: // prod:
// export const WAI_HOST = 'https://wai-server-qq4qmtq7wc9he4.mycht.cn/api/v1'; // 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 = 'https://p9axztuwd7x8a7.mycht.cn/mail-server/service-mail';
export const API_HOST = 'https://p9axztuwd7x8a7.mycht.cn/whatsapp_server/v2'; // export const API_HOST = 'https://p9axztuwd7x8a7.mycht.cn/whatsapp_server/v2';
export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_server'; // prod: export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_server'; // prod:
export const VONAGE_URL = 'https://p9axztuwd7x8a7.mycht.cn/vonage-server'; // 语音和视频接口: 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 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 DATE_FORMAT = 'YYYY-MM-DD';
export const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; 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_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_AP = 'https://hiana-crm.oss-ap-southeast-1.aliyuncs.com/WAMedia/';
export const OSS_URL = OSS_URL_AP; export const OSS_URL = OSS_URL_AP;
@ -38,3 +41,5 @@ const __BUILD_DATE__ = `__BUILD_DATE__`;
export const BUILD_VERSION = process.env.NODE_ENV === 'production' ? __BUILD_VERSION__ : process.env.NODE_ENV; 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 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';

@ -1,16 +1,19 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { isEmpty } from '@/utils/commons' import { isEmpty, objectMapper, olog, } from '@/utils/commons'
import { getEmailDetailAction, postResendEmailAction, getSalesSignatureAction, getEmailOrderAction } from '@/actions/EmailActions' 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 { App } from 'antd'
import useConversationStore from '@/stores/ConversationStore'; import useConversationStore from '@/stores/ConversationStore';
import { msgStatusRenderMapped } from '@/channel/bubbleMsgUtils'; import { msgStatusRenderMapped } from '@/channel/bubbleMsgUtils';
import { POPUP_FEATURES } from '@/config';
import { internalEventEmitter } from '@/utils/EventEmitterService';
export const useEmailSignature = (opi_sn) => { export const useEmailSignature = (opi_sn) => {
const [signature, setSignature] = useState('') const [signature, setSignature] = useState('')
const getSignature = useCallback(async () => { const getSignature = useCallback(async () => {
if (isEmpty(opi_sn)) { if (isEmpty(Number(opi_sn))) {
return false return false
} }
try { try {
@ -23,89 +26,417 @@ export const useEmailSignature = (opi_sn) => {
useEffect(() => { useEffect(() => {
getSignature() getSignature()
}, [opi_sn]) }, [getSignature])
return { signature, getSignature } return { signature }
} }
/** /**
* 邮件详情
*
* @param mai_sn 邮件编号ID * @param mai_sn 邮件编号ID
* @param data 直接传递, 不重复获取 * @param data 直接传递, 不重复获取
* * 在详情点击`回复`呼出编辑时 * * 在详情点击`回复`呼出编辑时
* @param {number|boolean} oid 订单ID
* - If `number`: 直接传递, 直接获取订单详情
* - If `false`: 不需要获取订单信息
*/ */
export const useEmailDetail = (mai_sn, data) => { export const useEmailDetail = (mai_sn=0, data={}, oid=0, markRead=false) => {
const {notification} = App.useApp() const {notification} = App.useApp()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [mailData, setMailData] = useState({ loading, info: {}, content: '', attachments: [] }) const [mailData, setMailData] = useState({ loading, info: { MAI_COLI_SN: 0 }, content: '', attachments: [], AttachList: [] })
const [coliSN, setColiSN] = useState(''); const [maiSN, setMaiSN] = useState(mai_sn);
const [coliSN, setColiSN] = useState(oid);
const [orderDetail, setOrderDetail] = useState({}); const [orderDetail, setOrderDetail] = useState({});
const [updateMessageItem] = useConversationStore(state => [state.updateMessageItem]); const [refreshTrigger, setRefreshTrigger] = useState(0);
const refresh = useCallback(() => {
setRefreshTrigger(prev => prev + 1);
}, []);
// console.log(maiSN, 'mailSN', mai_sn)
useEffect(() => { // const [updateMessageItem] = useConversationStore(state => [state.updateMessageItem]);
const getEmailDetail = async () => {
if (isEmpty(mai_sn)) { const getEmailDetail = useCallback(async () => {
return false if (isEmpty(Number(maiSN)) && isEmpty(Number(mai_sn))) {
} return false
try { }
setLoading(true)
const data = await getEmailDetailAction({ mai_sn }) if (!isEmpty(data)) {
setMailData(data) setMailData(data)
setColiSN(data.info.MAI_COLI_SN) return false
setLoading(false) }
} catch (err) {
setLoading(false) try {
notification.error({ setLoading(true)
message: "请求失败", const data = await getEmailDetailAction({ mai_sn: Number(mai_sn) || maiSN })
description: err.message || '网络异常', // console.log(data)
placement: "top", setMailData(data)
duration: 3, 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 },
})
} }
} catch (err) {
setLoading(false)
notification.error({
message: '请求失败',
description: err.message || '网络异常',
placement: 'top',
duration: 3,
})
} }
}, [mai_sn, maiSN, refreshTrigger])
if (isEmpty(data)) getEmailDetail()
else setMailData(data)
}, [mai_sn])
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(() => { useEffect(() => {
const getOrderDetail = async () => { getEmailDetail();
if (isEmpty(coliSN)) { }, [getEmailDetail])
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,
});
}
}
useEffect(() => {
getOrderDetail() getOrderDetail()
}, [coliSN]) }, [getOrderDetail])
const postEmailResend = async ({ mai_sn, conversationid: externalid, actionId: actionid, ...body }) => { const postEmailResend = async ({ mai_sn, conversationid: externalid, actionId: actionid, ...body }) => {
if (isEmpty(mai_sn)) { if (isEmpty(Number(mai_sn))) {
return false return false
} }
await postResendEmailAction({ mai_sn, externalid, actionid, token: 0 }) await postResendEmailAction({ mai_sn, externalid, actionid, token: 0 })
// 重发没有状态返回值, 此处前端处理为'待发送', todo: 刷新页面后消息仍为上一个状态'失败'
updateMessageItem({ conversationid: externalid, actionid, id: actionid, status: msgStatusRenderMapped['accepted'] })
} }
return { loading, mailData, orderDetail, postEmailResend } 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}) => { 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>${subject}</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: undefined; width: 900px; background-color: #fcfcfc;">${content}</body></html>`; 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>`;
} }
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: '已处理邮件' },
]

@ -17,7 +17,7 @@ import MobileConversation from '@/views/mobile/Conversation'
import MobileChat from '@/views/mobile/Chat' import MobileChat from '@/views/mobile/Chat'
import CallCenter from '@/views/CallCenter' import CallCenter from '@/views/CallCenter'
import MobileSecondHeader from '@/views/mobile/SecondHeaderWrapper' import MobileSecondHeader from '@/views/mobile/SecondHeaderWrapper'
import CustomerProfile from '@/views/Conversations/Online/order/CustomerProfile' import OrderProfile from '@/components/OrderProfile'
import SnippetList from '@/views/accounts/SnippetList' import SnippetList from '@/views/accounts/SnippetList'
import GeneratePayment from '@/views/accounts/GeneratePayment' import GeneratePayment from '@/views/accounts/GeneratePayment'
@ -31,6 +31,10 @@ import useAuthStore from '@/stores/AuthStore'
import '@/assets/index.css' import '@/assets/index.css'
import CustomerRelation from '@/views/customer_relation/index' 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() useAuthStore.getState().loadUserSession()
@ -60,7 +64,7 @@ const router = createBrowserRouter([
{ {
element: <MobileSecondHeader />, element: <MobileSecondHeader />,
children: [ children: [
{ path: 'm/order', element: <CustomerProfile /> }, { path: 'm/order', element: <OrderProfile /> },
{ path: 'callcenter/call', element: <CallCenter /> }, { path: 'callcenter/call', element: <CallCenter /> },
{ path: 'callcenter/call/:phonenumber', element: <CallCenter /> }, { path: 'callcenter/call/:phonenumber', element: <CallCenter /> },
], ],
@ -86,6 +90,11 @@ const router = createBrowserRouter([
{ path: 'customer_relation/index', element: <CustomerRelation /> }, { 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 />},
], ],
}, },
{ {
@ -135,3 +144,13 @@ createRoot(root).render(
</ThemeContext.Provider> </ThemeContext.Provider>
</React.Suspense>, </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();
});

@ -158,7 +158,7 @@ const useAuthStore = create(devtools((set, get) => ({
loadUserSession: () => { loadUserSession: () => {
let sessionData = window.sessionStorage.getItem('GLOBAL_SALES_LOGIN_USER') let sessionData = window.sessionStorage.getItem('GLOBAL_SALES_LOGIN_USER')
// let 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"}]}' // 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"}]}'
// if (window.location.hostname === '202.103.68.93' && window.location.port === '4173' && isEmpty(sessionData)) { // 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"}]}` // 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"}]}`

@ -1,11 +1,13 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { RealTimeAPI } from '@/channel/realTimeAPI'; import { RealTimeAPI } from '@/channel/realTimeAPI';
import { olog, isEmpty, groupBy, sortArrayByOrder, logWebsocket, pick, sortKeys, omit, sortObjectsByKeysMap } from '@/utils/commons'; import { olog, isEmpty, groupBy, sortArrayByOrder, pick, sortKeys, omit, sortObjectsByKeysMap } from '@/utils/commons';
import { logWebsocket, clean7DaysWebsocketLog } from '@/utils/indexedDB'
import { receivedMsgTypeMapped, handleNotification } from '@/channel/bubbleMsgUtils'; import { receivedMsgTypeMapped, handleNotification } from '@/channel/bubbleMsgUtils';
import { fetchConversationsList, fetchTemplates, fetchConversationsSearch, UNREAD_MARK, fetchTags } from '@/actions/ConversationActions'; import { fetchConversationsList, fetchTemplates, fetchConversationsSearch, UNREAD_MARK, fetchTags } from '@/actions/ConversationActions';
import { devtools } from 'zustand/middleware'; import { devtools } from 'zustand/middleware';
import { WS_URL, DATETIME_FORMAT } from '@/config'; import { WS_URL, DATETIME_FORMAT } from '@/config';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import EmailSlice from './EmailSlice';
const replaceObjectsByKey = (arr1, arr2, key) => { const replaceObjectsByKey = (arr1, arr2, key) => {
const map2 = new Map(arr2.map(ele => [ele[key], ele])); const map2 = new Map(arr2.map(ele => [ele[key], ele]));
@ -177,7 +179,7 @@ const websocketSlice = (set, get) => ({
logWebsocket(data, 'I'); logWebsocket(data, 'I');
// olog('websocket Messages ----', data); // olog('websocket Messages ----', data);
// console.log(data); // console.log(data);
const { updateMessageItem, sentOrReceivedNewMessage, addGlobalNotify, setWai, addToConversationList } = get(); const { updateMessageItem, sentOrReceivedNewMessage, addGlobalNotify, setWai, addToConversationList, updateMailboxCount } = get()
const { errcode, errmsg, result } = data; const { errcode, errmsg, result } = data;
if (!result) { if (!result) {
@ -193,10 +195,18 @@ const websocketSlice = (set, get) => ({
const msgRender = receivedMsgTypeMapped[resultType].contentToRender(msgObj); const msgRender = receivedMsgTypeMapped[resultType].contentToRender(msgObj);
const msgUpdate = receivedMsgTypeMapped[resultType].contentToUpdate(msgObj); const msgUpdate = receivedMsgTypeMapped[resultType].contentToUpdate(msgObj);
// console.log('msgRender msgUpdate', msgRender, msgUpdate); // 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;
}
if ([ if ([
'whatsapp.message.updated', 'message', 'error', 'whatsapp.message.updated', 'message', 'error',
'email.updated', 'wai.message.updated', 'email.updated', 'wai.message.updated',
].includes(resultType)) { ].includes(resultType) && !isEmpty(msgUpdate)) {
updateMessageItem(msgUpdate); updateMessageItem(msgUpdate);
} }
if (!isEmpty(msgRender)) { if (!isEmpty(msgRender)) {
@ -228,7 +238,9 @@ const websocketSlice = (set, get) => ({
// }, 60_000); // }, 60_000);
} }
// 会话表 更新 // 会话表 更新
if (['session.new', 'session.updated'].includes(resultType)) { if (['session.new', 'session.updated'].includes(resultType)
&& result.webhooksource !== 'email'
) {
const sessionList = receivedMsgTypeMapped[resultType].getMsg(result); const sessionList = receivedMsgTypeMapped[resultType].getMsg(result);
addToConversationList(sessionList, 'top'); addToConversationList(sessionList, 'top');
} }
@ -434,136 +446,25 @@ const messageSlice = (set, get) => ({
targetMsgs.push(message); 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时间
// last_message: { ...message, source: message.msg_source },
// })
// } else if (targetIndex === -1) {
// // 当前客户端不存在的会话
// // todo: 设置为当前(在WhatsApp返回号码不一致时)
// const newContact = message.msg_direction === 'outbound' ? message.to : message.from;
// newConversations = [{
// ...conversationRow,
// ...message,
// sn: targetId,
// opi_sn: currentConversation.opi_sn, // todo: coli sn
// last_received_time: message.date,
// unread_msg_count: 0,
// whatsapp_name: newContact, //message?.senderName || message?.sender || '',
// customer_name: newContact, // message?.senderName || message?.sender || '',
// conversation_expiretime: message?.conversation?.expireTime || '', // 保留使用UTC时间
// whatsapp_phone_number: message.type === 'email' ? null : newContact,
// show_default: message?.conversation?.name || newContact || '',
// session_type: message?.conversation?.type === 'group' ? 1 : 0,
// last_message: { ...message, source: message.msg_source },
// channels: {
// "email": message.type === 'email' ? newContact : null,
// "phone_number": message.type === 'email' ? null : newContact,
// "whatsapp_phone_number": message.type === 'email' ? null : newContact,
// },
// }];
// }
// const mergedList = [...newConversations, ...conversationsList]
setFilter({ loadNextPage: true }); setFilter({ loadNextPage: true });
// const { topList, pageList } = sortConversationList(mergedList);
return set({ return set({
// topList,
// pageList,
// conversationsList: mergedList,
activeConversations: { ...activeConversations, [String(targetId)]: targetMsgs }, activeConversations: { ...activeConversations, [String(targetId)]: targetMsgs },
}); });
}, },
sentOrReceivedNewMessage: (targetId, message) => { sentOrReceivedNewMessage: (targetId, message) => {
// msgRender: // msgRender:
// console.log('sentOrReceivedNewMessage', targetId, message) // console.log('sentOrReceivedNewMessage', targetId, message)
const { activeConversations, conversationsList, currentConversation, totalNotify, setFilter } = get(); const { activeConversations, setFilter } = get();
const targetMsgs = activeConversations[String(targetId)] || []; 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 newContact = message.msg_direction === 'outbound' ? message.to : message.from;
// 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, source: message.msg_source },
// }
// : {
// ...conversationRow,
// ...message,
// sn: Number(targetId),
// opi_sn: message.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 : newContact,
// show_default: message?.conversation?.name || message?.senderName || message?.sender || newContact || '',
// session_type: message?.conversation?.type === 'group' ? 1 : 0,
// last_message: { ...message, source: message.msg_source },
// channels: {
// "email": message.type === 'email' ? newContact : null,
// "phone_number": message.type === 'email' ? null : newContact,
// "whatsapp_phone_number": message.type === 'email' ? null : newContact,
// },
// };
// if (targetIndex === -1) {
// conversationsList.unshift(newConversation);
// } else {
// // if (String(targetId)!== '0') {
// 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));
setFilter({ loadNextPage: true }); 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, source: message.msg_source },
}
: {...currentConversation, last_message: message,};
// const { topList, pageList } = sortConversationList(conversationsList);
return set({ return set({
// currentConversation: updatedCurrent,
// topList,
// pageList,
// conversationsList: [...conversationsList],
// totalNotify: totalNotify + (message.sender === 'me' ? 0 : 1),
activeConversations: { ...activeConversations, [String(targetId)]: [...targetMsgs, message] }, activeConversations: { ...activeConversations, [String(targetId)]: [...targetMsgs, message] },
}); });
}, },
}); });
/**
* Email
*/
const emailSlice = (set, get) => ({
emailMsg: { id: -1, conversationid: '', actionId: '', order_opi: '', coli_sn: '', msgOrigin: {} },
setEmailMsg: (emailMsg) => set({ emailMsg }),
detailPopupOpen: false,
setDetailOpen: (v) => set({ detailPopupOpen: v }),
openDetail: () => set(() => ({ detailPopupOpen: true })),
closeDetail: () => set(() => ({ detailPopupOpen: false })),
})
export const useConversationStore = create( export const useConversationStore = create(
devtools((set, get) => ({ devtools((set, get) => ({
...initialConversationState, ...initialConversationState,
@ -576,7 +477,7 @@ export const useConversationStore = create(
...tagsSlice(set, get), ...tagsSlice(set, get),
...filterSlice(set, get), ...filterSlice(set, get),
...globalNotifySlice(set, get), ...globalNotifySlice(set, get),
...emailSlice(set, get), ...EmailSlice(set, get),
...waiSlice(set, get), ...waiSlice(set, get),
// state actions // state actions
@ -585,7 +486,10 @@ export const useConversationStore = create(
// side effects // side effects
fetchInitialData: async ({userId, whatsAppBusiness, ...loginUser}) => { fetchInitialData: async ({userId, whatsAppBusiness, ...loginUser}) => {
const { addToConversationList, setTemplates, setInitial, setTags } = get(); 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 conversationsList = await fetchConversationsList({ opisn: userId }); const conversationsList = await fetchConversationsList({ opisn: userId });
addToConversationList(conversationsList); addToConversationList(conversationsList);

@ -0,0 +1,189 @@
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,11 +12,15 @@ export const useFormStore = create(
setMsgHistorySelectMatch: (msgHistorySelectMatch) => set({ msgHistorySelectMatch }), setMsgHistorySelectMatch: (msgHistorySelectMatch) => set({ msgHistorySelectMatch }),
msgListParams: {}, msgListParams: {},
setMsgListParams: (msgListParams) => set(state => ({ msgListParams: {...state.msgListParams, ...msgListParams} })), setMsgListParams: (msgListParams) => set(state => ({ msgListParams: {...state.msgListParams, ...msgListParams} })),
ImageAlbum: [], ImageAlbum: [],
setImageAlbum: (ImageAlbum) => set({ ImageAlbum }), setImageAlbum: (ImageAlbum) => set({ ImageAlbum }),
ImagePreviewSrc: '', ImagePreviewSrc: '',
setImagePreviewSrc: (ImagePreviewSrc) => set({ ImagePreviewSrc }), setImagePreviewSrc: (ImagePreviewSrc) => set({ ImagePreviewSrc }),
EmailList: [],
setEmailList: (EmailList) => set({ EmailList }),
// 订单跟踪页面 // 订单跟踪页面
orderFollowForm: { orderFollowForm: {
type: 'today', type: 'today',

@ -1,8 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import { devtools } from 'zustand/middleware' import { devtools } from 'zustand/middleware'
import { fetchJSON, postForm } from '@/utils/request' import { fetchJSON, postForm, postJSON } from '@/utils/request'
import { API_HOST, EMAIL_HOST } from '@/config' import { API_HOST, API_HOST_V3, EMAIL_HOST } from '@/config'
import { isNotEmpty, prepareUrl } from '@/utils/commons' import { isNotEmpty, prepareUrl, uniqWith } from '@/utils/commons'
const initialState = { const initialState = {
orderList: [], orderList: [],
@ -11,212 +11,247 @@ const initialState = {
lastQuotation: {}, lastQuotation: {},
quotationList: [], quotationList: [],
otherEmailList: [], otherEmailList: [],
}; }
export const useOrderStore = create(devtools((set, get) => ({ export const useOrderStore = create(
devtools(
...initialState, (set, get) => ({
drawerOpen: false, ...initialState,
drawerOpen: false,
resetOrderStore: () => set(initialState),
resetOrderStore: () => set(initialState),
openDrawer: () => {
set(() => ({ openDrawer: () => {
drawerOpen: true set(() => ({
})) drawerOpen: true,
}, }))
},
closeDrawer: () => {
set(() => ({ closeDrawer: () => {
drawerOpen: false set(() => ({
})) drawerOpen: false,
}, }))
},
fetchOrderList: async (formValues, loginUser) => {
let fetchOrderUrl = `${API_HOST}/getwlorder?opisn=${loginUser.userIdStr}&otype=${formValues.type}` fetchOrderList: async (formValues, loginUser) => {
const params = {}; let fetchOrderUrl = `${API_HOST}/getwlorder?opisn=${loginUser.userIdStr}&otype=${formValues.type}`
const params = {}
if (formValues.type === 'advance') {
fetchOrderUrl = `${API_HOST}/getdvancedwlorder?opisn=${loginUser.userIdStr}`; if (formValues.type === 'advance') {
const { type, ...formParams } = formValues; fetchOrderUrl = `${API_HOST}/getdvancedwlorder?opisn=${loginUser.userIdStr}`
Object.assign(params, formParams) 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) {
fetchOrderDetail: (colisn) => { const _result = json.result.map((order) => {
return fetchJSON(`${API_HOST}/getorderinfo`, { colisn }) return { ...order, key: order.COLI_ID }
.then(json => { })
if (json.errcode === 0 && json.result.length > 0) { const _result_unique = uniqWith(_result, (a, b) => a.COLI_SN === b.COLI_SN)
const orderResult = json.result[0] set(() => ({
set(() => ({ orderList: _result_unique,
orderDetail: {...orderResult, coli_sn: colisn }, }))
customerDetail: orderResult.contact.length > 0 ? orderResult.contact[0] : {}, } else {
lastQuotation: orderResult.quotes.length > 0 ? orderResult.quotes[0] : {}, throw new Error(json?.errmsg + ': ' + json.errcode)
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) },
}
}) fetchOrderDetail: (colisn) => {
return fetchJSON(`${API_HOST}/getorderinfo`, { colisn }).then((json) => {
}, if (json.errcode === 0 && json.result.length > 0) {
const orderResult = json.result[0]
appendOrderComment: async (opi_sn, coli_sn, comment) => { set(() => ({
const { fetchOrderDetail } = get() orderDetail: { ...orderResult, coli_sn: colisn },
const formData = new FormData() customerDetail: orderResult.contact.length > 0 ? orderResult.contact[0] : {},
formData.append('opi_sn', opi_sn) // lastQuotation: orderResult.quotes.length > 0 ? orderResult.quotes[0] : {},
formData.append('coli_sn', coli_sn) // quotationList: orderResult.quotes,
formData.append('remark', comment) }))
return {
return postForm(`${API_HOST}/remark_order`, formData) orderDetail: { ...orderResult, coli_sn: colisn },
.then(json => { customerDetail: orderResult.contact.length > 0 ? orderResult.contact[0] : {},
if (json.errcode === 0) { // lastQuotation: orderResult.quotes.length > 0 ? orderResult.quotes[0] : {},
return fetchOrderDetail(coli_sn) // quotationList: orderResult.quotes,
} 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)
}
})
},
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)
} }
} 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) => {
Promise.all(orderPromiseList).then((values) => { console.log(values)
console.log(values); })
}) },
},
fetchOtherEmail: (coli_sn) => {
fetchOtherEmail: (coli_sn) => { return fetchJSON(`${EMAIL_HOST}/email_supplier`, { coli_sn }).then((json) => {
return fetchJSON(`${EMAIL_HOST}/email_supplier`, { coli_sn }) if (json.errcode === 0) {
.then(json => { set(() => ({
if (json.errcode === 0) { otherEmailList: json.result.MailInfo ?? [],
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,
},
}))
}
})
},
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) => {
if (propName === 'orderlabel') {
set((state) => ({
orderDetail: {
...state.orderDetail,
tags: value,
},
})) }))
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
} }
})
},
setOrderPropValue: async (colisn, propName, value) => { if (propName === 'orderstatus') {
set((state) => ({
if (propName === 'orderlabel') { orderDetail: {
set((state) => ({ ...state.orderDetail,
orderDetail: { states: value,
...state.orderDetail, },
tags: 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)
} }
})
},
}), { name: 'orderStore' })) 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' },
),
)
export const OrderLabelDefaultOptions = [ export const OrderLabelDefaultOptions = [
{ value: 240003, label: '重点', emoji: '❣️' }, { value: 240003, label: '重点', emoji: '❣️' },
{ value: 240002, label: '次重点', emoji: '❗' }, { value: 240002, label: '次重点', emoji: '❗' },
{ value: 240001, label: '一般', emoji: '' } { value: 240001, label: '一般', emoji: '' },
] ]
export const OrderLabelDefaultOptionsMapped = OrderLabelDefaultOptions.reduce((acc, cur) => { export const OrderLabelDefaultOptionsMapped = OrderLabelDefaultOptions.reduce((acc, cur) => {
return { ...acc, [String(cur.value)]: cur } return { ...acc, [String(cur.value)]: cur }
}, {}) ; }, {})
export const OrderStatusDefaultOptions = [ export const OrderStatusDefaultOptions = [
{ value: 1, label: '新订单', emoji: '' }, { value: 1, label: '新订单', emoji: '' },
@ -226,7 +261,7 @@ export const OrderStatusDefaultOptions = [
{ value: 5, label: '成行', emoji: '💰' }, { value: 5, label: '成行', emoji: '💰' },
{ value: 6, label: '丢失', emoji: '🎈' }, { value: 6, label: '丢失', emoji: '🎈' },
{ value: 7, label: '取消', emoji: '🚫' }, { value: 7, label: '取消', emoji: '🚫' },
{ value: 8, label: '未报价', emoji: '' } { value: 8, label: '未报价', emoji: '' },
] ]
export const OrderStatusDefaultOptionsMapped = OrderStatusDefaultOptions.reduce((acc, cur) => { export const OrderStatusDefaultOptionsMapped = OrderStatusDefaultOptions.reduce((acc, cur) => {
return { ...acc, [String(cur.value)]: cur } return { ...acc, [String(cur.value)]: cur }
@ -238,25 +273,27 @@ export const OrderStatusDefaultOptionsMapped = OrderStatusDefaultOptions.reduce(
export const RemindStateDefaultOptions = [ export const RemindStateDefaultOptions = [
{ value: '1', label: '一催' }, { value: '1', label: '一催' },
{ value: '2', label: '二催' }, { value: '2', label: '二催' },
{ value: '3', label: '三催' } { value: '3', label: '三催' },
] ]
/** /**
* @useage 订单信息: 标记状态 * @useage 订单信息: 标记状态
*/ */
export const remindStatusOptions = [ export const remindStatusOptions = [
{ value: '1', label: '已发一催' }, { value: 1, label: '已发一催' },
{ value: '2', label: '已发二催' }, { value: 2, label: '已发二催' },
{ value: '3', label: '已发三催' }, { value: 3, label: '已发三催' },
{ value: 'important', label: '重点团' }, { value: 'important', label: '重点团' },
{ value: 'sendsurvey', label: '已发 travel advisor survey' }, { 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 } * @param {Object} params { coli_sn, remindstate }
*/ */
export const fetchSetRemindStateAction = async (params) => { export const fetchSetRemindStateAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/SetRemindState`, params); const { errcode, result } = await fetchJSON(`${API_HOST}/SetRemindState`, params)
return errcode === 0 ? result : {}; return errcode === 0 ? result : {}
}; }

@ -0,0 +1,20 @@
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();

@ -226,6 +226,15 @@ export function omit(object, keysToOmit) {
return Object.fromEntries(Object.entries(object).filter(([key]) => !keysToOmit.includes(key))); 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;
};
/** /**
* 深拷贝 * 深拷贝
*/ */
@ -592,69 +601,77 @@ export const TagColorStyle = (tag, outerStyle = false) => {
return { color: `${color}`, ...outerStyleObj }; 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.
*/ */
export const logWebsocket = (message, direction) => { function createTreeNode(key, name, parent = null, keyMap={}, _raw={}) {
var open = indexedDB.open('LogWebsocketData', 2) return {
open.onupgradeneeded = function () { key: key,
var db = open.result title: name,
// 数据库是否存在 parent: parent,
if (!db.objectStoreNames.contains('LogStore')) { icon: _raw?.icon,
var store = db.createObjectStore('LogStore', { keyPath: 'id', autoIncrement: true }) iconIndex: _raw?.[keyMap.iconIndex],
} _raw: _raw,
} children: [],
open.onsuccess = function () { parentTitle: '',
var db = open.result parentIconIndex: '',
var tx = db.transaction('LogStore', 'readwrite') };
var store = tx.objectStore('LogStore') }
store.put({ direction, message, date: new Date().toLocaleString() }) /**
tx.oncomplete = function () { * Builds a tree structure from a flat list of nodes.
db.close() * @returns {Array<object>} An array of root tree nodes.
} */
export const buildTree = (list, keyMap={ rootKeys: [], ignoreKeys: [] }) => {
if (!list || list.length === 0) {
return []
} }
};
export const readWebsocketLog = () => { const nodeMap = new Map()
return new Promise((resolve, reject) => { const treeRoots = []
let openRequest = indexedDB.open('LogWebsocketData')
openRequest.onerror = function (e) { list.forEach((item) => {
reject('Error opening database.') const node = createTreeNode(item[keyMap.key], item[keyMap.name], item[keyMap.parent], keyMap, item)
} nodeMap.set(item[keyMap.key], node)
openRequest.onsuccess = function (e) { })
let db = e.target.result
// 数据库是否存在 list.forEach((item) => {
if (!db.objectStoreNames.contains('LogStore')) { const node = nodeMap.get(item[keyMap.key])
resolve('Database does not exist.') if (keyMap.rootKeys.includes(item[keyMap.parent]) || item[keyMap.parent] === null || item[keyMap.parent] === undefined) {
return // This is a root node
} treeRoots.push(node)
let transaction = db.transaction('LogStore') } else {
let store = transaction.objectStore('LogStore') const parentNode = nodeMap.get(item[keyMap.parent])
let request = store.getAll() if (keyMap.ignoreKeys.includes(item[keyMap.parent])) {
request.onerror = function (e) { const grandParentNode = nodeMap.get(parentNode.parent);
reject('Error getting all records.') 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])) {
//
} }
request.onsuccess = function (e) { else if (parentNode) {
let data = e.target.result node.parentTitle = parentNode.title;
sessionStorage.setItem('websocketLogData', JSON.stringify(data)) node.parentIconIndex = parentNode.iconIndex;
console.log(JSON.stringify(data)) parentNode.children.push(node)
resolve(data) } 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)
} }
} }
}) })
};
export const clearWebsocketLog = () => { return treeRoots
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) {}
}
} }

@ -0,0 +1,570 @@
import { isEmpty } from './commons';
/**
*
*/
const INDEXED_DB_VERSION = 4;
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('readIndexDB 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
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)) {
resolve('Database does not exist.')
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)) {
resolve('Database does not exist.')
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') {
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 () {
var db = openRequest.result
storeNames.forEach(storeName => {
// 数据库是否存在
if (!db.objectStoreNames.contains(storeName)) {
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)
}
}
}
}
openRequest.onerror = function (e) {
reject('Error opening database:'+database, e)
}
})
}
}
export const clean7DaysWebsocketLog = cleanOldData('LogWebsocketData', ['LogStore']);
export const clean7DaysMailboxLog = cleanOldData('mailbox');
/**
* 缓存清除策略: 清理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,4 +1,5 @@
import { loadScript, readWebsocketLog } from '@/utils/commons' import { loadScript } from '@/utils/commons'
import { readWebsocketLog } from '@/utils/indexedDB'
import { BUILD_VERSION, BUILD_DATE } from '@/config' import { BUILD_VERSION, BUILD_DATE } from '@/config'
export const loadPageSpy = (title) => { export const loadPageSpy = (title) => {

@ -11,11 +11,11 @@ import {
FloatButton, FloatButton,
theme, theme,
} from 'antd' } from 'antd'
import { BugOutlined, CustomerServiceOutlined } from '@ant-design/icons' import { AudioOutlined, AudioTwoTone, BugOutlined, CustomerServiceOutlined } from '@ant-design/icons'
import zhLocale from 'antd/locale/zh_CN' import zhLocale from 'antd/locale/zh_CN'
import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-cn'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Outlet, useHref, useNavigate } from 'react-router-dom' import { Link, NavLink, Outlet, useHref, useNavigate } from 'react-router-dom'
import { appendRequestHeader } from '@/utils/request' import { appendRequestHeader } from '@/utils/request'
import { loadPageSpy } from '@/utils/pagespy' import { loadPageSpy } from '@/utils/pagespy'
@ -24,8 +24,10 @@ import '@/assets/App.css'
import 'react-chat-elements/dist/main.css' import 'react-chat-elements/dist/main.css'
import EmailFetch from './Conversations/Online/Components/EmailFetch' import EmailFetch from './Conversations/Online/Components/EmailFetch'
import FetchEmailWorker from './../workers/fetchEmailWorker?worker&url' import FetchEmailWorker from './../workers/fetchEmailWorker?worker&url'
import { clearWebsocketLog, readWebsocketLog } from '@/utils/commons' import { readWebsocketLog } from '@/utils/indexedDB'
import { useGlobalNotify } from '@/hooks/useGlobalNotify' 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 fetchEmailWorkerURL = new URL('/src/workers/fetchEmailWorker.js', import.meta.url);
const fetchEmailWorker = new Worker(FetchEmailWorker, { type: 'module' }); const fetchEmailWorker = new Worker(FetchEmailWorker, { type: 'module' });
@ -101,7 +103,7 @@ function AuthApp() {
endTime: now, endTime: now,
}) })
messageApi.info('Success') messageApi.info('Success')
clearWebsocketLog() // clearWebsocketLog()
sendNotify() sendNotify()
} catch (error) { } catch (error) {
messageApi.error('Failure') messageApi.error('Failure')
@ -149,10 +151,12 @@ function AuthApp() {
<ErrorBoundary> <ErrorBoundary>
<FloatButton.Group <FloatButton.Group
shape='square' shape='square'
placement={'left'}
trigger={floatTrigger} trigger={floatTrigger}
style={{ style={{
insetInlineEnd: floatButtonLineEnd, insetInlineEnd: floatButtonLineEnd,
insetBlockEnd: floatButtonLineEnd, insetBlockEnd: floatButtonLineEnd,
flexDirection: 'row',
}} }}
icon={<CustomerServiceOutlined />} icon={<CustomerServiceOutlined />}
> >
@ -185,6 +189,8 @@ function AuthApp() {
</button> </button>
</form> </form>
</dialog> </dialog>
<GeneratePaymentDrawer />
<GenerateAutoDocDrawer />
</ErrorBoundary> </ErrorBoundary>
</AntApp> </AntApp>
</ConfigProvider> </ConfigProvider>

@ -9,12 +9,14 @@ import ImageAlbumPreview from './Conversations/History/ImageAlumPreview';
import { flush, pick } from '@/utils/commons'; import { flush, pick } from '@/utils/commons';
import { fetchConversationsSearch, CONVERSATION_PAGE_SIZE } from '@/actions/ConversationActions'; import { fetchConversationsSearch, CONVERSATION_PAGE_SIZE } from '@/actions/ConversationActions';
import EmailDetail from './Conversations/Online/Components/EmailDetail'; import EmailDetail from './Conversations/Online/Components/EmailDetail';
import SupplierEmailDrawer from './Conversations/Online/Components/EmailListDrawer';
const { Sider, Content } = Layout; const { Sider, Content } = Layout;
const Index = (props) => { const Index = (props) => {
const [formValues, setFormValues] = useFormStore((state) => [state.chatHistoryForm, state.setChatHistoryForm]); const [formValues, setFormValues] = useFormStore((state) => [state.chatHistoryForm, state.setChatHistoryForm]);
const [selectedConversation, setSelectedConversation] = useFormStore((state) => [state.chatHistorySelectChat, state.setChatHistorySelectChat]); const [selectedConversation, setSelectedConversation] = useFormStore((state) => [state.chatHistorySelectChat, state.setChatHistorySelectChat]);
const [EmailList, ] = useFormStore((state) => [state.EmailList, ]);
const [conversationsListLoading, setConversationsListLoading] = useState(false); const [conversationsListLoading, setConversationsListLoading] = useState(false);
const [conversationsList, setConversationsList] = useState([]); const [conversationsList, setConversationsList] = useState([]);
@ -49,9 +51,15 @@ const Index = (props) => {
const [openEmailDetail, setOpenEmailDetail] = useState(false); const [openEmailDetail, setOpenEmailDetail] = useState(false);
const [emailDetail, setEmailDetail] = useState({}); const [emailDetail, setEmailDetail] = useState({});
const [initialPosition, setInitialPosition] = useState({})
const [initialSize, setInitialSize] = useState({})
const [emailItem, setEmailItem] = useState({});
const onOpenEmail = (emailMsg) => { const onOpenEmail = (emailMsg) => {
setOpenEmailDetail(true); // setOpenEmailDetail(true);
setEmailDetail({...emailMsg, order_opi: Number(selectedConversation?.opi_sn || 0)}); // 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 ( return (
@ -68,7 +76,8 @@ const Index = (props) => {
<MessagesList onOpenEmail={onOpenEmail} /> <MessagesList onOpenEmail={onOpenEmail} />
</Flex> </Flex>
<ImageAlbumPreview /> <ImageAlbumPreview />
<EmailDetail open={openEmailDetail} setOpen={setOpenEmailDetail} emailMsg={emailDetail} key={`history-email-detail-${emailDetail.id}`} disabled /> <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> </Content>
</Layout> </Layout>
</> </>

@ -1,17 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Layout, Spin, Button } from 'antd'; import { Layout, Spin, Button } from 'antd';
import { RightCircleOutlined, RightOutlined, ReloadOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'; import { RightCircleOutlined, RightOutlined, ReloadOutlined, MenuFoldOutlined, MenuUnfoldOutlined, LeftOutlined } from '@ant-design/icons';
// import { useParams, useNavigate } from 'react-router-dom'; // import { useParams, useNavigate } from 'react-router-dom';
import MessagesHeader from './Conversations/Online/MessagesHeader'; import MessagesHeader from './Conversations/Online/MessagesHeader';
import MessagesWrapper from './Conversations/Online/MessagesWrapper'; import MessagesWrapper from './Conversations/Online/MessagesWrapper';
import InputComposer from './Conversations/Online/Input/InputComposer';
import ConversationsList from './Conversations/Online/ConversationsList'; import ConversationsList from './Conversations/Online/ConversationsList';
import CustomerProfile from './Conversations/Online/order/CustomerProfile'; import OrderProfile from '@/components/OrderProfile'
// import { useAuthContext } from '@/stores/AuthContext';
// import useConversationStore from '@/stores/ConversationStore';
import ReplyWrapper from './Conversations/Online/ReplyWrapper'; import ReplyWrapper from './Conversations/Online/ReplyWrapper';
import useConversationStore from '@/stores/ConversationStore';
import { useShallow } from 'zustand/react/shallow';
import './Conversations/Conversations.css'; import './Conversations/Conversations.css';
import EmailEditorPopup from './Conversations/Online/Input/EmailEditorPopup';
const { Sider, Content, Header, Footer } = Layout; const { Sider, Content, Header, Footer } = Layout;
@ -21,7 +21,10 @@ const { Sider, Content, Header, Footer } = Layout;
const ChatWindow = () => { const ChatWindow = () => {
const [collapsedLeft, setCollapsedLeft] = useState(false); const [collapsedLeft, setCollapsedLeft] = useState(false);
const [collapsedRight, setCollapsedRight] = useState(false); const [collapsedRight, setCollapsedRight] = useState(true);
const currentOrder = useConversationStore(useShallow(state => state.currentConversation?.coli_sn || ""));
return ( return (
<> <>
@ -37,7 +40,7 @@ const ChatWindow = () => {
collapsed={false} collapsed={false}
onBreakpoint={(broken) => { onBreakpoint={(broken) => {
// setCollapsedLeft(broken) // setCollapsedLeft(broken)
setCollapsedRight(broken) // setCollapsedRight(broken)
}} }}
trigger={null}> trigger={null}>
<ConversationsList /> <ConversationsList />
@ -49,7 +52,7 @@ const ChatWindow = () => {
{/* <Button type='text' icon={collapsedLeft ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} onClick={() => setCollapsedLeft(!collapsedLeft)} className=' rounded-none rounded-l' /> */} {/* <Button type='text' icon={collapsedLeft ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} onClick={() => setCollapsedLeft(!collapsedLeft)} className=' rounded-none rounded-l' /> */}
<MessagesHeader /> <MessagesHeader />
{/* <Button type='text' icon={<ReloadOutlined />} onClick={() => setCollapsedRight(!collapsedRight)} className='' title='最新消息记录' /> */} {/* <Button type='text' icon={<ReloadOutlined />} onClick={() => setCollapsedRight(!collapsedRight)} className='' title='最新消息记录' /> */}
<Button type='text' icon={collapsedRight ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />} onClick={() => setCollapsedRight(!collapsedRight)} className=' rounded-none rounded-r' /> <Button type='text' icon={collapsedRight ? <RightOutlined /> : <LeftOutlined />} onClick={() => setCollapsedRight(!collapsedRight)} className=' rounded-none rounded-r' />
</Header> </Header>
<Content className="flex-grow bg-whatsapp-bg relative" > <Content className="flex-grow bg-whatsapp-bg relative" >
<MessagesWrapper /> <MessagesWrapper />
@ -72,9 +75,10 @@ const ChatWindow = () => {
collapsedWidth={0} collapsedWidth={0}
trigger={null} trigger={null}
collapsed={collapsedRight}> collapsed={collapsedRight}>
<CustomerProfile /> <OrderProfile coliSN={currentOrder} />
</Sider> </Sider>
</Layout> </Layout>
<EmailEditorPopup key='email-editor-online' />
</> </>
); );
}; };

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

@ -1,14 +1,15 @@
import { useRef, useEffect, useState, forwardRef, memo } from 'react'; import { useRef, useEffect, useState, forwardRef, memo } from 'react';
import { App, Flex, List, Button, } from 'antd'; import { App, Flex, List, Button, Image } from 'antd';
import { LoadingOutlined } from '@ant-design/icons'; import { LoadingOutlined } from '@ant-design/icons';
import { MessageBox } from 'react-chat-elements'; import { MessageBox } from 'react-chat-elements';
import { MESSAGE_PAGE_SIZE, fetchMessagesHistory } from '@/actions/ConversationActions'; import { MESSAGE_PAGE_SIZE, fetchMessagesHistory } from '@/actions/ConversationActions';
import useFormStore from '@/stores/FormStore'; import useFormStore from '@/stores/FormStore';
import { isEmpty, stringToColour, groupBy, isNotEmpty } from '@/utils/commons'; import { isEmpty, stringToColour, groupBy, isNotEmpty, TagColorStyle } from '@/utils/commons';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import MergeConversationTo from './MergeConversationTo'; import MergeConversationTo from './MergeConversationTo';
import BubbleIM from '../Online/Components/BubbleIM'; import BubbleIM from '../Online/Components/BubbleIM';
import BubbleEmail from '../Online/Components/BubbleEmail'; import BubbleEmail from '../Online/Components/BubbleEmail';
import { ERROR_IMG, POPUP_FEATURES } from '@/config';
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 20; const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 20;
const MessagesList = ({ ...listProps }) => { const MessagesList = ({ ...listProps }) => {
@ -19,6 +20,7 @@ const MessagesList = ({ ...listProps }) => {
const [paramsForMsgList, setParamsForMsgList] = useFormStore((state) => [state.msgListParams, state.setMsgListParams]); const [paramsForMsgList, setParamsForMsgList] = useFormStore((state) => [state.msgListParams, state.setMsgListParams]);
const [selectMatch, setSelectedMatch] = useFormStore((state) => [state.msgHistorySelectMatch, state.setMsgHistorySelectMatch]); const [selectMatch, setSelectedMatch] = useFormStore((state) => [state.msgHistorySelectMatch, state.setMsgHistorySelectMatch]);
const [setImageAlbumList, setImagePreviewSrc] = useFormStore(useShallow((state) => [state.setImageAlbum, state.setImagePreviewSrc])); const [setImageAlbumList, setImagePreviewSrc] = useFormStore(useShallow((state) => [state.setImageAlbum, state.setImagePreviewSrc]));
const [ setEmailList] = useFormStore(useShallow((state) => [ state.setEmailList]));
const [chatItemMessages, setChatItemMessages] = useState([]); const [chatItemMessages, setChatItemMessages] = useState([]);
const [messageListPreLoading, setMessageListPreLoading] = useState(false); const [messageListPreLoading, setMessageListPreLoading] = useState(false);
@ -109,6 +111,8 @@ const MessagesList = ({ ...listProps }) => {
// setParamsForMsgList({ pretime: chatItemMessages[0].msgtime, lasttime: chatItemMessages[chatItemMessages.length - 1].msgtime }); // setParamsForMsgList({ pretime: chatItemMessages[0].msgtime, lasttime: chatItemMessages[chatItemMessages.length - 1].msgtime });
const album = chatItemMessages.filter((ele) => ele.whatsapp_msg_type === 'image').map((ele) => ele.data.uri); const album = chatItemMessages.filter((ele) => ele.whatsapp_msg_type === 'image').map((ele) => ele.data.uri);
setImageAlbumList(album); 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 () => {}; return () => {};
}, [chatItemMessages]); }, [chatItemMessages]);
@ -173,7 +177,7 @@ const MessagesList = ({ ...listProps }) => {
{headerObj ? ( {headerObj ? (
<div className='text-neutral-500 text-center'> <div className='text-neutral-500 text-center'>
{'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>} {'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>}
{'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <img src={headerObj.parameters[0].image.link} height={100}></img>} {'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <Image src={headerObj.parameters[0].image.link} height={100} fallback={ERROR_IMG}></Image>}
{['document', 'video'].includes((headerObj?.parameters?.[0]?.type || '').toLowerCase()) && ( {['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'> <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;] [&nbsp;{headerObj.parameters[0].type}&nbsp;]
@ -211,7 +215,7 @@ const MessagesList = ({ ...listProps }) => {
return false; return false;
case 'document': case 'document':
window.open(msg.data.link || msg.data.uri, '_blank', 'noopener,noreferrer'); window.open(msg.data.link || msg.data.uri, msg.data.uri, POPUP_FEATURES);
return false; return false;
default: default:
@ -285,7 +289,7 @@ const MessagesList = ({ ...listProps }) => {
<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 ' style={{backgroundColor: 'unset'}}>
<span <span
className={`p-1 rounded-b ${message.msg_direction === 'outbound' ? 'text-white' : ''} `} className={`p-1 rounded-b ${message.msg_direction === 'outbound' ? 'text-white' : ''} `}
style={{ backgroundColor: message.msg_direction === 'outbound' ? stringToColour(message.senderName) : 'unset' }}> style={{ backgroundColor: message.msg_direction === 'outbound' ? stringToColour(message.senderName) : 'unset', ...TagColorStyle(message.senderName, true) }}>
{message.msg_direction === 'outbound' ? selectedConversation.OPI_Name : message.senderName} {message.msg_direction === 'outbound' ? selectedConversation.OPI_Name : message.senderName}
</span> </span>
<span>{message.dateString || message.localDate}</span> <span>{message.dateString || message.localDate}</span>

@ -40,13 +40,13 @@ const SearchForm = memo(function ({ initialValues, onSubmit, onReset }) {
<Input placeholder='关键词' allowClear /> <Input placeholder='关键词' allowClear />
</Form.Item> </Form.Item>
<Form.Item label='消息渠道' name='mchannel' className='w-52'> <Form.Item label='消息渠道' name='mchannel' className='w-52'>
<Select mode='multiple' maxTagCount={0} allowClear options={[ <Select maxTagCount={0} allowClear options={[
{ key: 'waba', label: 'WA商业号', value: 'waba' }, { key: 'WABA', label: 'WA商业号', value: 'WABA' },
{ key: 'email', label: '邮件', value: 'email' }, { key: 'email', label: '邮件', value: 'email' },
{ key: 'whatsapp', label: 'WhatsApp', value: 'whatsapp' }, { key: 'wai', label: 'WhatsApp', value: 'wai' },
]} className='w-8' /> ]} className='w-8' />
</Form.Item> </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={[ <Select mode='multiple' maxTagCount={0} allowClear options={[
{ key: 'image', label: '图片', value: 'image' }, { key: 'image', label: '图片', value: 'image' },
{ key: 'video', label: '视频', value: 'video' }, { key: 'video', label: '视频', value: 'video' },
@ -54,10 +54,10 @@ const SearchForm = memo(function ({ initialValues, onSubmit, onReset }) {
{ key: 'file', label: '文件', value: 'file' }, { key: 'file', label: '文件', value: 'file' },
{ key: 'email', label: '邮件', value: 'email' }, { key: 'email', label: '邮件', value: 'email' },
]} className='w-8' /> ]} className='w-8' />
</Form.Item> </Form.Item> */}
<Form.Item label='日期' name='msgDateRange'> <Form.Item label='日期' name='msgDateRange'>
<RangePicker format={'YYYY-MM-DD'} /> <RangePicker format={'YYYY-MM-DD'} />
</Form.Item> </Form.Item>
</Flex> </Flex>
<div style={{ flex: '0 1 64px' }} className='flex justify-between'> <div style={{ flex: '0 1 64px' }} className='flex justify-between'>

@ -56,11 +56,11 @@ const BubbleEmail = ({ onOpenEditor, onOpenEmail, ...message }) => {
<> <>
<b>From: </b> <b>From: </b>
<span> <span>
{/* {message?.emailOrigin?.fromName}&nbsp;&lt;{message?.emailOrigin.fromEmail}&gt; */}
{message.msgOrigin?.from} {message.msgOrigin?.from}
</span> </span>
</> : <><b>To: </b>{message.msgOrigin?.to}</> </> : <><b>To: </b>{message.msgOrigin?.to}</>
} }
{/* <b>Subject: </b>{message.msgOrigin.email.subject} */}
</span> </span>
</> </>
} }

@ -1,5 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import { App, Button } from 'antd'; import { App, Button, Image } from 'antd';
import { ExportOutlined, CopyOutlined, PhoneOutlined } from '@ant-design/icons'; import { ExportOutlined, CopyOutlined, PhoneOutlined } from '@ant-design/icons';
import { MessageBox } from 'react-chat-elements'; import { MessageBox } from 'react-chat-elements';
import { groupBy, isEmpty, TagColorStyle } from '@/utils/commons'; import { groupBy, isEmpty, TagColorStyle } from '@/utils/commons';
@ -7,6 +7,7 @@ import useConversationStore from '@/stores/ConversationStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { ReplyIcon } from '@/components/Icons'; import { ReplyIcon } from '@/components/Icons';
import ChannelLogo from './ChannelLogo'; import ChannelLogo from './ChannelLogo';
import { ERROR_IMG } from '@/config';
const outboundStyle = { const outboundStyle = {
'waba': { color: '#ccd4ae' }, 'waba': { color: '#ccd4ae' },
@ -54,7 +55,7 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
{headerObj ? ( {headerObj ? (
<div className='text-neutral-500 text-center'> <div className='text-neutral-500 text-center'>
{'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>} {'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>}
{'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <img src={headerObj.parameters[0].image.link} height={100}></img>} {'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <Image src={headerObj.parameters[0].image.link} height={100} fallback={ERROR_IMG}></Image>}
{['document', 'video'].includes((headerObj?.parameters?.[0]?.type || '').toLowerCase()) && ( {['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'> <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;] [&nbsp;{headerObj.parameters[0].type}&nbsp;]

@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Button, Tag, Radio, Popover, Form } from 'antd'; import { Button, Tag, Radio, Popover, Form, Space, Tooltip } from 'antd';
import { isEmpty, objectMapper, TagColorStyle } from '@/utils/commons'; import { isEmpty, objectMapper, TagColorStyle } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore'; import useConversationStore from '@/stores/ConversationStore';
import { OrderLabelDefaultOptions } from '@/stores/OrderStore'; import { OrderLabelDefaultOptions } from '@/stores/OrderStore';
import { FilterIcon } from '@/components/Icons'; import { FilterOutlined, FilterTwoTone } from '@ant-design/icons';
const otypes = [ const otypes = [
{ label: 'All', value: '', labelValue: '' }, { label: 'All', value: '', labelValue: '' },
@ -117,7 +117,7 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
return ( return (
<> <>
<div className='my-1 flex justify-between items-center '> <div className='my-1 flex justify-between items-center '>
<Radio.Group {/* <Radio.Group
optionType={'button'} optionType={'button'}
buttonStyle='solid' buttonStyle='solid'
size='small' size='small'
@ -126,7 +126,20 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
onChange={(e) => { onChange={(e) => {
setFilterOtype(e.target.value) 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 <Popover
destroyTooltipOnHide destroyTooltipOnHide
placement='bottomLeft' placement='bottomLeft'
@ -152,14 +165,14 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
initialValues={{}} initialValues={{}}
onFinish={onFinish} onFinish={onFinish}
className='*:mb-2'> className='*:mb-2'>
<Form.Item label='订单'> {/* <Form.Item label=''>
<Tag <Tag
key={selectedOType} key={selectedOType}
closeIcon={selectedOType !== ''} closeIcon={selectedOType !== ''}
onClose={() => setFilterOtype('')}> onClose={() => setFilterOtype('')}>
{otypesMapped[selectedOType]?.label || 'All'} {otypesMapped[selectedOType]?.label || 'All'}
</Tag> </Tag>
</Form.Item> </Form.Item> */}
<Form.Item name={'tags'} label='标签' className='*.div:gap-1'> <Form.Item name={'tags'} label='标签' className='*.div:gap-1'>
{tags.map((tag, ti) => ( {tags.map((tag, ti) => (
<Tag.CheckableTag <Tag.CheckableTag
@ -177,29 +190,27 @@ const ChatListFilter = ({ onFilterChange, activeList, ...props }) => {
</Form.Item> </Form.Item>
<Form.Item noStyle className='flex justify-center mb-0'> <Form.Item noStyle className='flex justify-center mb-0'>
<Button.Group> <Space.Compact>
<Button onClick={onReset} type='primary' ghost> <Button onClick={onReset} type='primary' ghost>
重置 重置
</Button> </Button>
{/* <Button htmlType='submit' type='primary'> {/* <Button htmlType='submit' type='primary'>
确定 确定
</Button> */} </Button> */}
</Button.Group> </Space.Compact>
</Form.Item> </Form.Item>
</Form> </Form>
</> </>
}> }>
<Button <Tooltip title='更多筛选' >
icon={ <Button
<FilterIcon icon={
className={ isEmpty(selectedTags) ? <FilterOutlined className='text-neutral-500' /> : <FilterTwoTone />
isEmpty(selectedTags) ? 'text-neutral-500' : 'text-blue-500' }
} type='text'
/> size='middle'
} />
type='text' </Tooltip>
size='middle'
/>
</Popover> </Popover>
</div> </div>
</> </>

@ -230,7 +230,7 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
{ label: '编辑联系人', key: 'edit0' }, { label: '编辑联系人', key: 'edit0' },
{ type: 'divider' }, { type: 'divider' },
{ label: '移到🗂已处理', key: 'close', danger: true }, { label: '移到🗂隐藏列表', key: 'close', danger: true },
], ],
triggerSubMenuAction: 'click', triggerSubMenuAction: 'click',
openKeys: openTags, openKeys: openTags,

@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { App, Modal, Button, Table } from 'antd' import { App, Modal, Button, Table, Form, Row, Col, Input, Checkbox } from 'antd'
import { ApiOutlined } from '@ant-design/icons' import { ApiOutlined } from '@ant-design/icons'
import { isEmpty, cloneDeep } from '@/utils/commons' import { isEmpty, cloneDeep } from '@/utils/commons'
import { fetchJSON } from '@/utils/request' import { fetchJSON } from '@/utils/request'
@ -9,12 +9,17 @@ import dayjs from 'dayjs'
import useAuthStore from '@/stores/AuthStore' import useAuthStore from '@/stores/AuthStore'
import { fetchEmailBindOrderAction } from '@/actions/EmailActions' import { fetchEmailBindOrderAction } from '@/actions/EmailActions'
const fetchOrderList = async (params) => { const fetchMyOrderList = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getdvancedwlorder`, params) const { errcode, result } = await fetchJSON(`${API_HOST}/getdvancedwlorder`, params)
return errcode !== 0 ? [] : result 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, onBoundSuccess, ...props }) => { export const EmailBindFormModal = ({ mai_sn, conversationid, userId, coliID, onBoundSuccess, ...props }) => {
const [form] = Form.useForm()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { userId: loginUserId } = useAuthStore((state) => state.loginUser) const { userId: loginUserId } = useAuthStore((state) => state.loginUser)
@ -28,7 +33,7 @@ export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSucc
const copyObject = cloneDeep(values) const copyObject = cloneDeep(values)
delete copyObject.type delete copyObject.type
const allEmpty = Object.values(copyObject).every((val) => { const allEmpty = Object.values(copyObject).every((val) => {
return val === null || val === '' || val === undefined return val === null || String(val).trim() === '' || val === undefined
}) })
if (allEmpty) { if (allEmpty) {
notification.warning({ notification.warning({
@ -39,12 +44,14 @@ export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSucc
}) })
return false return false
} }
values.opisn = loginUserId // values.opisn = loginUserId
values.sourcetype = values.is_biz ? '227002' : '227001'
delete values.is_biz
setLoading(false) setLoading(false)
setSearchLoading(true) setSearchLoading(true)
setSearchResult([]) setSearchResult([])
const result = await fetchOrderList(values) const result = await fetchHTOrderList(values)
setSearchResult(result) setSearchResult(result.map(ele => ({...ele, sourcetype: values.sourcetype})))
setSearchLoading(false) setSearchLoading(false)
} }
@ -76,40 +83,58 @@ export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSucc
width: 222, width: 222,
}, },
{ {
title: '客人姓名', title: '顾问',
key: 'coli_guest', key: 'OperatorName',
dataIndex: 'coli_guest', dataIndex: 'OperatorName',
render: (text, record) => { width: 100,
let regularText = ''
if (record.buytime > 0) regularText = '(R' + record.buytime + ')'
return text + regularText
},
}, },
{ {
title: '出发日期', title: '分配时间',
key: 'COLI_OrderStartDate', key: 'COLI_AssignDate',
dataIndex: 'COLI_OrderStartDate', dataIndex: 'COLI_AssignDate',
width: 120, width: 100,
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: '附加信息', title: '天数',
ellipsis: true, key: 'COLI_Days',
key: 'COLI_Introduction', dataIndex: 'COLI_Days',
dataIndex: 'COLI_Introduction', 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: '', title: '',
key: 'action', key: 'action',
width: 150, width: 150,
render: (_, record) => ( render: (_, record) => (
<Button type={'text'} className='text-primary' onClick={() => handleBindOrder({ coli_sn: record.COLI_SN, coli_id: record.COLI_ID })}> <Button type={'text'} className='text-primary' onClick={() => handleBindOrder({ coli_sn: record.COLI_SN, coli_id: record.COLI_ID, sourcetype: record.sourcetype })}>
关联此订单 关联此订单
</Button> </Button>
), ),
@ -131,9 +156,31 @@ export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSucc
onCancel={() => { onCancel={() => {
setOpen(false) setOpen(false)
}} }}
destroyOnClose> destroyOnHidden>
<AdvanceSearchForm onSubmit={onSearchOrder} loading={searchLoading} /> {/* <AdvanceSearchForm onSubmit={onSearchOrder} loading={searchLoading} /> */}
<Table key={'advanceOrderTable'} loading={loading} dataSource={searchResult} columns={searchResultColumns} pagination={searchResult.length <= 10 ? false : paginationProps} /> <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> </Modal>
</> </>
) )

@ -0,0 +1,146 @@
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

@ -4,15 +4,17 @@ import { LoadingOutlined, ApiOutlined } from '@ant-design/icons';
import { EditIcon, ReplyIcon, ResendIcon, ShareForwardIcon } from '@/components/Icons' import { EditIcon, ReplyIcon, ResendIcon, ShareForwardIcon } from '@/components/Icons'
import { isEmpty, TagColorStyle } from '@/utils/commons' import { isEmpty, TagColorStyle } from '@/utils/commons'
import EmailEditorPopup from '../Input/EmailEditorPopup' import EmailEditorPopup from '../Input/EmailEditorPopup'
import DnDModal from '@/components/DndModal' import DnDModal from '@/components/DnDModal'
import useStyleStore from '@/stores/StyleStore' import useStyleStore from '@/stores/StyleStore'
import { useEmailDetail, } from '@/hooks/useEmail'; import { useEmailDetail, } from '@/hooks/useEmail';
import { EMAIL_ATTA_HOST } from '@/config'; import { EMAIL_ATTA_HOST } from '@/config';
import EmailBindFormModal from './EmailBind'; import EmailBindFormModal from './EmailBind';
import EmailDetailInline from './EmailDetailInline'; import EmailDetailInline from './EmailDetailInline';
import EmailContent from './EmailContent';
/** /**
* @property {*} emailMsg - 邮件数据. { conversationid, actionId, order_opi, coli_sn, msgOrigin: { from, to, id, email: { subject, mai_sn, } } } * @property {*} emailMsg - 邮件数据. { conversationid, actionId, order_opi, coli_sn, msgOrigin: { from, to, id, email: { subject, mai_sn, } } }
* @property {*} disabled - 是否禁用操作: 回复, 转发
*/ */
const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) => { const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) => {
@ -23,16 +25,16 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
const { conversationid, actionId, order_opi, coli_sn } = emailMsg const { conversationid, actionId, order_opi, coli_sn } = emailMsg
const { mai_sn, id } = emailMsg.msgOrigin?.email || emailMsg.msgOrigin || {} const { mai_sn, id } = emailMsg.msgOrigin?.email || emailMsg.msgOrigin || {}
const mailID = mai_sn || id const mailID = mai_sn || id
const [initialPosition, setInitialPosition] = useState({}) // const [initialPosition, setInitialPosition] = useState({})
const [initialSize, setInitialSize] = useState({}) // const [initialSize, setInitialSize] = useState({})
function onHandleMove(e) { function onHandleMove(e) {
const { top, left, width, height } = e const { top, left, width, height } = e
setInitialPosition({ top, left }) props?.setInitialPosition({ top, left })
} }
function onHandleResize(e) { function onHandleResize(e) {
const { top, left, width, height } = e const { top, left, width, height } = e
setInitialPosition({ top, left }) props?.setInitialPosition({ top, left })
setInitialSize({ width, height }) props?.setInitialSize({ width, height })
} }
const [action, setAction] = useState('') const [action, setAction] = useState('')
@ -141,64 +143,13 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
{mailData.info?.MAI_Subject || emailMsg?.msgOrigin?.email?.subject} {mailData.info?.MAI_Subject || emailMsg?.msgOrigin?.email?.subject}
</> </>
} }
initial={{ width: window.innerWidth - 740, height: window.innerHeight - 100, left: 300 + 24, top: 74 }} 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 }}
onMove={onHandleMove} onMove={onHandleMove}
onResize={onHandleResize} onResize={onHandleResize}
footer={<ActionBtns className='w-full !justify-start' />}> footer={<ActionBtns className='w-full !justify-start' />}>
{/* <EmailDetailInline { ...{ mailData, loading, mailID } } /> */} <EmailDetailInline { ...{ mailData, emailMsg, loading, mailID } } />
<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 flex-wrap justify-end', window.innerWidth < 600 ? 'flex-col' : 'flex-row '].join(' ')}>
<div className=' grow shrink 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=' shrink-0 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-12 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>
)}
{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 ${mailData.info?.MAI_ContentType === 'text/html' ? '' : 'whitespace-pre-wrap'}`} dangerouslySetInnerHTML={{ __html: mailData.content }}></div>
</div>
</div>
</DnDModal> </DnDModal>
<EmailEditorPopup {/* <EmailEditorPopup
open={openEmailEditor} open={openEmailEditor}
setOpen={setOpenEmailEditor} setOpen={setOpenEmailEditor}
fromEmail={fromEmail} fromEmail={fromEmail}
@ -209,11 +160,11 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) =
customerDetail={orderDetail.customerDetail} customerDetail={orderDetail.customerDetail}
// emailMsg={ReferEmailMsg} // emailMsg={ReferEmailMsg}
quoteid={mailID} quoteid={mailID}
initial={{ ...initialPosition, ...initialSize }} initial={{ ...props.initialPosition, ...props.initialSize }}
mailData={mailData} mailData={mailData}
action={action} action={action}
key={`email-detail-inner-${action}-popup_${mailID}`} key={`email-detail-inner-${action}_${mailID}`}
/> /> */}
</> </>
) )
} }

@ -1,52 +1,104 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { App, Button, Divider, Avatar } from 'antd' import { App, Button, Divider, Avatar, List, Flex, Typography, Tooltip, Empty } from 'antd'
import { LoadingOutlined, ApiOutlined } from '@ant-design/icons'; import { LoadingOutlined, ApiOutlined, FilePdfOutlined, FileOutlined, FileWordOutlined, FileExcelOutlined, FileJpgOutlined, FileImageOutlined, FileTextOutlined, FileGifOutlined, GlobalOutlined, FileZipOutlined, DeleteOutlined, ExpandOutlined } from '@ant-design/icons'
import { EditIcon, ReplyIcon, ResendIcon, ShareForwardIcon } from '@/components/Icons' import { EditIcon, MailCheckIcon, ReplyAllIcon, ReplyIcon, ResendIcon, ShareForwardIcon, SendPlaneFillIcon, InboxIcon } from '@/components/Icons'
import { isEmpty, TagColorStyle } from '@/utils/commons' import { isEmpty, TagColorStyle } from '@/utils/commons'
import EmailEditorPopup from '../Input/EmailEditorPopup' import EmailEditorPopup from '../Input/EmailEditorPopup'
import DnDModal from '@/components/DndModal' import DnDModal from '@/components/DnDModal'
import useStyleStore from '@/stores/StyleStore' import useStyleStore from '@/stores/StyleStore'
import { useEmailDetail, } from '@/hooks/useEmail'; import { openPopup, useEmailDetail, } from '@/hooks/useEmail'
import { EMAIL_ATTA_HOST } from '@/config'; import { EMAIL_ATTA_HOST, POPUP_FEATURES } from '@/config'
import EmailBindFormModal from './EmailBind'; 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, } } } * @property {*} emailMsg - 邮件数据. { conversationid, actionId, order_opi, coli_sn, msgOrigin: { from, to, id, email: { subject, mai_sn, } } }
*/ */
const EmailDetailInline = ({ mailID, open, setOpen = () => {}, emailMsg={}, disabled=false, ...props }) => { const EmailDetailInline = ({ mailID, emailMsg = {}, disabled = false, variant, size, autoMark = false, ...props }) => {
// console.log('emailDetail', emailMsg); // 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 { notification, message } = App.useApp()
const { conversationid, actionId, order_opi, coli_sn } = emailMsg const { conversationid, actionId, order_opi, coli_sn } = emailMsg
const { mai_sn, id } = emailMsg.msgOrigin?.email || emailMsg.msgOrigin || {} const { mai_sn, id } = emailMsg.msgOrigin?.email || emailMsg.msgOrigin || {}
// const mailID = mai_sn || id // const mailID = mai_sn || id
const [action, setAction] = useState('') // const [action, setAction] = useState('')
const [openEmailEditor, setOpenEmailEditor] = useState(false) // const [openEmailEditor, setOpenEmailEditor] = useState(false)
const [fromEmail, setFromEmail] = useState('') // const [fromEmail, setFromEmail] = useState('')
useEffect(() => { // useEffect(() => {
setOpenEmailEditor(false) // setOpenEmailEditor(false)
return () => {} // return () => {}
}, [mailID]) // }, [mailID])
const onOpenEditor = (msgOrigin, action) => { const onOpenEditor = (msgOrigin, action='reply') => {
const { from, to } = msgOrigin openPopup(`/email/${action}/${mailID || 0}`, `${action}-${mailID || 0}`)
setOpenEmailEditor(true) if (typeof props.onOpenEditor === 'function') {
setFromEmail(action === 'edit' ? from : to) props.onOpenEditor(msgOrigin, action);
setAction(action) } else {
// setOpen(false) // const { from, to } = msgOrigin
// setOpenEmailEditor(true)
// setFromEmail(action === 'edit' ? from : to)
// setAction(action)
// // setOpen(false)
}
} }
const { loading, mailData, orderDetail, postEmailResend } = useEmailDetail(mailID) const { loading, mailData, orderDetail, postEmailResend } = useEmailDetail(mailID, null, false, autoMark)
const [showBindBtn, setShowBindBtn] = useState(false); const [showBindBtn, setShowBindBtn] = useState(false)
useEffect(() => { useEffect(() => {
setShowBindBtn(isEmpty(mailData.info?.MAI_COLI_SN)) setShowBindBtn(mailID ? isEmpty(mailData.info?.MAI_COLI_SN) : false)
return () => {} return () => {}
}, [mailData.info?.MAI_COLI_SN]) }, [mailID, mailData.info?.MAI_COLI_SN])
const handleView = async () => {
openPopup(`/email/view/${mailID || 0}`, `view-${mailID || 0}`, { fullscreen: true })
};
const handleResend = async () => { const handleResend = async () => {
if (isEmpty(mai_sn)) { if (isEmpty(mai_sn)) {
@ -57,11 +109,27 @@ const EmailDetailInline = ({ mailID, open, setOpen = () => {}, emailMsg={}, disa
// setOpen(false) // setOpen(false)
} catch (err) { } catch (err) {
notification.error({ notification.error({
message: "请求失败", message: '请求失败',
description: err.message, description: err.message,
placement: "top", placement: 'top',
duration: 3, duration: 3,
}); })
}
}
const handleDel = async () => {
if (isEmpty(mai_sn)) {
return false
}
try {
//
} catch (err) {
notification.error({
message: '请求失败',
description: err.message,
placement: 'top',
duration: 3,
})
} }
} }
@ -72,15 +140,19 @@ const EmailDetailInline = ({ mailID, open, setOpen = () => {}, emailMsg={}, disa
* * 失败: 重发 * * 失败: 重发
* todo: disabled 不显示 * todo: disabled 不显示
*/ */
const ActionBtns = ({className, ...props}) => { const renderActionBtns = ({ className, ...props }) => {
const { status } = mailData.info const { status } = mailData.info
let btns = [] 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) { if (showBindBtn) {
btns.push(<EmailBindFormModal key={'bind'} onBoundSuccess={() => setShowBindBtn(false)} {...{conversationid, mai_sn, showBindBtn}} />) btns.push(<EmailBindFormModal key={'bind'} onBoundSuccess={() => setShowBindBtn(false)} {...{ conversationid, mai_sn, showBindBtn }} />)
btns.push(<Divider key='divider1' type='vertical' />); btns.push(<Divider key='divider1' type='vertical' className='mx-0' />)
} }
switch (status) { switch (status) {
@ -89,93 +161,166 @@ const EmailDetailInline = ({ mailID, open, setOpen = () => {}, emailMsg={}, disa
case 'sent': case 'sent':
case '': // case '': //
btns.push( 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' />}> <Button key={'reply'} onClick={() => onOpenEditor(emailMsg.msgOrigin, 'reply')} size='small' type='text' icon={<ReplyIcon className='text-indigo-500' />}>
回复 {compactBtn ? '' : '回复'}
</Button> </Button>
</Tooltip>,
) )
btns.push( btns.push(
<Button key={'forward'} onClick={() => onOpenEditor(emailMsg.msgOrigin, 'forward')} size='small' type='text' icon={<ShareForwardIcon className='text-primary' />}> <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>
</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 break
case 'failed': case 'failed':
btns.push( btns.push(
<Button key={'resend'} onClick={() => handleResend()} size='small' type='text' icon={<ResendIcon className='text-orange-500' />}> // <Tooltip key='delete-t' title=''>
重发 // <Button key={'delete'} danger onClick={() => handleDel()} size='small' type='text' icon={<DeleteOutlined className='text-red-500' />}>{compactBtn ? '' : ''}</Button></Tooltip>,
</Button>
<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( btns.push(
<Button key={'edit'} onClick={() => onOpenEditor({...(emailMsg.msgOrigin || {}), content: mailData.content}, 'edit')} size='small' type='text' icon={<EditIcon className='text-indigo-500' />}> <Tooltip key='edit-t' title='编辑'>
编辑 <Button
</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 break
default: default:
break 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 ( return (
<div className={`flex justify-end items-center w-full ${className || ''}`}> <List
{btns} dataSource={list}
</div> 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 ( return mailID ? (
<> <>
<div className='email-container flex flex-col gap-2 *:p-2 *:rounded-sm *:border-b *:border-gray-200 *:shadow-1md'> <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=' font-bold'>{loading ? <LoadingOutlined className='mr-1' /> : null}{mailData.info?.MAI_Subject || emailMsg?.msgOrigin?.subject}</div> <div className=''>
<div className='flex flex-wrap justify-between'>
<div> <span className={(mailData.info?.MAI_ReadState || 0) > 0 ? '' : ' font-bold '}>
<div className={['flex flex-wrap justify-end', window.innerWidth < 600 ? 'flex-col' : 'flex-row '].join(' ')}> {loading ? <LoadingOutlined className='mr-1' /> : null}
<div className=' grow shrink basis-0 flex flex-wrap gap-2 mb-2 items-center'> {mailData.info?.MAI_Subject || emailMsg?.msgOrigin?.subject}
<Avatar className='' style={TagColorStyle(mailData.info?.MAI_From, true)}> </span>
{(mailData.info?.MAI_From || '').substring(0, 1)} {/* <ActionBtns key='actions' className={'ml-auto'} /> */}
</Avatar> {renderActionBtns({ className: 'ml-auto'})}
<div className=' flex flex-col '> </div>
{/* <span className=' font-bold text-base'>{mailData.info?.fromName}</span> */} <Divider className='my-2' />
<span className='text-neutral-500 text-wrap break-words break-all '>{mailData.info?.MAI_From}</span> <div className={['flex flex-wrap justify-end', window.innerWidth < 800 ? 'flex-col' : 'flex-row '].join(' ')}>
</div> <div className=' flex-auto basis-0 flex flex-wrap gap-2 mb-2 items-center'>
</div> <Avatar className='' style={TagColorStyle(mailData.info?.MAI_From, true)}>
<div className=' shrink-0 flex flex-col justify-start gap-1 items-end'> {(mailData.info?.MAI_From || '').substring(0, 1)}
<ActionBtns /> </Avatar>
<div className='text-xs '>{mailData.info?.MAI_SendDate || emailMsg.localDate}</div> <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> </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'> <div className='text-sm'>
<span className='text-neutral-500 pr-2 w-12 inline-block text-justify' style={{textAlignLast: 'justify'}}>收件人</span> <span className='text-neutral-500 pr-2 w-12 inline-block text-justify' style={{ textAlignLast: 'justify' }}>
{mailData.info?.MAI_To} 抄送
</span>
{mailData.info.MAI_CS}
</div> </div>
{mailData.info?.MAI_CS && ( )}
<div className='text-sm'> {mailData.info?.bcc && (
<span className='text-neutral-500 pr-2 w-12 inline-block text-justify' style={{textAlignLast: 'justify'}}>抄送</span> <div className='text-sm'>
{mailData.info.MAI_CS} <span className='text-neutral-500 pr-2 w-12 inline-block text-justify' style={{ textAlignLast: 'justify' }}>
</div> 密送
)} </span>
{mailData.info?.bcc && ( {mailData.info.bcc}
<div className='text-sm'> </div>
<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.attachments.length > 0 && ( {mailData.AttachList.length > 0 && (
<div className='mt-2 *:ml-2'> <div className={`${compactBtn === false ? 'w-48' : 'border-b border-t-0'} overflow-hidden`}>
<span>{mailData.attachments.length}个附件</span> {mailData.attachments.length > 0 && (
<div className='flex flex-wrap gap-2'> <>
{mailData.attachments.map((atta) => ( <span>&nbsp;附件 ({mailData.attachments.length})</span>
<a href={`${EMAIL_ATTA_HOST}${atta.ATI_ServerFile}`} key={atta.ATI_SN} target='_blank' rel='noreferrer'> {renderAttaList({ list: mailData.attachments || []})}
{atta.ATI_Name} </>
</a> )}
))} {mailData.insideAttachments.length > 0 && <details>
</div> <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> </div>
)} )}
<Divider className='my-2' /> </Flex>
<div className='mt-2 whitespace-pre-wrap' dangerouslySetInnerHTML={{ __html: mailData.content }}></div>
</div>
</div> </div>
<EmailEditorPopup </div>
{/* <EmailEditorPopup
open={openEmailEditor} open={openEmailEditor}
setOpen={setOpenEmailEditor} setOpen={setOpenEmailEditor}
fromEmail={fromEmail} fromEmail={fromEmail}
@ -189,9 +334,11 @@ const EmailDetailInline = ({ mailID, open, setOpen = () => {}, emailMsg={}, disa
// initial={{ ...initialPosition, ...initialSize }} // initial={{ ...initialPosition, ...initialSize }}
mailData={void 0} mailData={void 0}
action={action} action={action}
key={`email-detail-inner-${action}-inline_${mailID}`} key={`email-detail-inline-${action}_${mailID}`}
/> /> */}
</> </>
) : (
<Empty description={'未打开邮件'} image={Empty.PRESENTED_IMAGE_SIMPLE} />
) )
} }
export default EmailDetailInline export default EmailDetailInline

@ -0,0 +1,145 @@
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

@ -69,7 +69,7 @@ const EmailQuotation = ({ sfi_sn, ...props }) => {
邮件 邮件
</Button> </Button>
<EmailEditorPopup {/* <EmailEditorPopup
open={editorOpen} open={editorOpen}
setOpen={setEditorOpen} setOpen={setEditorOpen}
fromEmail={pickEmail.key} fromEmail={pickEmail.key}
@ -86,7 +86,7 @@ const EmailQuotation = ({ sfi_sn, ...props }) => {
// customerDetail={customerDetail} // customerDetail={customerDetail}
action={'new'} action={'new'}
key={`email-quotation-new-popup_${currentConversation.sn}`} key={`email-quotation-new-popup_${currentConversation.sn}`}
/> /> */}
</> </>
) )
} }

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

@ -1,114 +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 { DownloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'
import { InboxIcon, SendPlaneFillIcon, ExpandIcon } from '@/components/Icons'
import EmailDetailInline from '../Components/EmailDetailInline'
import { debounce, isEmpty } from '@/utils/commons'
const SupplierEmailDrawer = ({ list: otherEmailList, currentConversationID, opi_sn, oid, ...props }) => {
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)
}
return (
<>
<Button
icon={<ExpandIcon />}
type={'primary'}
className='ml-2'
ghost
size='small'
onClick={() => {
setOpen(true)
}}
/>
<Drawer
zIndex={3}
mask={false}
width={900}
styles={{ header: {} }}
title={`供应商邮件`}
classNames={{ header: '!py-1 !px-2', body: '!p-1 [&_.ant-list-pagination]:ms-1' }}
placement='right'
onClose={() => {
setOpen(false)
}}
open={open}>
<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}
pagination={{
pageSize: 10,
// showLessItems: true,
showSizeChanger: false,
size: 'small',
}}
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={() => {
const emailMsg = {
conversationid: currentConversationID,
order_opi: opi_sn,
coli_sn: oid,
id: emailItem.MAI_SN,
MAI_SN: emailItem.MAI_SN,
msgOrigin: {
from: '',
to: '',
id: emailItem.MAI_SN,
email: { mai_sn: emailItem.MAI_SN, subject: emailItem.MAI_Subject, id: emailItem.MAI_SN },
},
}
setSelectedEmail(emailMsg)
}}>
<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>
)}
/>
<EmailDetailInline {...{ mailID: selectedEmail.MAI_SN, emailMsg: selectedEmail }} />
</Drawer>
</>
)
}
export default SupplierEmailDrawer

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

@ -318,30 +318,32 @@ const Conversations = () => {
// enterButton={'Filter'} // enterButton={'Filter'}
/> />
{/* TODO 这个在完成搜索历史会话后去掉,待讨论查询规则 */} {/* TODO 这个在完成搜索历史会话后去掉,待讨论查询规则 */}
</div>
<div className="flex gap-1 justify-between items-center shadow">
<div className='mr-auto'>{/* 占位 */}</div>
<ChatListFilter key='chat-list-filter'
onFilterChange={(d) => {
refreshConversationList()
}}
activeList={activeList}
/>
{conversationsListLoading ? ( {conversationsListLoading ? (
<div className='text-center py-1 px-2'> <div className='text-center py-1 px-2'>
<LoadingOutlined className='text-orange-400 ' /> <LoadingOutlined className='text-orange-400 ' />
</div> </div>
) : ) :
<Tooltip key={'conversation-list'} title={activeList ? '🗂已处理' : '活跃会话'}> <Tooltip key={'conversation-list'} title={activeList ? '🗂已隐藏' : '活跃会话'}>
<Button onClick={toggleClosedConversationsList} icon={activeList ? '🗂' : '📌'} type='text' /> <Button onClick={toggleClosedConversationsList} icon={activeList ? '🗂' : '📌'} type='text' />
</Tooltip> </Tooltip>
} }
{mobile && ( {mobile && (
<AudioTwoTone className='px-2' <AudioTwoTone className='px-3'
onClick={() => { onClick={() => {
navigate(`/callcenter/call`) navigate(`/callcenter/call`)
}} }}
/> />
)} )}
</div> </div>
<ChatListFilter key='chat-list-filter'
onFilterChange={(d) => {
refreshConversationList()
}}
activeList={activeList}
/>
<div className='flex-1 overflow-x-hidden overflow-y-auto relative'> <div className='flex-1 overflow-x-hidden overflow-y-auto relative'>
{/* {mobile && conversationsListLoading && dataSource.length === 0 ? ( */} {/* {mobile && conversationsListLoading && dataSource.length === 0 ? ( */}

@ -59,7 +59,7 @@ export const ConversationItemForm = ({ initialValues, onFormInstanceReady }) =>
<Form.Item label='WhatsApp' name={'wa_id'} dependencies={['phone_number']}> <Form.Item label='WhatsApp' name={'wa_id'} dependencies={['phone_number']}>
<Input disabled={!initialValues.is_new && isNotEmpty(initialValues.wa_id)} /> <Input disabled={!initialValues.is_new && isNotEmpty(initialValues.wa_id)} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item hidden
name={'email'} name={'email'}
label='邮箱' label='邮箱'
validateStatus={contactValidateStatus} validateStatus={contactValidateStatus}
@ -124,7 +124,7 @@ export const ConversationItemFormModal = ({ open, onCreate, onCancel, initialVal
onCancel(); onCancel();
formInstance?.resetFields(); formInstance?.resetFields();
}} }}
destroyOnClose destroyOnHidden
onOk={async () => { onOk={async () => {
try { try {
const values = await formInstance?.validateFields(); const values = await formInstance?.validateFields();

@ -258,7 +258,7 @@ const EmailComposer = ({ ...props }) => {
<Alert message="账户没有配置邮箱地址" description='请 重新登录 获取最新配置' type="warning" showIcon className=' absolute top-0 left-0 right-0 bottom-0' /> <Alert message="账户没有配置邮箱地址" description='请 重新登录 获取最新配置' type="warning" showIcon className=' absolute top-0 left-0 right-0 bottom-0' />
} }
<EmailEditorPopup {/* <EmailEditorPopup
{...{ open, setOpen }} {...{ open, setOpen }}
fromEmail={fromEmail} fromEmail={fromEmail}
fromUser={fromUser} fromUser={fromUser}
@ -270,7 +270,7 @@ const EmailComposer = ({ ...props }) => {
customerDetail={customerDetail} customerDetail={customerDetail}
action='new' action='new'
key={'email-new-editor-popup'} key={'email-new-editor-popup'}
/> /> */}
</ConfigProvider> </ConfigProvider>
) )
} }

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import { App, ConfigProvider, Button, Form, Input, Flex, Checkbox, Popconfirm, Select, Space, Upload, Divider, Modal } from 'antd'; import { App, ConfigProvider, Button, Form, Input, Flex, Checkbox, Popconfirm, Select, Space, Upload, Divider, Modal, Tabs } from 'antd';
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons'; import { UploadOutlined, LoadingOutlined } from '@ant-design/icons';
import '@dckj/react-better-modal/dist/index.css'; import '@dckj/react-better-modal/dist/index.css';
import DnDModal from '@/components/DndModal'; import DnDModal from '@/components/DnDModal';
import useStyleStore from '@/stores/StyleStore'; import useStyleStore from '@/stores/StyleStore';
import useConversationStore from '@/stores/ConversationStore'; import useConversationStore from '@/stores/ConversationStore';
import useAuthStore from '@/stores/AuthStore'; import useAuthStore from '@/stores/AuthStore';
@ -10,7 +10,8 @@ import useAuthStore from '@/stores/AuthStore';
import LexicalEditor from '@/components/LexicalEditor'; import LexicalEditor from '@/components/LexicalEditor';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { cloneDeep, isEmpty, } from '@/utils/commons'; import { cloneDeep, debounce, isEmpty, } from '@/utils/commons';
import { writeIndexDB } from '@/utils/indexedDB';
import './EmailEditor.css'; import './EmailEditor.css';
import { postSendEmail } from '@/actions/EmailActions'; import { postSendEmail } from '@/actions/EmailActions';
import { sentMsgTypeMapped, } from '@/channel/bubbleMsgUtils'; import { sentMsgTypeMapped, } from '@/channel/bubbleMsgUtils';
@ -60,10 +61,9 @@ const generateMailContent = (mailData) => `<br><br><p>${mailData.content}</p>`
* @property {string} quoteid - 引用邮件ID * @property {string} quoteid - 引用邮件ID
* @property {object} draft - 草稿 * @property {object} draft - 草稿
*/ */
const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid, toEmail, conversationid, quoteid, initial = {}, mailData: _mailData, action = 'reply', draft = {}, customerDetail={}, ...props }) => { const EmailEditorPopup = () => {
const { notification, message } = App.useApp(); const { notification, message } = App.useApp();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [mobile] = useStyleStore((state) => [state.mobile]); const [mobile] = useStyleStore((state) => [state.mobile]);
const [userId, username, emailList] = useAuthStore((state) => [state.loginUser.userId, state.loginUser.username, state.loginUser.emailList]); 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 })) || []; const emailListOption = emailList?.map(ele => ({ ...ele, label: ele.email, key: ele.email, value: ele.email })) || [];
@ -72,8 +72,35 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
const emailListMatMapped = emailListOption?.reduce((r, v) => ({ ...r, [v.mat_sn]: v }), {}); const emailListMatMapped = emailListOption?.reduce((r, v) => ({ ...r, [v.mat_sn]: v }), {});
// console.log('emailListMapped', emailListOption, emailListAddrMapped); // console.log('emailListMapped', emailListOption, emailListAddrMapped);
const mai_sn = quoteid; const emailEdiorProps = useConversationStore((state) => state.emailEdiorProps);
const { loading: getLoading, mailData } = useEmailDetail(mai_sn, _mailData) 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 [stickToProps, setStickToProps] = useState({}); const [stickToProps, setStickToProps] = useState({});
@ -99,24 +126,24 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
// : ID, // : ID,
// , 使focus, ID // , 使focus, ID
// , , ID, // , , ID,
const [stickToCid, setStickToCid] = useState(conversationid); const [stickToCid, setStickToCid] = useState(activeEdit.conversationid);
useEffect(() => { useEffect(() => {
const propsObj = { open, setOpen, fromEmail, fromUser, fromOrder, oid, toEmail, conversationid, quoteid, mai: _mailData?.info?.MAI_MAT_SN, action, draft } const propsObj = { ...activeEdit, mai: activeEdit.mailData?.info?.MAI_MAT_SN, }
setContentPrefix(oid ? `<p>Dear Mr./Ms. ${customerDetail.name || ''}</p><p>Reference Number: ${oid}</p>` : ''); setContentPrefix(activeEdit.oid ? `<p>Dear Mr./Ms. ${activeEdit.receiverName || ''}</p><p>Reference Number: ${activeEdit.oid}</p>` : '');
// //
if (isEmpty(quoteid)) { if (isEmpty(activeEdit.quoteid)) {
setStickToProps(propsObj) setStickToProps(propsObj)
setPropsSerialize(JSON.stringify(propsObj)) setPropsSerialize(JSON.stringify(propsObj))
setStickToCid(conversationid) setStickToCid(activeEdit.conversationid)
setEmailOrder(fromOrder) setEmailOrder(activeEdit.fromOrder)
setEmailOPI(fromUser) setEmailOPI(activeEdit.fromUser)
setNewFromEmail(fromEmail) setNewFromEmail(activeEdit.fromEmail)
setNewToEmail(toEmail) setNewToEmail(activeEdit.toEmail)
const _findMat = emailListAddrMapped?.[fromEmail]?.mat_sn const _findMat = emailListAddrMapped?.[activeEdit.fromEmail]?.mat_sn
setEmailMat(_findMat) setEmailMat(_findMat)
// if (open !== true) { // if (open !== true) {
@ -129,7 +156,7 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
const reEmailUser = mailData.info?.MAI_OPI_SN const reEmailUser = mailData.info?.MAI_OPI_SN
const reEmailUserMat = mailData.info?.MAI_MAT_SN const reEmailUserMat = mailData.info?.MAI_MAT_SN
setEmailOrder((prev) => reEmailO || prev || fromOrder) setEmailOrder((prev) => reEmailO || prev || activeEdit.fromOrder)
setEmailOPI((prev) => reEmailUser || prev) setEmailOPI((prev) => reEmailUser || prev)
setEmailMat((prev) => reEmailUserMat || prev) setEmailMat((prev) => reEmailUserMat || prev)
@ -178,6 +205,7 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
setTextContent(textContent); setTextContent(textContent);
form.setFieldValue('content', htmlContent); form.setFieldValue('content', htmlContent);
form.setFieldValue('abstract', getAbstract(textContent)); form.setFieldValue('abstract', getAbstract(textContent));
debouncedSave({htmlContent});
}; };
const [initialForm, setInitialForm] = useState({}); const [initialForm, setInitialForm] = useState({});
@ -189,7 +217,7 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
useEffect(() => { useEffect(() => {
// console.log('quoteid', quoteid, isEmpty(quoteid), isEmpty(mailData.info)); // console.log('quoteid', quoteid, isEmpty(quoteid), isEmpty(mailData.info));
if (isEmpty(quoteid) && action !== 'new') { if (isEmpty(activeEdit.quoteid) && activeEdit.action !== 'new') {
return () => {} return () => {}
} }
const { info } = mailData const { info } = mailData
@ -201,7 +229,7 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
// const _initialContent = isEmpty(mailData.info) ? signatureBody : signatureBody+preQuoteBody // const _initialContent = isEmpty(mailData.info) ? signatureBody : signatureBody+preQuoteBody
if (!isEmpty(mailData.info) && action !== 'edit') { if (!isEmpty(mailData.info) && activeEdit.action !== 'edit') {
setInitialContent(contentPrefix + signatureBody) setInitialContent(contentPrefix + signatureBody)
} }
@ -212,14 +240,14 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
subject: `Re: ${info.MAI_Subject || ''}`, subject: `Re: ${info.MAI_Subject || ''}`,
} }
const forwardValues = { from: newFromEmail, subject: `Fw: ${info.MAI_Subject || ''}` } const forwardValues = { from: newFromEmail, subject: `Fw: ${info.MAI_Subject || ''}` }
if (action === 'reply') { if (activeEdit.action === 'reply') {
form.setFieldsValue(_formValues) form.setFieldsValue(_formValues)
setInitialForm(_formValues) setInitialForm(_formValues)
} else if (action === 'forward') { } else if (activeEdit.action === 'forward') {
setStickToCid('0') setStickToCid('0')
form.setFieldsValue(forwardValues) form.setFieldsValue(forwardValues)
setInitialForm(forwardValues) setInitialForm(forwardValues)
} else if (action === 'edit') { } else if (activeEdit.action === 'edit') {
const thisFormValues = { const thisFormValues = {
to: info?.MAI_To || '', to: info?.MAI_To || '',
cc: info?.MAI_CS || '', cc: info?.MAI_CS || '',
@ -231,11 +259,11 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
// console.log('thisBody', thisBody); // console.log('thisBody', thisBody);
setInitialContent(thisBody) setInitialContent(thisBody)
} else if (action === 'new') { } else if (activeEdit.action === 'new') {
const newEmail = { to: newToEmail, subject: draft?.subject || '' } const newEmail = { to: newToEmail, subject: activeEdit.draft?.subject || '' }
form.setFieldsValue(newEmail) form.setFieldsValue(newEmail)
setInitialForm(newEmail) setInitialForm(newEmail)
setInitialContent((draft?.content || contentPrefix || '') + signatureBody) setInitialContent((activeEdit.draft?.content || contentPrefix || '') + signatureBody)
} }
return () => {} return () => {}
@ -391,7 +419,7 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
body.coli_sn = emailOrder || ''; body.coli_sn = emailOrder || '';
// console.log('body', body, '\n', emailOrder); // console.log('body', body, '\n', emailOrder);
const values = await form.validateFields(); const values = await form.validateFields();
const preQuoteBody = quoteid ? (quoteContent ? quoteContent : generateQuoteContent(mailData, isRichText)) : ''; const preQuoteBody = activeEdit.quoteid ? (quoteContent ? quoteContent : generateQuoteContent(mailData, isRichText)) : '';
body.mailcontent = isRichText ? EmailBuilder({ subject: values.subject, content: htmlContent+preQuoteBody }) : textContent+preQuoteBody body.mailcontent = isRichText ? EmailBuilder({ subject: values.subject, content: htmlContent+preQuoteBody }) : textContent+preQuoteBody
body.cc = values.cc || ''; body.cc = values.cc || '';
body.bcc = values.bcc || ''; body.bcc = values.bcc || '';
@ -450,6 +478,32 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
const [openDrawerSnippet] = useSnippetStore((state) => [state.openDrawer]) const [openDrawerSnippet] = useSnippetStore((state) => [state.openDrawer])
const [openPaymentDrawer] = useOrderStore((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 ( return (
<> <>
<ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}> <ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}>
@ -457,15 +511,19 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
rootClassName='email-editor-wrapper !border-indigo-300 ' rootClassName='email-editor-wrapper !border-indigo-300 '
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
initial={{ width: window.innerWidth - 600, height: window.innerHeight - 40, left: 300 + 24, top: 20 }} // 300 + 24
// window.innerWidth - 600
initial={{ width: 880, height: window.innerHeight - 40, left: 20, top: 20 }}
onCancel={() => { onCancel={() => {
form.resetFields() form.resetFields()
stateReset() stateReset()
}} }}
titleClassName={`!pl-0 !pt-0 !pb-0`}
title={ title={
<> <>
{getLoading ? <LoadingOutlined className='mr-1' /> : null} {/* {getLoading ? <LoadingOutlined className='mr-1' /> : null} */}
{initialForm.subject || `${!isEmpty(quoteid) ? '回复: ' : '写邮件: '} ${newFromEmail || ''}`} {/* {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 />
</> </>
} }
footer={ footer={
@ -486,7 +544,7 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
</div> </div>
}> }>
<Form <Form
form={form} form={form} onValuesChange={onEditChange}
preserve={false} preserve={false}
name={`email_max_form-${Date.now().toString(32)}`} name={`email_max_form-${Date.now().toString(32)}`}
size='small' size='small'
@ -573,7 +631,7 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
</Form.Item> </Form.Item>
</Form> </Form>
<LexicalEditor {...{ isRichText }} onChange={handleEditorChange} defaultValue={initialContent} /> <LexicalEditor {...{ isRichText }} onChange={handleEditorChange} defaultValue={initialContent} />
{quoteid && !showQuoteContent && ( {activeEdit.quoteid && !showQuoteContent && (
<div className='flex justify-start items-center ml-2'> <div className='flex justify-start items-center ml-2'>
<Button className='flex gap-2 ' type='link' onClick={() => setShowQuoteContent(!showQuoteContent)}> <Button className='flex gap-2 ' type='link' onClick={() => setShowQuoteContent(!showQuoteContent)}>
显示引用内容 显示引用内容
@ -584,7 +642,7 @@ const EmailEditorPopup = ({ open, setOpen, fromEmail, fromUser, fromOrder, oid,
</div> </div>
)} )}
{showQuoteContent && ( {showQuoteContent && (
<div contentEditable className='border-0 outline-none' onBlur={(e) => setQuoteContent(e.target.innerHTML)} dangerouslySetInnerHTML={{ __html: generateQuoteContent(mailData) }}></div> <blockquote contentEditable className='border-0 outline-none' onBlur={(e) => setQuoteContent(`<blockquote>${e.target.innerHTML}</blockquote>`)} dangerouslySetInnerHTML={{ __html: generateQuoteContent(mailData) }}></blockquote>
)} )}
</DnDModal> </DnDModal>
</ConfigProvider> </ConfigProvider>

@ -8,7 +8,7 @@ const PaymentlinkBtn = ({ type, ...props }) => {
return ( return (
<> <>
<GeneratePaymentDrawer /> {/* <GeneratePaymentDrawer /> */}
<Tooltip title='支付链接'> <Tooltip title='支付链接'>
{/* <Button type='text' onClick={() => openPaymentDrawer()} icon={<DollarOutlined className='text-orange-500' />} size={'middle'} /> */} {/* <Button type='text' onClick={() => openPaymentDrawer()} icon={<DollarOutlined className='text-orange-500' />} size={'middle'} /> */}

@ -42,7 +42,6 @@ const MessagesHeader = () => {
{currentConversation.sn ? ( {currentConversation.sn ? (
<div className='flex flex-col'> <div className='flex flex-col'>
<Typography.Text>{currentConversation.session_type === 1 ? '' : currentConversation?.channels?.whatsapp_phone_number}</Typography.Text> <Typography.Text>{currentConversation.session_type === 1 ? '' : currentConversation?.channels?.whatsapp_phone_number}</Typography.Text>
<Typography.Text>{currentConversation?.channels?.email}</Typography.Text>
</div> </div>
) : ( ) : (
<Typography.Text strong type='danger'> <Typography.Text strong type='danger'>

@ -13,6 +13,8 @@ import EmailDetail from './Components/EmailDetail';
import { useOrderStore, } from "@/stores/OrderStore"; import { useOrderStore, } from "@/stores/OrderStore";
import { isEmpty } from '@/utils/commons'; import { isEmpty } from '@/utils/commons';
import useStyleStore from '@/stores/StyleStore'; import useStyleStore from '@/stores/StyleStore';
import EmailListDrawer from './Components/EmailListDrawer';
import { POPUP_FEATURES } from '@/config';
const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => { const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
const userId = useAuthStore((state) => state.loginUser.userId); const userId = useAuthStore((state) => state.loginUser.userId);
@ -87,7 +89,7 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
setLongList(activeMessages); setLongList(activeMessages);
const thisLastTime = activeMessages.length > 0 ? activeMessages[0].msgtime : ''; const thisLastTime = activeMessages.length > 0 ? activeMessages[0].msgtime : '';
const loadNextPage = !(activeMessages.length === 0 || activeMessages.length < MESSAGE_PAGE_SIZE); const loadNextPage = !(activeMessages.length === 0 || activeMessages.length < MESSAGE_PAGE_SIZE);
updateCurrentConversation({ lasttime: thisLastTime, loadNextPage, }); updateCurrentConversation({ lasttime1: thisLastTime, loadNextPage, });
return () => {}; return () => {};
}, [activeMessages, currentConversationSN]); }, [activeMessages, currentConversationSN]);
@ -123,7 +125,7 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
const thisLastTime = data.length > 0 ? data[0].msgtime : ''; // orgmsgtime const thisLastTime = data.length > 0 ? data[0].msgtime : ''; // orgmsgtime
const loadNextPage = !(data.length === 0 || data.length < MESSAGE_PAGE_SIZE); const loadNextPage = !(data.length === 0 || data.length < MESSAGE_PAGE_SIZE);
updateCurrentConversation({ lasttime: thisLastTime, loadNextPage, }); updateCurrentConversation({ lasttime1: thisLastTime, loadNextPage, });
// setPrevSN(item.sn); // setPrevSN(item.sn);
setMsgLoading(false); setMsgLoading(false);
@ -145,7 +147,7 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
setLongList(prevValue => data.concat(prevValue)); setLongList(prevValue => data.concat(prevValue));
const thisLastTime = data.length > 0 ? data[0].msgtime : ''; const thisLastTime = data.length > 0 ? data[0].msgtime : '';
const loadNextPage = !(data.length === 0 || data.length < MESSAGE_PAGE_SIZE); const loadNextPage = !(data.length === 0 || data.length < MESSAGE_PAGE_SIZE);
updateCurrentConversation({ lasttime: thisLastTime, loadNextPage, }); updateCurrentConversation({ lasttime1: thisLastTime, loadNextPage, });
return data.length; return data.length;
}; };
@ -165,7 +167,7 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
return false; return false;
case 'document': case 'document':
window.open(msg.data.link || msg.data.uri, '_blank', 'noopener,noreferrer'); window.open(msg.data.link || msg.data.uri, msg.data.link || msg.data.uri, POPUP_FEATURES);
return false; return false;
default: default:
@ -191,27 +193,66 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
const handleContactClick = (data) => { const handleContactClick = (data) => {
return data.length > 1 ? handleContactListClick(data) : handleContactItemClick(data[0]); return data.length > 1 ? handleContactListClick(data) : handleContactItemClick(data[0]);
} }
// EmailEditor // EmailEditor
const [setEditorOpen, setEditorProps] = useConversationStore((state) => [state.setEditorOpen, state.setEditorProps]);
const [openEmailEditor, setOpenEmailEditor] = useState(false); const [openEmailEditor, setOpenEmailEditor] = useState(false);
const [fromEmail, setFromEmail] = useState(''); const [fromEmail, setFromEmail] = useState('');
const [ReferEmailMsg, setReferEmailMsg] = useState(''); const [ReferEmailMsg, setReferEmailMsg] = useState('');
const onOpenEditor = (emailMsgContent) => { const [action, setAction] = useState('')
const onOpenEditor = (emailMsgContent, action='reply') => {
const { from, to } = emailMsgContent; // msgtext const { from, to } = emailMsgContent; // msgtext
// console.log('emailMsgContent', emailMsgContent); console.log('emailMsgContent', emailMsgContent);
setOpenEmailEditor(true); setOpenEmailEditor(true);
setFromEmail(to); // setFromEmail(to);
setFromEmail(action === 'edit' ? from : to)
setReferEmailMsg({...emailMsgContent, order_opi: Number(orderDetail?.opi_sn || userId)}); setReferEmailMsg({...emailMsgContent, order_opi: Number(orderDetail?.opi_sn || userId)});
setAction(action)
setEditorProps({
action,
fromEmail: action === 'edit' ? from : to,
fromUser: Number(orderDetail?.opi_sn || userId),
fromOrder: currentConversation.coli_sn,
oid: orderDetail?.order_no || emailMsgContent?.coli_id,
receiverName: customerDetail.name,
quoteid: emailMsgContent.mai_sn || emailMsgContent.id,
conversationid: currentConversation.sn,
subject: emailMsgContent.subject,
});
// setEditorOpen(true);
}; };
const [openEmailDetail, setOpenEmailDetail] = useState(false); const [openEmailDetail, setOpenEmailDetail] = useState(false);
const [emailDetail, setEmailDetail] = useState({}); const [emailDetail, setEmailDetail] = useState({});
const [initialPosition, setInitialPosition] = useState({})
const [initialSize, setInitialSize] = useState({})
const [emailItem, setEmailItem] = useState({});
const onOpenEmail = (emailMsg) => { const onOpenEmail = (emailMsg) => {
setOpenEmailDetail(true); // setOpenEmailDetail(true);
setEmailDetail({...emailMsg, order_opi: Number(orderDetail?.opi_sn || userId)}); // setEmailDetail({...emailMsg, order_opi: Number(orderDetail?.opi_sn || userId)});
setEmailItem({ MAI_SN: emailMsg.msgtext?.email?.mai_sn, MAI_Subject: emailMsg.msgtext?.email?.subject, SenderReceiver: '', MAI_SendDate: '' })
} }
const [emailList, setEmailList] = useState([]);
useEffect(() => {
const _emailList = longList
.filter((item) => item.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);
setEmailItem({});
}, [longList]);
return ( return (
<> <>
<MessagesList key={`${currentConversationSN}_${currentConversationColiSN}`} <MessagesList key={`${currentConversationSN}_${currentConversationColiSN}`}
@ -261,7 +302,7 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
onCancel={() => setNewChatModalVisible(false)} onCancel={() => setNewChatModalVisible(false)}
/> />
{/* <EmailEditor open ={openEmailEditor} setOpen={setOpenEmailEditor} reference={ReferEmailMsg} setRefernce={setReferEmailMsg} {...{ fromEmail, }} key={'email-editor-reply'} /> */} {/* <EmailEditor open ={openEmailEditor} setOpen={setOpenEmailEditor} reference={ReferEmailMsg} setRefernce={setReferEmailMsg} {...{ fromEmail, }} key={'email-editor-reply'} /> */}
<EmailEditorPopup {/* <EmailEditorPopup
open={openEmailEditor} open={openEmailEditor}
setOpen={setOpenEmailEditor} setOpen={setOpenEmailEditor}
fromEmail={fromEmail} fromEmail={fromEmail}
@ -271,9 +312,11 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
customerDetail={customerDetail} customerDetail={customerDetail}
quoteid={ReferEmailMsg.mai_sn || ReferEmailMsg.id} quoteid={ReferEmailMsg.mai_sn || ReferEmailMsg.id}
conversationid={currentConversation.sn} conversationid={currentConversation.sn}
key={`email-msg-reply-top-popup_${ReferEmailMsg.id}`} key={`email-msg-reply-online_${ReferEmailMsg.id}`}
/> action={action}
<EmailDetail open={openEmailDetail} setOpen={setOpenEmailDetail} emailMsg={emailDetail} key={`email-detail-${emailDetail.id}`} /> /> */}
<EmailDetail open={openEmailDetail} setOpen={setOpenEmailDetail} emailMsg={emailDetail} key={`email-detail-${emailDetail.id}`} {...{initialPosition, initialSize, setInitialPosition, setInitialSize}} />
<EmailListDrawer showExpandBtn={false} list={emailList} emailItem={emailItem} currentConversationID={currentConversation.sn} oid={currentConversation.coli_sn} opi_sn={currentConversation.opi_sn} {...{onOpenEditor}} />
</> </>
) )
}; };

@ -87,7 +87,7 @@ const ReplyWrapper = () => {
icon: <WABIcon className={sessionType === 1 ? 'text-gray-500 [&_path]:fill-current' : ''} />, icon: <WABIcon className={sessionType === 1 ? 'text-gray-500 [&_path]:fill-current' : ''} />,
children: <InputComposer currentActive={activeChannel === 'waba'} channel={'waba'} lastWABAMsg={lastWABAMsg} />, children: <InputComposer currentActive={activeChannel === 'waba'} channel={'waba'} lastWABAMsg={lastWABAMsg} />,
}, },
{ key: 'email', label: mobile ? '' : '邮件', icon: <MailOutlined className='text-indigo-500' />, children: <EmailComposer currentActive={activeChannel === 'email'} /> }, // { key: 'email', label: mobile ? '' : '', icon: <MailOutlined className='text-indigo-500' />, children: <EmailComposer currentActive={activeChannel === 'email'} /> },
{ {
key: 'wai', key: 'wai',
label: mobile ? '' : 'WhatsApp', label: mobile ? '' : 'WhatsApp',

@ -7,7 +7,7 @@ import { useShallow } from 'zustand/react/shallow';
import { copy, isEmpty } from "@/utils/commons"; import { copy, isEmpty } from "@/utils/commons";
import { Conditional } from "@/components/Conditional"; import { Conditional } from "@/components/Conditional";
import useConversationStore from "@/stores/ConversationStore"; import useConversationStore from "@/stores/ConversationStore";
import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions, remindStatusOptions, fetchSetRemindStateAction } from "@/stores/OrderStore"; import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions, remindStatusOptions, fetchSetRemindStateAction, remindStatusOptionsMapped } from "@/stores/OrderStore";
import useAuthStore from "@/stores/AuthStore"; import useAuthStore from "@/stores/AuthStore";
import QuotesHistory from "./QuotesHistory"; import QuotesHistory from "./QuotesHistory";
import ConversationBind from "./../ConversationBind"; import ConversationBind from "./../ConversationBind";
@ -16,11 +16,10 @@ import { useConversationNewItem } from "@/hooks/useConversation";
import EmailDetail from './../Components/EmailDetail'; import EmailDetail from './../Components/EmailDetail';
import { postEditConversationItemColiAction } from "@/actions/ConversationActions"; import { postEditConversationItemColiAction } from "@/actions/ConversationActions";
import useStyleStore from '@/stores/StyleStore'; import useStyleStore from '@/stores/StyleStore';
import { ExpandIcon } from "@/components/Icons"; import SupplierEmailDrawer from "../Components/EmailListDrawer";
import EmailDetailInline from "../Components/EmailDetailInline";
import SupplierEmailDrawer from "../Components/SupplierEmailDrawer";
const CustomerProfile = () => { // @Deprecated OrderProfile
const CustomerProfile = ({ disabled }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { notification, message } = App.useApp(); const { notification, message } = App.useApp();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -34,7 +33,6 @@ const CustomerProfile = () => {
const currentConversationID = useConversationStore(useShallow(state => state.currentConversation?.sn || "")); const currentConversationID = useConversationStore(useShallow(state => state.currentConversation?.sn || ""));
const channels = useConversationStore(state => state.currentConversation?.channels); const channels = useConversationStore(state => state.currentConversation?.channels);
const [updateCurrentConversation] = useConversationStore(state => [state.updateCurrentConversation]); const [updateCurrentConversation] = useConversationStore(state => [state.updateCurrentConversation]);
const [emailMsg, setEmailMsg, detailPopupOpen, setDetailOpen, openDetail] = useConversationStore(state => [state.emailMsg, state.setEmailMsg, state.detailPopupOpen, state.setDetailOpen, state.openDetail]);
const loginUser = useAuthStore(state => state.loginUser); const loginUser = useAuthStore(state => state.loginUser);
const [ const [
orderDetail, customerDetail, lastQuotation, quotationList, fetchOrderDetail, setOrderPropValue, appendOrderComment, fetchOtherEmail, otherEmailList, fetchHistoryOrder orderDetail, customerDetail, lastQuotation, quotationList, fetchOrderDetail, setOrderPropValue, appendOrderComment, fetchOtherEmail, otherEmailList, fetchHistoryOrder
@ -97,19 +95,19 @@ const CustomerProfile = () => {
}); });
fetchOtherEmail(currentOrder) // fetchOtherEmail(currentOrder)
.then(result => { // .then(result => {
// console.info(result) // // console.info(result)
}) // })
.finally(() => setLoading(false)) // .finally(() => setLoading(false))
.catch(reason => { // .catch(reason => {
notification.error({ // notification.error({
message: "查询供应商邮件出错", // message: "",
description: reason.message, // description: reason.message,
placement: "top", // placement: "top",
duration: 60, // duration: 60,
}); // });
}); // });
} else if (isEmpty(currentOrder)) { } else if (isEmpty(currentOrder)) {
resetOrderStore(); resetOrderStore();
} }
@ -128,22 +126,7 @@ const CustomerProfile = () => {
setNewChatModalVisible(false); setNewChatModalVisible(false);
}; };
const handle3rdEmailItemClick = useCallback((emailItem) => { const [clicked3rdEmailItem, setClicked3rdEmailItem] = useState({});
const emailMsg = {
conversationid: currentConversationID,
order_opi: orderDetail.opi_sn,
coli_sn: currentOrder,
id: emailItem.MAI_SN,
msgOrigin: {
from: '',
to: '',
id: emailItem.MAI_SN,
email: { mai_sn: emailItem.MAI_SN, subject: emailItem.MAI_Subject, id: emailItem.MAI_SN },
},
}
setEmailMsg(emailMsg);
openDetail();
}, [])
const handleOrderSwitch = async (coli_sn) => { const handleOrderSwitch = async (coli_sn) => {
await postEditConversationItemColiAction({conversationid: currentConversationID, coli_sn }); await postEditConversationItemColiAction({conversationid: currentConversationID, coli_sn });
@ -187,7 +170,7 @@ const CustomerProfile = () => {
onChange={handleOrderSwitch} onChange={handleOrderSwitch}
/>} />}
actions={[ actions={[
<Select <Select disabled={disabled} className={`[&_.ant-select-selection-item]:text-gray-950`}
key={"orderlabel"} key={"orderlabel"}
size="small" size="small"
style={{ style={{
@ -211,7 +194,7 @@ const CustomerProfile = () => {
value={orderDetail.tags} value={orderDetail.tags}
options={orderLabelOptions} options={orderLabelOptions}
/>, />,
<Select <Select disabled={disabled} className={`[&_.ant-select-selection-item]:text-gray-950`}
key={"orderstatus"} key={"orderstatus"}
size="small" size="small"
style={{ style={{
@ -287,7 +270,9 @@ const CustomerProfile = () => {
<Divider orientation="left"> <Divider orientation="left">
<Typography.Text strong>催信:</Typography.Text> <Typography.Text strong>催信:</Typography.Text>
</Divider> </Divider>
<Checkbox.Group key='substatus' className="px-2" value={[orderRemindState]} options={remindStatusOptions} onChange={handleSetRemindState} /> {disabled ? (<div className="px-2"><span>{remindStatusOptionsMapped[orderRemindState]?.label}</span></div>) : <Checkbox.Group key='substatus' className="px-2" value={[orderRemindState]} options={remindStatusOptions} onChange={handleSetRemindState} />}
{/*
<Divider orientation="left"> <Divider orientation="left">
<Typography.Text strong>最新报价</Typography.Text> <Typography.Text strong>最新报价</Typography.Text>
</Divider> </Divider>
@ -309,12 +294,12 @@ const CustomerProfile = () => {
</> </>
} }
/> />
</Flex> </Flex>*/}
{/*
<Divider orientation="left"> <Divider orientation="left">
<Typography.Text strong>供应商邮件</Typography.Text> <Typography.Text strong>供应商邮件</Typography.Text>
<Badge count={otherEmailList.length} className='ml-2' color="oklch(62.3% 0.214 259.815)" /> <Badge count={otherEmailList.length} className='ml-2' color="oklch(62.3% 0.214 259.815)" />
<SupplierEmailDrawer list={otherEmailList} currentConversationID={currentConversationID} opi_sn={orderDetail.opi_sn} oid={currentOrder} /> <SupplierEmailDrawer title={'供应商邮件'} list={otherEmailList} currentConversationID={currentConversationID} opi_sn={orderDetail.opi_sn} oid={currentOrder} emailItem={clicked3rdEmailItem} />
</Divider> </Divider>
<List <List
dataSource={otherEmailList} dataSource={otherEmailList}
@ -328,23 +313,21 @@ const CustomerProfile = () => {
renderItem={(email) => ( renderItem={(email) => (
<List.Item <List.Item
className='hover:bg-stone-50 cursor-pointer [&.ant-list-item]:py-1 [&.ant-list-item]:px-2' className='hover:bg-stone-50 cursor-pointer [&.ant-list-item]:py-1 [&.ant-list-item]:px-2'
onClick={() => { onClick={() => setClicked3rdEmailItem(email) }>
handle3rdEmailItemClick(email)
}}>
<Flex <Flex
vertical className="grow" vertical className="w-full"
> >
<div className="text-stone-600 line-clamp-1"> <div className="text-stone-600 line-clamp-1">
<Typography.Text ellipsis={{tooltip: email.SenderReceiver}} style={{width: 380}} className="text-stone-600">{email.SenderReceiver}</Typography.Text></div> <Typography.Text ellipsis={{tooltip: email.SenderReceiver}} className="text-stone-600">{email.SenderReceiver}</Typography.Text></div>
<div className="">{email.MAI_Subject}</div> <div className="">{email.MAI_Subject}</div>
</Flex> </Flex>
</List.Item> </List.Item>
)} )}
/> /> */}
<Divider orientation="left"> <Divider orientation="left">
<Typography.Text strong>表单信息</Typography.Text> <Typography.Text strong>表单信息</Typography.Text>
<Button className="ml-2" <Button className="ml-2"
size={"small"} size={"small"} disabled={disabled}
onClick={() => { onClick={() => {
setIsModalOpen(true); setIsModalOpen(true);
}}> }}>
@ -383,7 +366,6 @@ const CustomerProfile = () => {
</Modal> </Modal>
</Spin> </Spin>
<ConversationsNewItem initialValues={newChatFormValues} open={newChatModalVisible} onCreate={handleNewChat} onCancel={() => setNewChatModalVisible(false)} /> <ConversationsNewItem initialValues={newChatFormValues} open={newChatModalVisible} onCreate={handleNewChat} onCancel={() => setNewChatModalVisible(false)} />
<EmailDetail open={detailPopupOpen} setOpen={setDetailOpen} emailMsg={emailMsg} key={`supplier-email-detail-1-${emailMsg?.id}`} />
</div> </div>
); );

@ -3,6 +3,7 @@ import { Button, Flex, List, Popover } from 'antd'
import { useState } from 'react' import { useState } from 'react'
import EmailQuotation from '../Components/EmailQuotation' import EmailQuotation from '../Components/EmailQuotation'
// @Deprecated
const QuotesHistory = ((props) => { const QuotesHistory = ((props) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)

@ -27,9 +27,6 @@ import 'react-chat-elements/dist/main.css'
import ReloadPrompt from './ReloadPrompt' import ReloadPrompt from './ReloadPrompt'
import ClearCache from './ClearCache' import ClearCache from './ClearCache'
import SnippetList from '@/views/accounts/SnippetList'
import GeneratePayment from '@/views/accounts/GeneratePayment'
import { BUILD_VERSION, BUILD_DATE } from '@/config' import { BUILD_VERSION, BUILD_DATE } from '@/config'
const { Header, Footer, Content } = Layout const { Header, Footer, Content } = Layout
@ -125,24 +122,6 @@ function DesktopApp() {
width: '100%', width: '100%',
background: 'white', background: 'white',
}}> }}>
<Drawer
title='图文集'
placement={'top'}
size={'large'}
onClose={() => closeSnippetDrawer()}
open={snippetDrawerOpen}
>
<SnippetList></SnippetList>
</Drawer>
<Drawer
title='支付链接'
placement={'top'}
size={'large'}
onClose={() => closePaymentDrawer()}
open={paymentDrawerOpen}
>
<GeneratePayment></GeneratePayment>
</Drawer>
<Row gutter={{ md: 24 }} align='middle'> <Row gutter={{ md: 24 }} align='middle'>
<Col flex='220px'> <Col flex='220px'>
<NavLink to='/'> <NavLink to='/'>
@ -173,13 +152,13 @@ function DesktopApp() {
</Link> </Link>
), ),
}, },
{ // {
key: '/callcenter/call', // key: '/callcenter/call',
label: <Link to='/callcenter/call'>语音通话</Link>, // label: <Link to='/callcenter/call'></Link>,
}, // },
{ {
key: '/chat/history', key: '/chat/history',
label: <Link to='/chat/history'>聊天记录</Link>, label: <Link to='/chat/history'>历史记录</Link>,
}, },
{ {
key: '/customer_relation/index', key: '/customer_relation/index',

@ -0,0 +1,28 @@
import { useParams } from 'react-router-dom';
import { App, ConfigProvider } from 'antd'
import useStyleStore from '@/stores/StyleStore'
import EmailDetailInline from './Conversations/Online/Components/EmailDetailInline';
/**
* 独立窗口查看邮件
*
* - 从销售平台进入: 自动复制 storage, 可读取loginUser
*
* ! 无状态管理
*/
const EmailDetailWindow = () => {
const pageParam = useParams();
// console.log(pageParam)
// const [mobile] = useStyleStore((state) => [state.mobile])
return (
<>
<ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}>
<EmailDetailInline mailID={pageParam.mailid} variant={'full'} />
</ConfigProvider>
</>
)
}
export default EmailDetailWindow

@ -0,0 +1,738 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { App, ConfigProvider, Button, Form, Input, Flex, Checkbox, Popconfirm, Select, Space, Upload, Divider, Modal, Tabs, Radio, Typography, } from 'antd'
import { UploadOutlined, LoadingOutlined, SaveOutlined, SendOutlined, CheckCircleOutlined, ExclamationCircleFilled } from '@ant-design/icons'
import useStyleStore from '@/stores/StyleStore'
// import useConversationStore from '@/stores/ConversationStore'
import useAuthStore from '@/stores/AuthStore'
import LexicalEditor from '@/components/LexicalEditor'
import { v4 as uuid } from 'uuid'
import { cloneDeep, debounce, isEmpty, olog, omitEmpty } from '@/utils/commons'
import { writeIndexDB, readIndexDB, deleteIndexDBbyKey, } from '@/utils/indexedDB';
import '@/views/Conversations/Online/Input/EmailEditor.css'
import { deleteEmailAttachmentAction, parseHTMLString, postSendEmail, saveEmailDraftOrSendAction } from '@/actions/EmailActions'
import { sentMsgTypeMapped } from '@/channel/bubbleMsgUtils'
import { EmailBuilder, openPopup, useEmailDetail, useEmailSignature, useEmailTemplate } from '@/hooks/useEmail'
import useSnippetStore from '@/stores/SnippetStore'
// import { useOrderStore } from '@/stores/OrderStore'
import PaymentlinkBtn from '@/views/Conversations/Online/Input/PaymentlinkBtn'
import { TextIcon } from '@/components/Icons';
import { EMAIL_ATTA_HOST, POPUP_FEATURES } from '@/config';
const {confirm} = Modal;
//
// .application, .exe, .app
const disallowedAttachmentTypes = [
'.ps1',
'.msi',
'application/x-msdownload',
'application/x-ms-dos-executable',
'application/x-ms-wmd',
'application/x-ms-wmz',
'application/x-ms-xbap',
'application/x-msaccess',
]
const getAbstract = (longtext) => {
const lines = longtext.split('\n')
const firstLine = lines[0]
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 = '<br>' + el.textContent
// })
// Replace <hr> with a line of dashes
// Array.from(dom.body.querySelectorAll('hr')).forEach((el) => {
// el.innerHTML = '<p><hr>------------------------------------------------------------------</p>'
// })
const line = '<p>------------------------------------------------------------------</p>'
return line+(dom.body.innerHTML || '')
}
const generateQuoteContent = (mailData, isRichText = true) => {
const html = `<br><hr><blockquote><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></blockquote>`
return isRichText ? html : parseHTMLText(html)
}
const generateMailContent = (mailData) => mailData.info?.MAI_ContentType === 'text/html' ? `${mailData.content}<br>` : `<p>${mailData.content.replace(/\r\n/g, '<br>')}</p>`
/**
* 独立窗口编辑器
*
* - 从销售平台进入: 自动复制 storage, 可读取loginUser
*
* ! 无状态管理
*/
const NewEmail = () => {
const pageParam = useParams();
const { templateKey } = pageParam
const editorKey = pageParam.action==='new' ? `new-0-${pageParam.oid}` : `${pageParam.action}-${pageParam.quoteid}`
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 = useMemo(() => emailList?.map((ele) => ({ ...ele, label: ele.email, key: ele.email, value: ele.email })) || [], [emailList])
const emailListOPIMapped = useMemo(() => emailListOption?.reduce((r, v) => ({ ...r, [v.opi_sn]: v }), {}), [emailListOption]);
const emailListAddrMapped = useMemo(() => emailListOption?.reduce((r, v) => ({ ...r, [v.email]: v }), {}), [emailListOption])
const emailListMatMapped = useMemo(() => emailListOption?.reduce((r, v) => ({ ...r, [v.mat_sn]: v }), {}), [emailListOption])
// console.log('emailListMapped', emailListOption, emailListAddrMapped);
const [emailAccount, setEmailAccount] = useState({});
const [emailAccountOPI, setEmailAccountOPI] = useState(0);
const mai_sn = pageParam.quoteid // activeEdit.quoteid
const { loading: quoteLoading, mailData, orderDetail, postEmailSaveOrSend } = useEmailDetail(mai_sn, null, pageParam.oid)
const { loading: loadingTamplate, templateContent } = useEmailTemplate(templateKey, {coli_sn: pageParam.oid, opi_sn: orderDetail.opi_sn || mailData.info?.MAI_OPI_SN || 0, lgc: 1});
const initOPI = useMemo(() => emailAccountOPI || orderDetail.opi_sn || mailData.info?.MAI_OPI_SN || 0, [emailAccountOPI, mailData, orderDetail])
const { signature } = useEmailSignature(initOPI)
const [initialContent, setInitialContent] = useState('')
const [showQuoteContent, setShowQuoteContent] = useState(false)
const [quoteContent, setQuoteContent] = useState('')
// const [newFromEmail, setNewFromEmail] = useState('')
// const [newToEmail, setNewToEmail] = useState('')
// const [emailOPI, setEmailOPI] = useState('')
// const [emailOrder, setEmailOrder] = useState('')
// const [emailOrderSN, setEmailOrderSN] = useState('')
// const [emailMat, setEmailMat] = useState('')
// const [contentPrefix, setContentPrefix] = useState('')
const [localDraft, setLocalDraft] = useState();
// const readMailboxLocalCache = async () => {
// console.log('===============', 'readMailboxLocalCache')
// const readCache = await readIndexDB(editorKey, 'draft', 'mailbox')
// if (readCache) {
// const btn = (
// <Space>
// <Button type='link' size='small' onClick={() => notification.destroy()}>
//
// </Button>
// {/* <Button type="primary" size="small" onClick={() => notification.destroy()}>
// Confirm
// </Button> */}
// </Space>
// )
// // notification.open({
// // key: editorKey,
// // placement: 'top',
// // // message: '',
// // description: '',
// // duration: 0,
// // actions: btn,
// // })
// setLocalDraft(readCache)
// if (!isEmpty(localDraft)) {
// const { htmlContent, ...draftFormsValues } = localDraft
// const _findMatOld = emailListMatMapped?.[draftFormsValues.mat_sn]
// const _from = draftFormsValues?.from || _findMatOld?.email || ''
// form.setFieldsValue(draftFormsValues)
// setNewFromEmail(_from)
// setEmailOPI(draftFormsValues.opi_sn)
// setEmailMat(draftFormsValues.mat_sn)
// setEmailOrder(draftFormsValues.coli_sn)
// requestAnimationFrame(() => {
// setInitialContent(htmlContent)
// })
// return false
// }
// }
// }
// useEffect(() => {
// readMailboxLocalCache()
// return () => {}
// }, [])
//
// -
// -
useEffect(() => {
// console.log('useEffect 1---- \nform.setFieldsValue ');
if (isEmpty(mailData.content) && isEmpty(orderDetail.order_no)) {
// return () => {}
}
const docTitle = mailData.info?.MAI_Subject || 'New Email-';
document.title = docTitle
const { order_no } = orderDetail
// setContentPrefix(order_no ? `<p>Dear Mr./Ms. ${orderDetail.contact?.[0]?.name || ''}</p><p>Reference Number: ${order_no}</p>` : '')
const orderPrefix = order_no ? `<p>Dear Mr./Ms. ${orderDetail.contact?.[0]?.name || ''}</p><p>Reference Number: ${order_no}</p>` : ''
const { info } = mailData
const { ...templateFormValues } = templateContent;
const orderReceiver = orderDetail.contact?.[0]?.email || ''
const _findMatOld = emailListOPIMapped?.[orderDetail.opi_sn]
const orderSender = _findMatOld?.email || ''
const _findMatOldE = emailListMatMapped?.[info.MAI_MAT_SN]
const quotedMailSender = _findMatOldE?.email || ''
const sender = quotedMailSender || orderSender
const quotedMailSenderObj = emailAccount?.email || sender; // { key: sender, label: sender, value: sender }
const defaultMAT = emailListAddrMapped?.[sender]?.mat_sn || ''
const _form2 = {
coli_sn: Number(pageParam.oid) || info?.MAI_COLI_SN || '',
mat_sn: emailAccount?.mat_sn || info?.MAI_MAT_SN || defaultMAT,
opi_sn: emailAccount?.opi_sn || info?.MAI_OPI_SN || orderDetail.opi_sn || '',
}
const originalContentType = info?.mailType === 'text/html';
setIsRichText(originalContentType)
let readyToInitialContent = '';
let _formValues = {};
// setShowCc(!isEmpty(mailData.info?.MAI_CS));
const signatureBody = generateMailContent({ content: signature })
// const preQuoteBody = generateQuoteContent(mailData)
// const _initialContent = isEmpty(mailData.info) ? signatureBody : signatureBody+preQuoteBody
// 稿: ``id
if (!isEmpty(mailData.info) && !['edit'].includes(pageParam.action)) {
readyToInitialContent = orderPrefix + '<br>' + signatureBody
}
switch (pageParam.action) {
case 'reply':
_formValues = {
from: quotedMailSenderObj,
to: info?.replyTo || orderReceiver,
cc: info?.MAI_CS || '',
// bcc: quote.bcc || '',
subject: `Re: ${info.MAI_Subject || ''}`,
..._form2
}
readyToInitialContent += generateQuoteContent(mailData, originalContentType)
break
case 'replyall':
_formValues = {
from: quotedMailSenderObj,
to: info?.replyToAll || orderReceiver,
cc: info?.MAI_CS || '',
// bcc: quote.bcc || '',
subject: `Re: ${info.MAI_Subject || ''}`,
..._form2
}
readyToInitialContent += generateQuoteContent(mailData, originalContentType)
break
case 'forward':
_formValues = {
from: quotedMailSenderObj,
subject: `Fw: ${info.MAI_Subject || ''}`,
// coli_sn: pageParam.oid,
..._form2
}
readyToInitialContent += generateQuoteContent(mailData, originalContentType)
break
case 'edit':
_formValues = {
from: quotedMailSenderObj,
to: info?.MAI_To || '',
cc: info?.MAI_CS || '',
subject: `${info.MAI_Subject || ''}`,
id: pageParam.quoteid,
mai_sn: pageParam.quoteid,
..._form2
}
readyToInitialContent = generateMailContent(mailData)
setFileList(mailData.attachments.map(ele => ({ uid: ele.ATI_SN, name: ele.ATI_Name, url: ele.ATI_ServerFile, fullPath: `${EMAIL_ATTA_HOST}${ele.ATI_ServerFile}` })))
break
case 'new':
_formValues = {
...templateFormValues,
from: quotedMailSenderObj,
to: orderReceiver || info?.MAI_To || '',
subject: `${info.MAI_Subject || templateFormValues.subject || ''}`,
..._form2,
}
readyToInitialContent = generateMailContent({ content: templateContent.bodycontent || readyToInitialContent || `<p></p><br>${signatureBody}` || '' })
// setFileList(mailData.attachments.map(ele => ({ uid: ele.ATI_SN, name: ele.ATI_Name, url: ele.ATI_ServerFile, fullPath: `${EMAIL_ATTA_HOST}${ele.ATI_ServerFile}` })))
setIsRichText(true)
break
default:
break
}
// olog('222', _formValues, pageParam.action)
form.setFieldsValue(_formValues) // todo: from
setInitialContent(readyToInitialContent);
return () => {}
}, [orderDetail.order_no, quoteLoading, loadingTamplate, emailAccount, signature])
// const readFromTemplate = () => {
// const { mailcontent, ...templateFormValues } = templateContent;
// if (mailcontent) {
// const _findMatOld = emailListOPIMapped?.[orderDetail.opi_sn]
// const _from = _findMatOld?.email || ''
// form.setFieldsValue({...templateFormValues, to: orderDetail?.contact?.[0]?.email || '', from1: { key: _from, value: _from, label: _from}});
// // setNewFromEmail(_from);
// // setEmailOPI(draftFormsValues.opi_sn)
// requestAnimationFrame(() => {
// setInitialContent(mailcontent);
// });
// }
// }
useEffect(() => {
// readMailboxLocalCache()
if (loadingTamplate) {
notification.open({
key: editorKey,
placement: 'top',
// message: '',
description: '正在加载邮件模板...',
duration: 0,
icon: <LoadingOutlined />,
// actions: btn,
// closeIcon: null,
closable: false,
})
} else {
notification.destroy(editorKey)
}
// readFromTemplate();
return () => {}
}, [loadingTamplate])
const handleSwitchEmail = (value) => {
// const { value } = labelValue
// setNewFromEmail(value)
const _findMat = emailListAddrMapped?.[value]
form.setFieldsValue({ mat_sn: _findMat?.mat_sn, opi_sn: _findMat?.opi_sn })
// console.log(_findMat, 'handleSwitchEmail')
setEmailAccount(_findMat)
setEmailAccountOPI(_findMat?.opi_sn)
}
const [isRichText, setIsRichText] = useState(mobile === false)
// const [isRichText, setIsRichText] = useState(false); //
const [htmlContent, setHtmlContent] = useState('')
const [textContent, setTextContent] = useState('')
const [showCc, setShowCc] = useState(true)
const [showBcc, setShowBcc] = useState(false)
const handleShowCc = () => {
setShowCc(true)
}
const handleShowBcc = () => {
setShowBcc(true)
}
const handleEditorChange = ({ editorStateJSON, htmlContent, textContent }) => {
const _text = textContent.replace(/\r\n/g, '\n').replace(/\n{2,}/g, '\n')
// console.log('textContent---\n', textContent, 'textContent');
// console.log('html', html);
setHtmlContent(htmlContent)
setTextContent(_text)
form.setFieldValue('content', htmlContent)
const abstract = _text;
// const { bodyText: abstract } = parseHTMLString(htmlContent, true);
// form.setFieldValue('abstract', getAbstract(textContent))
const formValues = omitEmpty(form.getFieldsValue());
if (!isEmpty(formValues)) {
debouncedSave({ ...form.getFieldsValue(), htmlContent, abstract, })
}
}
const [openPlainTextConfirm, setOpenPlainTextConfirm] = useState(false)
const handlePlainTextOpenChange = ({ target }) => {
const { value: newChecked } = target
if (newChecked === true) {
setIsRichText(true)
setOpenPlainTextConfirm(false)
return
}
setOpenPlainTextConfirm(true)
}
const confirmPlainText = () => {
setIsRichText(false)
setOpenPlainTextConfirm(false)
}
// :
// 1. ~~
// 2.
const [fileList, setFileList] = useState([])
// const handleChange = (info) => {
// let newFileList = [...info.fileList]
// // 2. Read from response and show file link
// newFileList = newFileList.map((file) => {
// if (file.response) {
// // Component will show file.url as link
// file.url = file.response.url
// }
// return file
// })
// setFileList(newFileList)
// }
const normFile = (e) => {
// console.log('Upload event:', e);
if (Array.isArray(e)) {
return e
}
return e?.fileList
}
const uploadProps = {
// action: 'https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload',
// onChange: handleChange,
multiple: true,
fileList,
beforeUpload: (file) => {
// console.log('beforeUpload', file);
const lastDotIndex = file.name.lastIndexOf('.')
const extension = file.name.slice(lastDotIndex).toLocaleLowerCase()
if (disallowedAttachmentTypes.includes(file.type) || disallowedAttachmentTypes.includes(extension)) {
message.warning('不支持的文件格式: ' + extension)
return false
}
setFileList((prev) => [...prev, file])
return false // ,
},
onRemove: async (file) => {
console.log('onRomove', file)
if (file.fullPath) {
try {
const x = await deleteEmailAttachmentAction([file.uid]);
message.success(`已删除 ${file.name}`, 2)
} catch (error) {
console.error(error)
notification.error({
key: editorKey,
message: '删除失败',
description: error.message,
placement: 'top',
duration: 3,
})
return false;
}
}
const index = fileList.indexOf(file)
const newFileList = fileList.slice()
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('', file.uid, POPUP_FEATURES)
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.fullPath) {
openPopup(file.fullPath, file.uid)
}
else 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 [sendLoading, setSendLoading] = useState(false)
const onHandleSaveOrSend = async (isDraft = false) => {
// console.log('onSend callback', '\nisRichText', isRichText);
// console.log(form.getFieldsValue());
const body = structuredClone(form.getFieldsValue())
body.attaList = fileList;
// console.log('body', body, '\n', fileList);
const values = await form.validateFields()
// const preQuoteBody = !['edit', 'new'].includes(pageParam.action) && pageParam.quoteid ? (quoteContent ? quoteContent : generateQuoteContent(mailData, isRichText)) : ''
body.mailcontent = isRichText ? EmailBuilder({ subject: values.subject, content: htmlContent }) : textContent
body.cc = values.cc || ''
body.bcc = values.bcc || ''
body.bcc = values.mailtype || ''
setSendLoading(!isDraft)
notification.open({
key: editorKey,
placement: 'top',
// message: '',
description: '正在保存...',
duration: 0,
icon: <LoadingOutlined className='text-primary' />,
closable: false,
})
// body.externalID = stickToCid
// body.actionID = `${stickToCid}.${msgObj.id}`
body.contenttype = isRichText ? 'text/html' : 'text/plain'
try {
// console.log('postSendEmail', body, '\n');
// console.log('🎈postSendEmail mailContent', body.mailcontent, '\n');
// throw new Error('test')
// return;
const mailSavedId = await postEmailSaveOrSend(body, isDraft)
form.setFieldsValue({
mai_sn: mailSavedId,
id: mailSavedId,
})
// bubbleMsg.email.mai_sn = mailSavedId
// setSendLoading(false);
if (!isDraft) {
notification.success({
key: editorKey,
message: '成功',
description: isDraft ? '' : '窗口将自动关闭...',
placement: 'top',
duration: 2,
showProgress: true,
pauseOnHover: true,
onClose: () => {
deleteIndexDBbyKey(editorKey, 'draft', 'mailbox');
isDraft ? false : window.close();
},
})
} else { notification.destroy(editorKey) }
// setOpen(false)
} catch (error) {
console.error(error)
notification.error({
key: editorKey,
message: '邮件保存失败',
description: error.message,
placement: 'top',
duration: 3,
})
} finally {
setSendLoading(false)
}
}
const [openDrawerSnippet] = useSnippetStore((state) => [state.openDrawer])
const idleCallbackId = useRef(null)
const debouncedSave = useCallback(
debounce((data) => {
idleCallbackId.current = window.requestIdleCallback(() => {
console.log('Saving data (idle, debounced):', data)
writeIndexDB([{ ...data, key: editorKey }], 'draft', 'mailbox')
})
}, 1500), // 1.5s
[],
)
useEffect(() => {
return () => {
if (idleCallbackId.current && window.cancelIdleCallback) {
window.cancelIdleCallback(idleCallbackId.current)
}
}
}, [debouncedSave])
const onEditChange = (changedValues, allValues) => {
// console.log('onEditChange', changedValues, '\n', allValues)
if ('from' in changedValues) {
handleSwitchEmail(allValues.from);
}
}
return (
<>
<ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}>
<Form
form={form}
onValuesChange={onEditChange}
// onFinishFailed={onFinishFailed}
preserve={false}
name={`email_max_form`}
size='small'
layout={'inline'}
variant={'borderless'}
// initialValues={{}}
// onFinish={() => {}}
className='email-editor-wrapper *:mb-2 *:border-b *:border-t-0 *:border-x-0 *:border-indigo-100 *:border-solid '
requiredMark={false}
// labelCol={{ span: 3 }}
>
<div className='w-full flex flex-wrap gap-2 justify-start items-center text-indigo-600 pb-1 mb-2 border-x-0 border-t-0 border-b border-solid border-neutral-200'>
<Button type='primary' size='middle' onClick={() => onHandleSaveOrSend()} loading={sendLoading} icon={<SendOutlined />}>
发送
</Button>
<Form.Item name={'from'} rules={[{ required: true, message: '请选择发件地址' }]} >
<Select labelInValue={false} options={emailListOption} labelRender={item => `发件人: ${item?.label || '选择'}`} variant={'borderless'} placeholder='发件人: 选择' className='[&_.ant-select-selection-item]:font-bold [&_.ant-select-selection-placeholder]:font-bold [&_.ant-select-selection-placeholder]:text-black' classNames={{popup: {root:'min-w-60'}}} />
</Form.Item>
{/* <div className="ant-form-item-explain-error text-red-500" >请选择发件地址</div> */}
<div className='ml-auto'></div>
<span>{orderDetail.order_no}</span>
<span>{templateContent.mailtypeName}</span>
<Popconfirm trigger1={['hover', 'click']}
description='切换内容为纯文本格式将丢失信件和签名的格式, 确定使用纯文本?'
onConfirm={confirmPlainText}
open={openPlainTextConfirm}
onCancel={() => setOpenPlainTextConfirm(false)}>
{/* <Checkbox checked={!isRichText} onChange={handlePlainTextOpenChange}>
纯文本
</Checkbox> */}
{/* <Button type='link' size='small' icon={<TextIcon />} className=' ' >纯文本</Button> */}
<Radio.Group options={[{label: '纯文本', value: false}, {label: '富文本', value: true}]} optionType="button" buttonStyle="solid" onChange={handlePlainTextOpenChange} value={isRichText} size='small' />
</Popconfirm>
<Button onClick={() => onHandleSaveOrSend(true)} type='dashed' icon={<SaveOutlined />} size='small' className='' >存草稿</Button>
</div>
<Form.Item className='w-full'>
<Space.Compact className='w-full'>
<Form.Item name={'to'} label='收件人' rules={[{ required: true }]} className='!flex-1'>
<Input className='w-full' />
</Form.Item>
<Flex gap={4}>
{!showCc && (
<Button type='text' onClick={handleShowCc}>
抄送
</Button>
)}
{!showBcc && (
<Button type='text' hidden={showBcc} onClick={handleShowBcc}>
密送
</Button>
)}
</Flex>
</Space.Compact>
</Form.Item>
<Form.Item label='抄&nbsp;&nbsp;&nbsp;&nbsp;送' 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'>
<Input />
</Form.Item>
<Form.Item label='主&nbsp;&nbsp;&nbsp;&nbsp;题' 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}>
<Flex justify='space-between'>
<Upload {...uploadProps} name='file' className='w-full [&_.ant-upload-list-item-name]:cursor-pointer'>
<Button icon={<UploadOutlined />}>附件</Button>
</Upload>
<Flex align={'center'} className='absolute right-0'>
<Divider type='vertical' />
<Button type={'link'} onClick={() => openDrawerSnippet()}>
图文集
</Button>
<PaymentlinkBtn type={'link'} />
{/* 更多工具 */}
{/* <Popover
content={
<div className='flex flex-col gap-2'>
<Button type={'link'}>??</Button>
</div>
}
trigger='click'
><MoreOutlined /></Popover> */}
</Flex>
</Flex>
</Form.Item>
<Form.Item name='content' hidden>
<Input />
</Form.Item>
<Form.Item name='abstract' hidden>
<Input />
</Form.Item>
<Form.Item name='id' hidden>
<Input />
</Form.Item>
<Form.Item name='mai_sn' hidden>
<Input />
</Form.Item>
<Form.Item name='mat_sn' hidden>
<Input />
</Form.Item>
<Form.Item name='coli_sn' hidden>
<Input />
</Form.Item>
<Form.Item name='opi_sn' hidden>
<Input />
</Form.Item>
<Form.Item name='mailtype' hidden>
<Input />
</Form.Item>
</Form>
<LexicalEditor {...{ isRichText }} onChange={handleEditorChange} defaultValue={initialContent} />
{/* {!isEmpty(Number(pageParam.quoteid)) && pageParam.action!=='edit' && !showQuoteContent && (
<div className='flex justify-start items-center ml-2'>
<Button className='flex gap-2 ' type='link' onClick={() => {
setShowQuoteContent(!showQuoteContent);
setInitialContent(pre => pre + generateQuoteContent(mailData))
}}>
显示引用内容
</Button>
</div>
)}
{showQuoteContent && (
<blockquote
// contentEditable
className='border-0 outline-none cursor-text'
onBlur={(e) => setQuoteContent(`<blockquote>${e.target.innerHTML}</blockquote>`)}
dangerouslySetInnerHTML={{ __html: generateQuoteContent(mailData) }}></blockquote>
)} */}
</ConfigProvider>
</>
)
}
export default NewEmail

@ -45,7 +45,7 @@ function GeneratePayment() {
const orderNumber = result.orderDetail.order_no const orderNumber = result.orderDetail.order_no
const travelAdvisor = loginUser.accountList.length > 0 ? (loginUser.accountList[0]?.OPI_NameEN || '') : '' const travelAdvisor = loginUser.accountList.length > 0 ? (loginUser.accountList[0]?.OPI_NameEN || '') : ''
generateForm.setFieldsValue({ generateForm.setFieldsValue({
notifyEmail: getPrimaryEmail(), // notifyEmail: getPrimaryEmail(),
orderNumber: orderNumber, orderNumber: orderNumber,
description: 'Tracking Code: ' + orderNumber + '\r\nTravel Advisor: ' + travelAdvisor + '\r\nContent: \r\n', description: 'Tracking Code: ' + orderNumber + '\r\nTravel Advisor: ' + travelAdvisor + '\r\nContent: \r\n',
langauge: 'US', langauge: 'US',
@ -60,7 +60,7 @@ function GeneratePayment() {
// }) // })
} else { } else {
generateForm.setFieldsValue({ generateForm.setFieldsValue({
notifyEmail: getPrimaryEmail(), // notifyEmail: getPrimaryEmail(),
description: 'Tracking Code: \r\nTravel Advisor: \r\nContent: \r\n', description: 'Tracking Code: \r\nTravel Advisor: \r\nContent: \r\n',
langauge: 'US', langauge: 'US',
orderType: '227001', orderType: '227001',
@ -170,9 +170,6 @@ function GeneratePayment() {
<Form.Item label='描述' name='description'> <Form.Item label='描述' name='description'>
<Input.TextArea rows={4} /> <Input.TextArea rows={4} />
</Form.Item> </Form.Item>
<Form.Item label='通知邮箱' name='notifyEmail'>
<Input />
</Form.Item>
</Form> </Form>
<Flex gap='middle' justify='center'> <Flex gap='middle' justify='center'>
<Button type='primary' loading={isHtmlLoading} onClick={handleGenerate}> <Button type='primary' loading={isHtmlLoading} onClick={handleGenerate}>

@ -113,7 +113,7 @@ function SnippetList() {
snippetForm.resetFields() snippetForm.resetFields()
setSnippetModalOpen(false)} setSnippetModalOpen(false)}
} }
destroyOnClose destroyOnHidden
forceRender forceRender
modalRender={(dom) => ( modalRender={(dom) => (
<Form <Form
@ -210,7 +210,7 @@ function SnippetList() {
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
<Row gutter={6} className=' ' style={{height: 'calc(100vh - 196px)'}}> <Row gutter={6} className=' ' style={{height: '591px'}}>
<Col span={8} className='h-[inherit] overflow-x-hidden overflow-y-auto'> <Col span={8} className='h-[inherit] overflow-x-hidden overflow-y-auto'>
<List <List
bordered bordered

@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
import { Form, Input, Button, DatePicker, Select, Table } from 'antd' import { Form, Input, Button, DatePicker, Select, Table } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ReadIcon, DeliverIcon, SentIcon, WaitingIcon, FailedIcon } from '@/components/Icons' import { ReadIcon, DeliverIcon, SentIcon, WaitingIcon, FailedIcon } from '@/components/Icons'
import { MessageTwoTone } from '@ant-design/icons'
import useCustomerRelationStore from '@/stores/CustomerRelationStore' import useCustomerRelationStore from '@/stores/CustomerRelationStore'
const { RangePicker } = DatePicker const { RangePicker } = DatePicker
@ -40,22 +41,28 @@ const Index = () => {
// crt_template msg_status // crt_template msg_status
const templateStatusCount = {} const templateStatusCount = {}
tasksList.forEach((item) => { tasksList.forEach((item) => {
const { crt_template, msg_status } = item const { crt_template, msg_status, msg_reply } = item
if (msg_status !== null && msg_status !== undefined) { if (msg_status !== null && msg_status !== undefined) {
if (!templateStatusCount[crt_template]) { if (!templateStatusCount[crt_template]) {
templateStatusCount[crt_template] = {} templateStatusCount[crt_template] = {}
templateStatusCount[crt_template]['msgreply'] = 0
} }
if (!templateStatusCount[crt_template][msg_status]) { if (!templateStatusCount[crt_template][msg_status]) {
templateStatusCount[crt_template][msg_status] = 0 templateStatusCount[crt_template][msg_status] = 0
} }
templateStatusCount[crt_template][msg_status]++ templateStatusCount[crt_template][msg_status]++
if (msg_reply) {
templateStatusCount[crt_template]['msgreply']++
}
} }
}) })
// //
const groupedResult = {} const groupedResult = {}
for (const template in templateStatusCount) { for (const template in templateStatusCount) {
const total = Object.values(templateStatusCount[template]).reduce((acc, val) => acc + val, 0) //msgreplymsgreply
const total = Object.values(templateStatusCount[template]).reduce((acc, val) => acc + val, 0) - templateStatusCount[template]['msgreply']
groupedResult[template] = {} groupedResult[template] = {}
for (const status in templateStatusCount[template]) { for (const status in templateStatusCount[template]) {
const count = templateStatusCount[template][status] const count = templateStatusCount[template][status]
@ -82,14 +89,17 @@ const Index = () => {
key: 'crt_template', key: 'crt_template',
}) })
const allStatuses = new Set() const allStatuses = new Set(['msgreply'])
tasksList.forEach((item) => { tasksList.forEach((item) => {
if (item.msg_status !== null && item.msg_status !== undefined) { if (item.msg_status !== null && item.msg_status !== undefined) {
allStatuses.add(item.msg_status) allStatuses.add(item.msg_status)
} }
}) })
allStatuses.forEach((status) => { // Set
const sortedStatuses = Array.from(allStatuses).sort()
sortedStatuses.forEach((status) => {
groupedColumns.push({ groupedColumns.push({
title: `${status} 计数`, title: `${status} 计数`,
dataIndex: `${status}_count`, dataIndex: `${status}_count`,
@ -160,7 +170,7 @@ const Index = () => {
const icon = statusIconMap[record.msg_status] || statusIconMap['default'] const icon = statusIconMap[record.msg_status] || statusIconMap['default']
return ( return (
<Link to={`/order/chat/${record.crt_coli_sn}`} title={record.errors_code ? record.errors_code + '' + record.errors_title : ''}> <Link to={`/order/chat/${record.crt_coli_sn}`} title={record.errors_code ? record.errors_code + '' + record.errors_title : ''}>
查看会话 {icon} 查看会话 {icon} {record.msg_reply ? <MessageTwoTone title='已回复' /> : ''}
</Link> </Link>
) )
} }

@ -1,373 +1,200 @@
import { Conditional } from '@/components/Conditional'
import useAuthStore from '@/stores/AuthStore' import useAuthStore from '@/stores/AuthStore'
import { PERM_IMPORT_EMAIL } from '@/stores/AuthStore' import { pick } from '@/utils/commons'
import useFormStore from '@/stores/FormStore' import { UnorderedListOutlined, LeftOutlined } from '@ant-design/icons'
import { useOrderStore } from '@/stores/OrderStore' import { Flex, Segmented, Tree, Typography, Layout, Splitter, Button, Tooltip, Badge } from 'antd'
import { copy, isNotEmpty, isEmpty } from '@/utils/commons' import { useEffect, useMemo, useState } from 'react'
import { WhatsAppOutlined, ImportOutlined } from '@ant-design/icons' import EmailDetailInline from '../Conversations/Online/Components/EmailDetailInline'
import { App, Badge, Empty, Flex, Button, Radio, Space, Switch, Table, Tabs, Divider, Tag, Tooltip } from 'antd' import OrderProfile from '@/components/OrderProfile'
import dayjs from 'dayjs' import Mailbox from './components/Mailbox'
import { useCallback, useEffect, useState } from 'react' import useConversationStore from '@/stores/ConversationStore';
import { Link } from 'react-router-dom' import { MailboxDirIcon } from './components/MailboxDirIcon'
import { useShallow } from 'zustand/react/shallow'
import { UNREAD_MARK } from '@/actions/ConversationActions' const deptMap = new Map([
import AdvanceSearchForm from './AdvanceSearchForm' ['1', 'CH'], // CH
['2', 'CH大客户组'],
['7', '市场推广'],
['8', '德语市场'],
['9', '日语市场'],
['10', '商旅市场'],
['11', '法语市场'],
['12', '西语市场'],
['13', '英文在线组'],
['14', '商务Biztravel'],
['15', 'CH产品'],
['16', 'APP移动项目组'],
['17', 'ChinaTravel组'],
['18', 'CT市场'],
['20', '俄语市场'],
['21', '意语市场'],
['22', '爱游网'],
['23', '三峡站'],
['24', '桂林站'],
['25', '上海站'],
['26', '北京站'],
['27', '西藏站'],
['28', 'AH'], // AH
['29', 'DMC地接组'],
['30', 'Trippest项目组'], //
['31', '花梨鹰'],
['32', 'Daytours板块'],
['33', 'GH'], // GH
['34', 'trippest网站'],
['35', 'newsletter营销'],
])
function OrderGroupTable({ formValues }) { function Follow() {
const handleImportEmail = useCallback((order) => {
importEmailMessage({ orderId: order.COLI_SN, orderNumber: order.COLI_ID })
.then((r) => {
console.info(r)
notification.info({
message: 'Notification',
description: '订单:' + order.COLI_ID + ' 导入' + r.result.length + '条邮件消息',
placement: 'top',
})
})
.catch((ex) =>
notification.error({
message: 'Notification',
description: ex.message,
placement: 'top',
duration: 4,
}),
)
}, [])
const orderColumns = [
{
title: '订单号',
dataIndex: 'COLI_ID',
width: 222,
render: (text, record) => {
let tagIcon = ''
if (record.COLI_LineGrade === 240003) tagIcon = <Tag color='red'>重点</Tag>
else if (record.COLI_LineGrade === 240002) tagIcon = <Tag color='green'>次重点</Tag>
else if (record.COLI_LineGrade === 240001) tagIcon = <Tag color='blue'>一般</Tag>
return (
<Space>
<Conditional
condition={isPermitted(PERM_IMPORT_EMAIL)}
whenTrue={<Button type='default' shape='round' icon={<ImportOutlined />} size='small' onClick={() => handleImportEmail(record)} />}
/>
<Link to={`/order/chat/${record.COLI_SN}`} state={record}>
{text}
</Link>
{tagIcon}
</Space>
)
},
},
{
title: '客人姓名',
dataIndex: 'coli_guest',
render: (text, record) => {
let regularText = ''
if (record.buytime > 0) regularText = '(R' + record.buytime + ')'
return (
<Space>
<Conditional
condition={isPermitted(record.coli_guest_WhatsApp)}
whenTrue={
<Tooltip title={record.coli_guest_WhatsApp}>
<WhatsAppOutlined className={['pl-1', record.last_received_time ? 'text-whatsapp' : 'text-neutral-500']} />
</Tooltip>
}
/>
{text + regularText}
<Badge
count={record.unread_msg >= UNREAD_MARK ? ' ' : record.unread_msg}
style={{
backgroundColor: '#52c41a',
}}
/>
</Space>
)
},
},
{
title: '订单状态',
dataIndex: 'COLI_State',
width: 140,
render: (text, record) => {
let extra = ''
if (record.coli_ordertype === 3 || record.coli_ordertype === 4 || record.coli_ordertype === 5) {
extra = '(' + (record.coli_ordertype - 2) + '催)'
}
return ( const [collapsed, setCollapsed] = useState(true)
<Space>
{extra}
{text}
</Space>
)
},
},
{
title: '报价 Title',
dataIndex: 'lettertitle',
ellipsis: true,
hidden: false,
},
{
title: '出发日期',
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: '客人最后一次回复时间',
dataIndex: 'last_received_time',
width: 180,
render: (text, record) => {
if (record.last_received_time) {
return new dayjs(record.last_received_time).format('YYYY-MM-DD HH:mm:ss')
}
},
},
{
title: '附加信息',
ellipsis: true,
dataIndex: 'COLI_Introduction',
},
]
const { notification } = App.useApp()
const [loading, setLoading] = useState(false)
const orderList = useOrderStore((state) => state.orderList)
const fetchOrderList = useOrderStore((state) => state.fetchOrderList)
const importEmailMessage = useOrderStore((state) => state.importEmailMessage)
const [loginUser, isPermitted] = useAuthStore((state) => [state.loginUser, state.isPermitted]) const [loginUser, isPermitted] = useAuthStore((state) => [state.loginUser, state.isPermitted])
const { accountList } = loginUser
useEffect(() => { const accountListDEIMapped = useMemo(() => accountList.reduce((a, c) => ({ ...a, [c.OPI_DEI_SN]: c }), {}), [accountList])
let canSearch = true const accountDEI = useMemo(() => {
return accountList.map((ele) => ({ key: ele.OPI_DEI_SN, value: ele.OPI_DEI_SN, label: deptMap.get(`${ele.OPI_DEI_SN}`) }))
if (formValues.type === 'advance') { }, [accountList])
const copyObject = copy(formValues)
delete copyObject.type const [getOPIEmailDir] = useConversationStore(state => [state.getOPIEmailDir]);
const allEmpty = Object.values(copyObject).every((val) => { const [currentMailboxDEI, setCurrentMailboxDEI, mailboxNestedDirsActive] = useConversationStore(state => [state.currentMailboxDEI, state.setCurrentMailboxDEI, state.mailboxNestedDirsActive]);
return val === null || val === '' || val === undefined const [currentMailboxOPI, setCurrentMailboxOPI] = useConversationStore(state => [state.currentMailboxOPI, state.setCurrentMailboxOPI]);
}) const [mailboxActiveNode, setMailboxActiveNode] = useConversationStore(state => [state.mailboxActiveNode, state.setMailboxActiveNode]);
if (allEmpty) { const [activeEmailId, setActiveEmailId] = useConversationStore(state => [state.mailboxActiveMAI, state.setMailboxActiveMAI]);
canSearch = false const [mailboxActiveCOLI, setMailboxActiveCOLI] = useConversationStore(state => [state.mailboxActiveCOLI, state.setMailboxActiveCOLI]);
notification.warning({
message: '温馨提示', const computedBreadcrumb = useMemo(() => {
description: '请输入至少一个条件', const { title, iconIndex, parentTitle, parentIconIndex } = mailboxActiveNode
placement: 'top', const x = [
duration: 60, { title: parentTitle, iconIndex: parentIconIndex },
}) { title, iconIndex },
} ].filter((ele) => ele.title)
} return x
}, [mailboxActiveNode.VKey])
if (canSearch) {
setLoading(true) const [expandTree, setExpandTree] = useState([])
fetchOrderList(formValues, loginUser)
.finally(() => setLoading(false)) const handleSwitchAccount = (value) => {
.catch((reason) => { setActiveEmailId(0);
notification.error({ // setExpandTree([]);
message: '查询出错',
description: reason.message, setCurrentMailboxDEI(value)
placement: 'top', const opi = accountListDEIMapped[value].OPI_SN
duration: 60, getOPIEmailDir(opi)
}) setCurrentMailboxOPI(opi);
})
}
}, [formValues])
const paginationProps = {
showQuickJumper: true,
showLessItems: true,
showSizeChanger: true,
showTotal: (total) => {
return `总数:${total}`
},
}
function groupByParam(array, param) {
return array.reduce((result, item) => {
;(result[item[param]] = result[item[param]] || []).push(item)
return result
}, {})
} }
const deptMap = new Map([ const handleTreeSelectGetMails = (selectedKeys, { node }) => {
['1', 'CH直销组'], // console.info('selectedTreeKeys: ', node)
['2', 'CH大客户组'], const treeNode = pick(node, ['key', 'parent', 'iconIndex', 'getMails', 'title', 'parentTitle', 'parentIconIndex' ]);
['7', '市场推广'], const { COLI_SN, VKey, VParent, ApplyDate, OrderSourceType, IsTrue } = node?._raw || {}
['8', '德语市场'], if (VKey && !(!IsTrue && !COLI_SN)) {
['9', '日语市场'], setMailboxActiveNode({...treeNode, ...node._raw, key: treeNode.key, OPI_SN: currentMailboxOPI});
['10', '商旅市场'], setActiveEmailId(0);
['11', '法语市场'], setMailboxActiveCOLI(COLI_SN);
['12', '西语市场'],
['13', '英文在线组'],
['14', '商务Biztravel'],
['15', 'CH产品'],
['16', 'APP移动项目组'],
['17', 'ChinaTravel组'],
['18', 'CT市场'],
['20', '俄语市场'],
['21', '意语市场'],
['22', '爱游网'],
['23', '三峡站'],
['24', '桂林站'],
['25', '上海站'],
['26', '北京站'],
['27', '西藏站'],
['28', 'AH亚洲项目组'],
['29', 'DMC地接组'],
['30', 'Trippest项目组'],
['31', '花梨鹰'],
['32', 'Daytours板块'],
['33', 'GH项目组'],
['34', 'trippest网站'],
['35', 'newsletter营销'],
])
const groupOrderData = groupByParam(orderList, 'OPI_DEI_SN')
const deptKeys = Object.keys(groupOrderData)
const deptItems = []
deptKeys.forEach((deptNo, index) => {
const deptOrderList = groupOrderData[deptNo]
// 123456coli_ordertype=7coli_ordertype=8
const newOrderList = deptOrderList.filter((o) => {
return o.coli_ordertype === 1
})
const newMsgList = deptOrderList.filter((o) => {
return o.coli_ordertype === 2 || o.coli_ordertype === 6
})
const followUpList = deptOrderList.filter((o) => {
return o.coli_ordertype === 3 || o.coli_ordertype === 4 || o.coli_ordertype === 5
})
const entryList = deptOrderList.filter((o) => {
return o.coli_ordertype === 7
})
const paymentList = deptOrderList.filter((o) => {
return o.coli_ordertype === 8
})
if (formValues.type === 'today') {
deptItems.push({
key: index,
label: deptMap.get(deptNo),
children: (
<>
<Divider orientation='left'>新订单</Divider>
<Conditional
condition={newOrderList.length > 0}
whenTrue={<Table key={'newOrderTable' + deptNo} loading={loading} dataSource={newOrderList} columns={orderColumns} pagination={newOrderList.length <= 10 ? false : paginationProps} />}
whenFalse={<Empty />}
/>
<Divider orientation='left'>新消息/老邮件</Divider>
<Conditional
condition={newMsgList.length > 0}
whenTrue={<Table key={'newMsgTable' + deptNo} loading={loading} dataSource={newMsgList} columns={orderColumns} pagination={newMsgList.length <= 10 ? false : paginationProps} />}
whenFalse={<Empty />}
/>
<Divider orientation='left'>催信</Divider>
<Conditional
condition={followUpList.length > 0}
whenTrue={<Table key={'followUpTable' + deptNo} loading={loading} dataSource={followUpList} columns={orderColumns} pagination={followUpList.length <= 10 ? false : paginationProps} />}
whenFalse={<Empty />}
/>
<Divider orientation='left'>余款收付</Divider>
<Conditional
condition={paymentList.length > 0}
whenTrue={<Table key={'paymentTable' + deptNo} loading={loading} dataSource={paymentList} columns={orderColumns} pagination={paymentList.length <= 10 ? false : paginationProps} />}
whenFalse={<Empty />}
/>
<Divider orientation='left'>入境提醒</Divider>
<Conditional
condition={entryList.length > 0}
whenTrue={<Table key={'entryTable' + deptNo} loading={loading} dataSource={entryList} columns={orderColumns} pagination={entryList.length <= 10 ? false : paginationProps} />}
whenFalse={<Empty />}
/>
</>
),
})
} else { } else {
deptItems.push({ const _expandTree = expandTree.includes(node.key) ? expandTree.filter(ele => ele !== node.key) : [...expandTree, ...selectedKeys]
key: index, setExpandTree(_expandTree)
label: deptMap.get(deptNo),
children: (
<>
<Table key={'advanceOrderTable' + deptNo} loading={loading} dataSource={deptOrderList} columns={orderColumns} pagination={deptOrderList.length <= 10 ? false : paginationProps} />
</>
),
})
} }
}) }
return <Conditional condition={orderList.length > 0} whenTrue={<Tabs defaultActiveKey={0} items={deptItems} />} whenFalse={<Empty />} />
}
function Follow() {
const [formValues, setFormValues] = useFormStore(useShallow((state) => [state.orderFollowForm, state.setOrderFollowForm]))
const [advanceChecked, toggleAdvance] = useFormStore(useShallow((state) => [state.orderFollowAdvanceChecked, state.setOrderFollowAdvanceChecked]))
const batchImportEmailMessage = useOrderStore((state) => state.batchImportEmailMessage) const [selectedEmail, setSelectedEmail] = useState({});
const [isPermitted] = useAuthStore((state) => [state.isPermitted]) const onClickEmailItem = (emailItem) => {
const mai_sn = emailItem.MAI_SN;
setActiveEmailId(mai_sn);
setMailboxActiveCOLI(emailItem.MAI_COLI_SN || 0)
const emailMsg = {
conversationid: '',
order_opi: currentMailboxOPI,
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)
};
const handleImportEmail = useCallback(() => { // 123456coli_ordertype=7coli_ordertype=8
batchImportEmailMessage() useEffect(() => {
}, []) const first = currentMailboxDEI || accountDEI[0].value
const opi = accountListDEIMapped[first].OPI_SN
setExpandTree(prev => [...[`${opi}-today`, `${opi}-todo`, `search-orders`, mailboxActiveNode?.VParent ]])
const handleSubmit = useCallback((values) => { return () => {}
setFormValues({ ...values, type: 'advance' }) }, [currentMailboxDEI, mailboxNestedDirsActive, mailboxActiveNode])
}, [])
return ( return (
<> <>
<Space direction='vertical' size='large' style={{ width: '100%' }}> <Layout>
<Flex gap='large' justify='start' align='center' horizontal='true'> <Layout.Sider width='300' theme='light' style={{ height: 'calc(100vh - 166px)' }} className=' relative'>
<Radio.Group <Flex justify='start' align='start' vertical className='h-full'>
options={[ <Segmented className='w-full' block shape='round' options={accountDEI} value={currentMailboxDEI} onChange={handleSwitchAccount} />
{ label: '今日任务', value: 'today' }, <div className='overflow-y-auto flex-auto w-full [&_.ant-tree-switcher]:me-0 [&_.ant-tree-node-content-wrapper]:px-0 [&_.ant-tree-node-content-wrapper]:text-ellipsis [&_.ant-tree-node-content-wrapper]:overflow-hidden [&_.ant-tree-node-content-wrapper]:whitespace-nowrap'>
{ label: '重点订单', value: 'zhongdian' }, <Tree
{ label: '次重点客户', value: 'qianli' }, className='[&_.ant-typography-ellipsis]:max-w-44 [&_.ant-typography-ellipsis]:min-w-36'
{ label: '成行', value: 'chengxing' }, key='sticky-today'
{ label: '走团中', value: 'zoutuan' }, blockNode
{ label: '走团后一月', value: 'zoutuanhou' }, showIcon
]} showLine
value={formValues.type} autoExpandParent={true}
onChange={({ target: { value } }) => { expandAction={'doubleClick'}
setFormValues({ onSelect={handleTreeSelectGetMails}
...formValues, selectedKeys={[mailboxActiveNode.key]}
type: value, onExpand={(expandedKeys) => setExpandTree(expandedKeys)}
}) expandedKeys={expandTree}
}} defaultExpandedKeys={expandTree}
optionType='button' treeData={mailboxNestedDirsActive}
buttonStyle='solid' icon={(node) => <MailboxDirIcon type={node?.iconIndex} />}
disabled={advanceChecked} titleRender={(node) => (
/> <Typography.Text ellipsis={{ tooltip: node.title }} className={`${node?._raw?.IsSuccess === 1 ? 'text-primary' : ''}`}>
<Switch {node.title}
checkedChildren='高级查询' <Badge size={'small'} count={node.count} offset={[3, 0]} style={{backgroundColor: "#1ba784", color1: '#1ba784'}} overflowCount={999} />
unCheckedChildren='高级查询' </Typography.Text>
checked={advanceChecked} )}
onChange={() => { />
toggleAdvance(!advanceChecked) </div>
}} </Flex>
/> </Layout.Sider>
<Layout.Content style={{ maxHeight: 'calc(100vh - 166px)', height: 'calc(100vh - 166px)', minWidth: '360px' }}>
<Conditional <Splitter>
condition={isPermitted(PERM_IMPORT_EMAIL)} <Splitter.Panel defaultSize='40%' min={380} max='70%'>
whenTrue={<Button type='default' shape='round' icon={<ImportOutlined />} size='small' onClick={handleImportEmail}/>} <Mailbox breadcrumb={computedBreadcrumb} mailboxDir={mailboxActiveNode} onMailItemClick={(item) => onClickEmailItem(item)} currentActiveMailItem={activeEmailId} />
</Splitter.Panel>
<Splitter.Panel>
<EmailDetailInline mailID={activeEmailId || 0} emailMsg={selectedEmail} variant={'outline'} size={'small'} onUpdated={(prop) => {}} autoMark={true} />
</Splitter.Panel>
</Splitter>
</Layout.Content>
<Tooltip title={(collapsed ? '展开' : '收起') + '订单信息'} placement='left'>
<Button
icon={collapsed ? <LeftOutlined /> : <UnorderedListOutlined />}
onClick={() => setCollapsed(!collapsed)}
className={`absolute z-10 rounded-none ${collapsed ? 'right-1 top-20 rounded-l-xl' : 'right-8 top-20 rounded-l'}`}
size={collapsed ? 'small' : 'middle'}
/> />
</Flex> </Tooltip>
<Conditional condition={advanceChecked} whenTrue={<AdvanceSearchForm onSubmit={handleSubmit} initialValues={formValues} />} /> <Layout.Sider
<OrderGroupTable formValues={formValues} /> width='280'
</Space> theme='light'
className='overflow-y-auto'
style={{
height: 'calc(100vh - 166px)',
}}
collapsible
collapsed={collapsed}
onCollapse={(value) => setCollapsed(value)}
collapsedWidth={0}
trigger={null}
reverseArrow={true}>
<OrderProfile coliSN={mailboxActiveCOLI} />
</Layout.Sider>
</Layout>
</> </>
) )
} }

@ -0,0 +1,217 @@
import { useEffect, useState } from 'react'
import { ReloadOutlined, ReadOutlined, RightOutlined, LeftOutlined, SearchOutlined, MailOutlined, DeleteOutlined, CloseOutlined, CloseCircleTwoTone, CloseCircleOutlined } from '@ant-design/icons'
import { Flex, Button, Tooltip, List, Form, Row, Col, Input, Checkbox, DatePicker, Switch, Breadcrumb, Skeleton, Popconfirm } from 'antd'
import { useEmailList } from '@/hooks/useEmail'
import { isEmpty } from '@/utils/commons'
import { MailboxDirIcon } from './MailboxDirIcon'
import { AttachmentIcon, MailCheckIcon, MailOpenIcon } from '@/components/Icons'
import NewEmailButton from './NewEmailButton'
import MailOrderSearchModal from './MailOrderSearchModal'
import MailListSearchModal from './MailListSearchModal'
const PAGE_SIZE = 50 //
const MailBox = ({ mailboxDir, onMailItemClick, ...props }) => {
const [selectedItems, setSelectedItems] = useState([])
const { mailList, loading, error, tempBreadcrumb, refresh, markAsUnread, markAsProcessed, markAsDeleted, } = useEmailList(mailboxDir)
const [pagination, setPagination] = useState({
current: 1,
pageSize: PAGE_SIZE,
total: 0,
pagedList: [],
})
useEffect(() => {
if (mailList) {
const total = mailList.length
const pageCount = Math.ceil(total / PAGE_SIZE)
setPagination((prev) => ({
...prev,
total,
pageCount,
current: 1, //
pagedList: getPagedData(mailList, 1),
}))
}
}, [mailList])
const getPagedData = (data, currentPage) => {
const startIndex = (currentPage - 1) * PAGE_SIZE
const endIndex = Math.min(startIndex + PAGE_SIZE, data.length)
return data.slice(startIndex, endIndex)
}
const prePage = () => {
if (pagination.current > 1) {
const newCurrent = pagination.current - 1
setPagination((prev) => ({
...prev,
current: newCurrent,
pagedList: getPagedData(mailList, newCurrent),
}))
}
}
const nextPage = () => {
if (pagination.current < Math.ceil(pagination.total / PAGE_SIZE)) {
const newCurrent = pagination.current + 1
setPagination((prev) => ({
...prev,
current: newCurrent,
pagedList: getPagedData(mailList, newCurrent),
}))
}
}
const mailItemRender = (item) => {
const isOrderNode = mailboxDir.COLI_SN > 0
const orderNumber = isEmpty(item.MAI_COLI_ID) || isOrderNode ? '' : item.MAI_COLI_ID + ' - '
const folderName = (item.showFolder) ? `[${item.FDir}] ` : ''
const orderMailType = item.MAT_Name ? <span className='text-neutral-600 text-xs'>{item.MAT_Name}</span> : ''
const countryName = isEmpty(item.CountryCN) ? '' : '[' + item.CountryCN + '] '
const mailStateClass = item.MOI_ReadState === 0 ? 'font-bold' : ''
const hasAtta = item.MAI_Attachment !== 0 ? <AttachmentIcon className='text-blue-500' /> : null
return (
<li
className={`flex border border-solid border-t-0 border-x-0 border-gray-200 hover:bg-neutral-50 active:bg-gray-200 p-2 ${props.currentActiveMailItem === item.MAI_SN ? 'bg-neutral-100' : ''}`}>
<div className=''>
<Checkbox
checked={selectedItems.some((i) => i.MAI_SN === item.MAI_SN)}
onClick={(e) => {
const isChecked = e.target.checked
const updatedSelection = isChecked ? [...selectedItems, item] : selectedItems.filter((item) => item.MAI_SN !== item.MAI_SN)
setSelectedItems(updatedSelection)
}}></Checkbox>
</div>
<div
className='flex-1 pl-2'
onClick={() => {
onMailItemClick(item)
}}>
<Flex gap='small' vertical={true} justify='space-between' className='cursor-pointer'>
<div>
{folderName}{orderNumber}
<span className={mailStateClass}>{item.MAI_Subject || '[无主题]'}</span>
{hasAtta}
</div>
<Flex gap='small' align='center' justify='flex-end' wrap className='text-neutral-500 text-wrap break-words break-all '>
<span className='mr-auto'>{countryName + item.SenderReceiver}</span>
{orderMailType}
<span className=''>{item.SRDate}</span>
</Flex>
</Flex>
</div>
</li>
)
}
return (
<div className='h-full flex flex-col gap-1 bg-white'>
<div className='bg-white h-auto px-1 flex gap-1 items-center'>
<Flex wrap gap='middle' justify={'center'} className='min-w-30 px-1'>
<Tooltip title='全选'>
<Checkbox
indeterminate={selectedItems.length > 0 && selectedItems.length < pagination.pagedList.length}
checked={pagination.pagedList.length === 0 ? false : pagination.pagedList.every((item) => selectedItems.some((selected) => selected.MAI_SN === item.MAI_SN))}
onChange={(e) => {
const isChecked = e.target.checked
if (isChecked) {
setSelectedItems((prev) => [...prev, ...pagination.pagedList])
} else {
setSelectedItems([])
}
}}></Checkbox>
</Tooltip>
<Tooltip title='刷新'>
<Button shape='circle' type='text' size='small' icon={<ReloadOutlined />} onClick={refresh} />
</Tooltip>
</Flex>
<Flex wrap gap={8} >
<NewEmailButton />
<Button
size='small'
icon={<MailOutlined />}
onClick={() => {
markAsUnread(selectedItems.map((item) => item.MAI_SN)).then(() => setSelectedItems([]))
}}>
未读
</Button>
<Button
size='small'
icon={<MailCheckIcon />}
onClick={() => {
markAsProcessed(selectedItems.map((item) => item.MAI_SN)).then(() => setSelectedItems([]))
}}>
已处理
</Button>
<Button
size='small' // danger
icon={<DeleteOutlined />}
onClick={() => {
markAsDeleted(selectedItems.map((item) => item.MAI_SN)).then(() => setSelectedItems([]))
}}>
删除
</Button>
<MailOrderSearchModal />
<MailListSearchModal />
</Flex>
</div>
<Flex align='center' justify='space-between' wrap className='px-1 border-0 border-b border-solid border-neutral-200'>
<Breadcrumb
items={(tempBreadcrumb || props.breadcrumb).map((bc) => {
return {
title: (
<>
<MailboxDirIcon type={bc?.iconIndex} />
<span>{bc.title}</span>
</>
),
}
})}
/>
{tempBreadcrumb && (<Button type="text" icon={<CloseCircleOutlined />} onClick={() => refresh()} />)}
<Flex align='center' justify='space-between' className='ml-auto'>
<span>已选: {selectedItems.length} </span>
<span>
{(pagination.current - 1) * PAGE_SIZE + 1}-{Math.min(pagination.current * PAGE_SIZE, pagination.total)} of {pagination.total}
</span>
<Button
icon={<LeftOutlined />}
type='text'
onClick={() => {
prePage()
}}
iconPosition={'end'}></Button>
<Button
icon={<RightOutlined />}
type='text'
onClick={() => {
nextPage()
}}
iconPosition={'end'}></Button>
</Flex>
</Flex>
<div className='bg-white overflow-auto px-2' style={{ height1: 'calc(100vh - 198px)' }}>
<Skeleton active loading={loading}>
<List
loading={loading}
className='flex flex-col h-full [&_.ant-list-items]:overflow-auto'
header={null}
itemLayout='vertical'
pagination={false}
dataSource={pagination.pagedList}
renderItem={mailItemRender}
/>
</Skeleton>
</div>
</div>
)
}
export default MailBox

@ -0,0 +1,75 @@
import { useState } from 'react'
import { SearchOutlined } from '@ant-design/icons'
import { Button, Modal, Form, Input, Radio } from 'antd'
import { searchEmailListAction } from '@/actions/EmailActions'
import useConversationStore from '@/stores/ConversationStore'
const MailListSearchModal = ({ ...props }) => {
const [currentMailboxOPI] = useConversationStore((state) => [state.currentMailboxOPI])
const [openForm, setOpenForm] = useState(false)
const [formSearch] = Form.useForm()
const [loading, setLoading] = useState(false)
const onSubmitSearchMailList = async (values) => {
setLoading(true)
await searchEmailListAction({...values, opi_sn: currentMailboxOPI});
setLoading(false)
setOpenForm(false)
}
return (
<>
<Button key={'bound'} onClick={() => setOpenForm(true)} size='small' icon={<SearchOutlined className='' />}>
查找邮件
</Button>
<Modal
width={window.innerWidth < 700 ? '95%' : 960}
open={openForm}
cancelText='关闭'
okText='查找'
confirmLoading={loading}
okButtonProps={{ autoFocus: true, htmlType: 'submit', type: 'default' }}
onCancel={() => setOpenForm(false)}
footer={null}
destroyOnHidden
modalRender={(dom) => (
<Form
layout='vertical'
form={formSearch}
name='searchmaillist_form_in_modal'
initialValues={{ mailboxtype: '' }}
clearOnDestroy
onFinish={(values) => onSubmitSearchMailList(values)}
className='[&_.ant-form-item]:m-2'>
{dom}
</Form>
)}>
<Form.Item name='mailboxtype' label='邮箱文件夹'>
<Radio.Group
options={[
{ key: 'All', value: '', label: 'All' },
{ key: '1', value: '1', label: '收件箱' },
{ key: '2', value: '2', label: '未读邮件' },
{ key: '3', value: '3', label: '已发邮件' },
{ key: '7', value: '7', label: '已处理邮件' },
]}
optionType='button'
/>
</Form.Item>
<Form.Item name='sender' label='发件人'>
<Input />
</Form.Item>
<Form.Item name='receiver' label='收件人'>
<Input />
</Form.Item>
<Form.Item name='subject' label='主题'>
<Input />
</Form.Item>
<Button type='primary' htmlType='submit' loading={loading}>
查找
</Button>
</Modal>
</>
)
}
export default MailListSearchModal

@ -0,0 +1,145 @@
import { useState } from 'react'
import { SearchOutlined } from '@ant-design/icons'
import { Button, Modal, Form, Input, Checkbox, Radio, DatePicker, Divider, Typography, Flex } from 'antd'
import dayjs from 'dayjs'
import { getEmailDirAction, queryHTOrderListAction, } from '@/actions/EmailActions'
import { isEmpty, objectMapper, pick } from '@/utils/commons'
import useConversationStore from '@/stores/ConversationStore'
const MailOrderSearchModal = ({ ...props }) => {
const [currentMailboxOPI] = useConversationStore((state) => [state.currentMailboxOPI])
const [updateCurrentMailboxNestedDirs, setMailboxActiveNode] = useConversationStore((state) => [state.updateCurrentMailboxNestedDirs, state.setMailboxActiveNode])
const [open, setOpen] = useState(false)
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const onSubmitSearchMailOrder = async (values) => {
// console.log('Received values of form: ', values)
setLoading(true)
const valuesToSub = objectMapper(values, {
year: { key: 'year', transform: (val) => (val ? dayjs(val).year() : '') },
important: { key: 'important', transform: (val) => val || '-1' },
by_success: { key: 'by_success', transform: (val) => (val ? '1' : '0') },
if_want_book: { key: 'if_want_book', transform: (val) => (val ? '1' : '0') },
if_thinking: { key: 'if_thinking', transform: (val) => (val ? '1' : '0') },
by_start_date: { key: 'by_start_date', transform: (val) => (val ? '1' : '0') },
coli_id: { key: 'coli_id', transform: (val) => (val ? val : '') },
is_biz: { key: 'sourcetype', transform: (val) => (val ? '227002' : '227001') },
})
let result
if (isEmpty(valuesToSub.coli_id)) {
const { coli_id, sourcetype, ...mailboxParams } = valuesToSub
result = await getEmailDirAction({ ...mailboxParams, opi_sn: currentMailboxOPI }, false)
updateCurrentMailboxNestedDirs(result[`${currentMailboxOPI}`])
} else {
const htOrderParams = pick(valuesToSub, ['coli_id', 'sourcetype'])
result = await queryHTOrderListAction({ ...htOrderParams, opi_sn: currentMailboxOPI })
const addToTree = {
key: 'search-orders',
title: '查找订单',
iconIndex: 'search',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: result.map((o) => ({
key: `search-${o.COLI_SN}`,
title: `${o.COLI_ID}`,
iconIndex: 13,
parent: 'search-orders',
parentTitle: '查找订单',
parentIconIndex: 'search',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: 'search-orders', IsTrue: 0, ApplyDate: '', OrderSourceType: htOrderParams.sourcetype, parent: 'search-orders' },
})),
}
updateCurrentMailboxNestedDirs([addToTree])
setMailboxActiveNode(addToTree)
}
setLoading(false)
setOpen(false)
}
return (
<>
<Button key={'bound'} onClick={() => setOpen(true)} size='small' icon={<SearchOutlined className='' />}>
查找订单
</Button>
<Modal
width={window.innerWidth < 700 ? '95%' : 960}
// title='' //mask={false}
open={open}
cancelText='关闭'
okText='查找'
confirmLoading={loading}
okButtonProps={{ autoFocus: true, htmlType: 'submit', type: 'default' }}
onCancel={() => setOpen(false)}
footer={null}
destroyOnHidden
modalRender={(dom) => (
<Form
layout='inline'
// size='small'
form={form}
name='searchmailorder_form_in_modal'
initialValues={{ year: dayjs(), important: '-1' }}
clearOnDestroy
onFinish={(values) => onSubmitSearchMailOrder(values)}
className='[&_.ant-form-item]:m-2'>
{dom}
</Form>
)}>
<Flex wrap gap={8}>
<div>
<Typography.Text strong>按订单的时间范围</Typography.Text>
<Form.Item name='year' label='年份'>
<DatePicker picker='year' />
</Form.Item>
<Form.Item name='important' label='重要程度'>
<Radio.Group
options={[
{ key: '-1', value: '-1', label: 'All' },
{ key: '240001', value: '240001', label: '普通' },
{ key: '240002', value: '240002', label: '较重要' },
{ key: '240003', value: '240003', label: '很重要' },
]}
optionType='button'
/>
</Form.Item>
<div className='flex'>
<Form.Item name='by_success' className='' valuePropName='checked'>
<Checkbox>成行订单</Checkbox>
</Form.Item>
<Form.Item name='if_want_book' className='' valuePropName='checked'>
<Checkbox>要预定</Checkbox>
</Form.Item>
<Form.Item name='if_thinking' className='' valuePropName='checked'>
<Checkbox>犹豫中</Checkbox>
</Form.Item>
</div>
<Form.Item name='by_start_date' className='' valuePropName='checked'>
<Checkbox>按出发日期</Checkbox>
</Form.Item>
<div className='text-end'>
<Button type='primary' htmlType='submit' loading={loading}>
查找
</Button>
</div>
<Divider className='my-2' />
<Typography.Text strong>订单号精确查找</Typography.Text>
<Form.Item name='coli_id' label='订单号' className=''>
<Input />
</Form.Item>
<Form.Item name='is_biz' className='' valuePropName='checked'>
<Checkbox>商务订单</Checkbox>
</Form.Item>
<div className='text-end'>
<Button type='primary' htmlType='submit' loading={loading}>
查找
</Button>
</div>
</div>
</Flex>
</Modal>
</>
)
}
export default MailOrderSearchModal

@ -0,0 +1,31 @@
import { StarTwoTone, CalendarTwoTone, FolderOutlined, DeleteOutlined, ClockCircleOutlined, FormOutlined, DatabaseOutlined, BellTwoTone, SearchOutlined } from '@ant-design/icons'
import { InboxIcon, MailUnreadIcon, SendPlaneFillIcon } from '@/components/Icons'
const EmailDirTypeIcons = {
'search': { component: SearchOutlined, color: '', className: 'text-blue-600' },
'star': { component: StarTwoTone, color: '', className: '' },
'calendar': { component: CalendarTwoTone, color: '', className: '' },
'reminder': { component: BellTwoTone, color: '', className: '' },
0: { component: FolderOutlined, color: '#ffe78f', className: 'text-blue-500' },
1: { component: FolderOutlined, color: '#ffe78f', className: 'text-blue-500' },
3: { component: InboxIcon, color: '', className: 'text-indigo-500' },
17: { component: InboxIcon, color: '', className: 'text-indigo-500' },
11: { component: MailUnreadIcon, color: '', className: 'text-indigo-500' },
4: { component: SendPlaneFillIcon, color: '', className: 'text-primary' },
2: { component: ClockCircleOutlined, color: '', className: 'text-yellow-500' },
5: { component: FormOutlined, color: '', className: 'text-blue-500' },
7: { component: DeleteOutlined, color: '', className: 'text-red-500' },
// '3': { component: MailCheckIcon, color: '', className: 'text-yellow-600' },
12: { component: DatabaseOutlined, color: '', className: 'text-blue-600' },
13: { component: () => null, color: '', className: '' },
14: { component: () => '❗', color: '', className: '' }, // 240002 /
15: { component: () => '❣️', color: '', className: '' }, // 240003 /
}
export const MailboxDirIcon = ({ type }) => {
const Icon = EmailDirTypeIcons[type || '13']?.component || EmailDirTypeIcons['13'].component
const className = EmailDirTypeIcons[type || '13']?.className || EmailDirTypeIcons['13'].className
return <Icon className={className} />
}
export default MailboxDirIcon

@ -0,0 +1,43 @@
import { useMemo } from 'react'
import { App, Dropdown } from 'antd'
import useConversationStore from '@/stores/ConversationStore'
import { emailTemplates, openPopup } from '@/hooks/useEmail'
import { isEmpty } from '@/utils/commons'
const NewEmailButton = ({ ...props }) => {
const { notification } = App.useApp()
const [mailboxActiveNode] = useConversationStore((state) => [state.mailboxActiveNode])
const [mailboxActiveCOLI] = useConversationStore((state) => [state.mailboxActiveCOLI])
const COLI_SN = useMemo(() => mailboxActiveCOLI || mailboxActiveNode?.COLI_SN || 0, [mailboxActiveNode.COLI_SN, mailboxActiveCOLI])
const handleTemplateDropdown = ({ key, domEvent }) => {
if (isEmpty(COLI_SN)) {
notification.warning({ message: '无法绑定订单', description: '请先选择到订单目录或订单邮件', placement: 'top' })
return false
}
openPopup(`/email/new/0/${COLI_SN}/${key}`, `new-0-${COLI_SN}-${key}`)
}
const handleNewEmail = () => {
openPopup(`/email/new/0/${COLI_SN}`, `new-0-${COLI_SN}`)
}
return (
<>
<Dropdown.Button
size='small'
className={`w-auto ${props.className}`}
placement='bottom'
arrow
type={'primary'}
menu={{
items: emailTemplates,
onClick: handleTemplateDropdown,
}}
onClick={handleNewEmail}>
新邮件
</Dropdown.Button>
</>
)
}
export default NewEmailButton

@ -99,6 +99,20 @@ const manifestForPWAPlugIn = {
}, },
}, },
}, },
{
urlPattern: /^https:\/\/.*\.mycht\.cn\/attachment\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'mail-attachment',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 7, // <== 7 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
], ],
}, },
manifest: { manifest: {
@ -169,12 +183,9 @@ export default defineConfig({
output: { output: {
entryFileNames: '[name]/build.[hash].js', entryFileNames: '[name]/build.[hash].js',
manualChunks(id) { manualChunks(id) {
if (id.toLowerCase().includes('lexical')) { if (id.includes('node_modules/')) {
return 'lexical'; // return 'vendor';
} return id.toString().split('node_modules/')[1].split('/')[0].toString();
if (id.includes('node_modules')) {
return 'vendor';
// return id.toString().split('node_modules/')[1].split('/')[0].toString();
} }
}, },
// chunkFileNames: (chunkInfo) => { // chunkFileNames: (chunkInfo) => {

@ -71,7 +71,8 @@ const uniqueMsgId = msg => (msg.id && msg.direction ? `${directionPrefix[msg.dir
*/ */
const webhookBodyBuilder = (messageData, messageType) => { const webhookBodyBuilder = (messageData, messageType) => {
const defaultContent = { id: '', from: '', to: '', externalId: '', type: '', direction: '', status: '' }; const defaultContent = { id: '', from: '', to: '', externalId: '', type: '', direction: '', status: '' };
const status = messageData.direction === 'inbound' ? '' : statusMapped?.[messageData.status] || messageData.status || ''; const outboundStatus = statusMapped?.[messageData.status] || messageData.status || '';
const status = messageData.direction === 'inbound' ? '' : outboundStatus;
const errors = messageData.status === 'error' ? { errorMessage: '未知错误', errorCode: '' } : {}; const errors = messageData.status === 'error' ? { errorMessage: '未知错误', errorCode: '' } : {};
const message = { const message = {
id: `evt_${generateId().replace(/-/g, '')}`, id: `evt_${generateId().replace(/-/g, '')}`,

Loading…
Cancel
Save