Compare commits

..

801 Commits

Author SHA1 Message Date
Lei OT 517d2deed2 1.6.7 2 days ago
Lei OT 7193b62821 perf: 获取更多模板 2 days ago
LiaoYijun 98aec9b037 1.6.6 1 month ago
Lei OT a3ef048865 + 客运新的商业号 1 month ago
LiaoYijun ea00e7d2fd 1.6.5 1 month ago
LiaoYijun c600958384 feat: 增加客运香港号码 1 month ago
LiaoYijun 576532ebe6 1.6.4 1 month ago
LiaoYijun 7769929d00 fix:马币不支持跨境付款 1 month ago
Lei OT f1eb47c17f 1.6.3 2 months ago
Lei OT a9c4c71921 perf: 模板显示 2 months ago
Lei OT 4afa544641 邮件发送: actionID 2 months ago
Lei OT 78f10715e7 1.6.2 3 months ago
Lei OT 144abacf90 perf: 模板提示 3 months ago
Lei OT 96a050afa9 perf: 会话页面: 短链接按钮 3 months ago
Lei OT 552bcf8356 1.6.1 3 months ago
Lei OT aceeb135c2 fix: 移动端: 钉钉免登授权后跳转 3 months ago
Lei OT 3777fabe71 perf: 移动端 3 months ago
Lei OT b260909210 perf: 优化提示 3 months ago
Lei OT 406f96e8e1 Merge remote-tracking branch 'origin/main' 3 months ago
ybc 226ea99ed8 优化短链接转换2 3 months ago
Lei OT ef69a4764c 删除移动端的语音通话入口 3 months ago
Lei OT c45e8f09cb perf: 消息模板 3 months ago
Lei OT 89e6560b15 Merge remote-tracking branch 'origin/main' 3 months ago
ybc 4746f8c4eb 优化短链接转换 3 months ago
Lei OT a21856e9d2 1.6.0 3 months ago
ybc cf98a38559 继续改进短链接转换2 3 months ago
ybc cb09c9d819 Merge branch 'main' of github.com:hainatravel-it/global-sales 3 months ago
ybc a7509400f6 继续改进短链接转换 3 months ago
Lei OT 5512701f8b perf: 消息模板排序 3 months ago
Lei OT 02ef6b4d86 Merge remote-tracking branch 'origin/main' 3 months ago
Lei OT ad93c35912 perf: 模板消息参数替换; 客运模板排序 3 months ago
ybc e947c78a73 改进短链接转换 3 months ago
ybc 0c43d84631 短链接转换 3 months ago
LiaoYijun db0cf324ba perf: 废弃模块增加 Warning 3 months ago
Lei OT 07f9b0a19d WAI: 正式的 haina-npm 地址 3 months ago
Lei OT d943af09f7 正式的 haina-npm 地址 3 months ago
Lei OT deb33d43c6 refactor(前端): `copy` --> structuredClone` 4 months ago
Lei OT 27d12a765d refactor(前端): `@haina/utils-request` 4 months ago
Lei OT a88f861b13 refactor(前端): `@haina/utils-commons` 4 months ago
Lei OT d720795ec4 refactor(前端): `@haina/utils-pagespy` 4 months ago
Lei OT 2bb45fb16a refactor(WAI): `urils/commons` --> `@haina/utils-commons` 4 months ago
LiaoYijun 65cf370a5d 1.5.27 4 months ago
LiaoYijun 424bfb5c63 perf: paypal增加马币币种 4 months ago
LiaoYijun 17a57fc6a4 doc: baileys 6.7.21 4 months ago
LiaoYijun e4c33adbc9 perf: 解决 WA 过期,升级 baileys 6.7.21 4 months ago
Lei OT b8d7597004 perf(前端): 多媒体文件不要loading 5 months ago
LiaoYijun 3912e93428 1.5.26 5 months ago
Lei OT 6d74c8f99c style: 邮件目录: 待发邮件 6 months ago
Lei OT 9c0ac172df fix: 订单列表: 全选 6 months ago
Lei OT 846725d7aa Merge remote-tracking branch 'origin/main' 6 months ago
Lei OT 278e420483 perf(WAI): update事件的 from to 不要覆盖 6 months ago
LiaoYijun 97f47c75c4 1.5.25 6 months ago
Lei OT 8503e85b72 perf(WAI): update事件的 from to 不要覆盖 6 months ago
Lei OT a88ea053e0 perf(WAI): update事件的 from to 不要覆盖 6 months ago
Lei OT 49f7186167 perf: 邮件列表: 订单节点: 显示邮件文件夹 6 months ago
Lei OT 06aee58133 perf(WAI): 获取已保存的`from` `to` 6 months ago
LiaoYijun 0eebc7bfb3 perf: 支付链接默认使用Highlights Travel账号 6 months ago
LiaoYijun 0438be50f6 1.5.24 6 months ago
LiaoYijun a3a831f668 feat:收件箱按时间分组 6 months ago
LiaoYijun a67fd186c1 1.5.23 6 months ago
LiaoYijun e2b06c729c perf:未读邮件变色、加深 6 months ago
Lei OT ec20df7dc4 perf(WAI): 引用为空的不发送context对象 6 months ago
LiaoYijun 5c1147f57e 🎨refactor: 删除控制台调试信息 7 months ago
LiaoYijun 65d5758148 perf:删除 makeInMemoryStore,解决发群消息 7 months ago
LiaoYijun 704110ac70 1.5.22 7 months ago
LiaoYijun ed19f2ff3d perf: baileys⬆️6.7.19 7 months ago
Lei OT 5317703635 fix: WAI 收到文件类型消息, 预览 7 months ago
LiaoYijun 127c193257 feat: Caching Group Metadata 7 months ago
Lei OT c151444856 fix: 客户运营: 跳转会话获取会话失败 7 months ago
Lei OT 52897120f2 Merge remote-tracking branch 'origin/main' 7 months ago
Lei OT 0a99743758 perf(WAI): document 消息的webhook数据 7 months ago
LiaoYijun b109b51e01 🐛fix: document 消息增加 filename 7 months ago
Lei OT 408b9666cf perf(WAI): document 消息的webhook数据 7 months ago
LiaoYijun f5a0a58a19 feat:增加documnetMessagee、audioMessage消息解析 7 months ago
LiaoYijun aa1ef98b8f perf:官方最新版本号:1027426813 7 months ago
LiaoYijun fc7afb8f29 feat: 增加文档消息解析 7 months ago
Lei OT 12feaa7780 perf(WAI): 解析有引用的消息 7 months ago
Lei OT 44b9d930dc perf(WAI): 消息状态异步返回; 补全字段 7 months ago
LiaoYijun b97c8e449c perf:WAI 拷贝前清理目录 7 months ago
LiaoYijun a0ea74f81f 🎨refactor:WhatsApp 使用 macOS('SAFARI') 7 months ago
LiaoYijun aed3a7bf8c 🎨refactor:messageUpdate 统一创建 Message 7 months ago
Lei OT f51452c919 feat(WAI): WhatsApp离线接口 offline, 调用实例方法 7 months ago
Lei OT b7a1dffe96 perf(WAI): 消息状态异步返回; 7 months ago
LiaoYijun b1694270e4 feat: WA 增加editedMessage、reactionMessage消息解析 7 months ago
LiaoYijun a87433f045 1.5.21 7 months ago
LiaoYijun 9d396c5eb0 📝docs: 更新 Bailys 参考文档 7 months ago
Lei OT 5873cc947e fix: 邮件详情: iframe 内的链接, 新页面打开, 允许不继承iframe的沙盒限制 7 months ago
Lei OT 79a5187cd2 Merge remote-tracking branch 'origin/main' 7 months ago
LiaoYijun b58c84daa4 🐛fix:金额必填 7 months ago
Lei OT d1124f43bf perf(WAI): 消息状态异步返回; 合并保存所有事件的原文 7 months ago
LiaoYijun c40c742994 feat:个人WhasApp服务端NodeJS打包脚本 7 months ago
LiaoYijun a1c659dfb3 1.5.20 7 months ago
LiaoYijun 05e2a3a473 Revert "feat: 订单信息增加复制订单号"
This reverts commit 89431e9ec3.
7 months ago
LiaoYijun 89431e9ec3 feat: 订单信息增加复制订单号 7 months ago
LiaoYijun 56369cf856 feat:订单信息增加复制订单号 7 months ago
LiaoYijun 3dc5ab72da feat:完成商业号模板消息解析 7 months ago
LiaoYijun 1525af2415 feat:完成商业号模板消息解释 7 months ago
LiaoYijun e3fc505d41 feat:增加 WhatsApp Baileys 本地调试 7 months ago
LiaoYijun 21b86f656a 1.5.19 7 months ago
LiaoYijun f767619f5b 1.5.18 7 months ago
Lei OT a16dc962a8 Merge remote-tracking branch 'origin/main' 7 months ago
Lei OT 7879387fbc fix: indexDB 更新表 7 months ago
LiaoYijun dead850380 1.5.17 7 months ago
Lei OT 7920a5a939 fix: indexDB 消息表 7 months ago
LiaoYijun d83c1ad3fd 1.5.16 8 months ago
LiaoYijun e35c699751 🐛fix:催信状态初始值 8 months ago
LiaoYijun 98f0b22e1e perf: 催信状态使用 zustand、action 管理 8 months ago
LiaoYijun 02f573eae7 🐛fix:remindStatusOptions.SecondRemind 8 months ago
LiaoYijun 81ab201706 🐛🐛fix: props.SecondRemind 8 months ago
LiaoYijun 3b6907aa6f 🐛fix: SecondRemind 8 months ago
LiaoYijun f6c8fc1df7 🎨refactor:修改二催字段名 8 months ago
LiaoYijun 354ec5337f 🎨refactor: 催信支持多选设置 8 months ago
LiaoYijun 2f51a1c40a 1.5.15 8 months ago
LiaoYijun 5a0ec3c66b 🐛fix: 参数名错误 8 months ago
LiaoYijun 1a3d0add75 1.5.14 8 months ago
LiaoYijun a169a81eef 🐛fix:loginByJSAuth error 8 months ago
LiaoYijun 3f1cedd64e 1.5.13 8 months ago
LiaoYijun de96b0a8d1 feat:移动端使用免登授权码 8 months ago
Lei OT b691b584a5 # 8 months ago
Lei OT afa424fc05 fix: 判断设备是否支持 `requestIdleCallback` 8 months ago
LiaoYijun 9b1588b70c 1.5.12 8 months ago
Lei OT 01a0569bca Merge remote-tracking branch 'origin/main' 8 months ago
Lei OT 9ac4df74a6 fix: 偶发的indexedDB 未创建 8 months ago
LiaoYijun 25f59d613d feat: 完成钉钉免登测试代码,验证通过后再正式使用 8 months ago
LiaoYijun 12c45471fe 1.5.11 8 months ago
LiaoYijun 2111373630 🔒build:Vite Git Head 8 months ago
Lei OT a5684dd418 perf: 编辑器: 行间距 8 months ago
Ycc 4fd740dc6a 显示国籍中文和显示额外信息
修复引用大小写问题
8 months ago
LiaoYijun 228b6e4aec perf: 使用 App.messge 替换 messageApi 8 months ago
LiaoYijun 4c3b0fe420 perf: 使用 App.messge 替换 messageApi 8 months ago
LiaoYijun c5f959d043 fix:useMessage() error 8 months ago
LiaoYijun 8aa8434be3 perf:增加常量,删除无效代码 8 months ago
LiaoYijun d0ed4adaa0 feat: 把幽灵依赖转为显式,以便使用 PNPM 8 months ago
Lei OT 09f422339e 1.5.10 8 months ago
Lei OT fdd876db40 fix: 附件地址 8 months ago
Lei OT 561c25e8a1 perf: 更新催信模板 8 months ago
LiaoYijun 29f29bcf38 perf: 增加等出 WA 实例 8 months ago
LiaoYijun 3ee4230af8 feat:WA 实例增加 stop 方法 8 months ago
LiaoYijun cebabfcc92 1.5.9 8 months ago
Lei OT 0077f1f210 fix: 在线聊天: 加载更多消息记录 8 months ago
Lei OT 8c89ef3e80 perf: WhatsApp消息 支持加粗, 斜体 8 months ago
LiaoYijun be84e99d9e 1.5.8 8 months ago
LiaoYijun 73167db47a perf: 订单跟踪、在线聊天默认展开订单信息 8 months ago
LiaoYijun 67694da2ce 1.5.7 8 months ago
LiaoYijun 8557402e53 feat: 上传日志前可输入描述文字 8 months ago
LiaoYijun 0dac008996 fix:在线聊天显示关联订单 9 months ago
Lei OT 6a2a0311f6 1.5.6 9 months ago
Lei OT 3dd44cf624 perf: 回复全部 9 months ago
LiaoYijun 4dd0fabc3d fix:WA 版本过期,先 Hardcode 解决 9 months ago
LiaoYijun 7a70fbe984 1.5.5 9 months ago
Ycc 17f77df1f9 Merge branch 'main' of github.com:hainatravel/global-sales 9 months ago
Ycc 54e0c85289 显示国籍 9 months ago
LiaoYijun 3041b1702f perf: 更新统计 SQL;vite: chunk vendor 9 months ago
LiaoYijun de5db83b04 perf: 更新登录页面 Logo 9 months ago
Ycc 6307deec1a 列名修改 9 months ago
Lei OT 6d5e037f3f fix: 保存发送邮件 字段名称错误 9 months ago
LiaoYijun e5281be550 feat: update favicon.ico 9 months ago
LiaoYijun a7f2e1d5dc 1.5.4 9 months ago
Lei OT 17577d6fb6 fix: indexedDB 版本更新 9 months ago
LiaoYijun 0ec8566344 1.5.3 9 months ago
Lei OT 7e20ca48cf perf: 在线聊天: 会话搜索: 判断是否把当前选中的放顶部显示 9 months ago
LiaoYijun b35b5db436 perf: 隐藏订单操作状态【取消】 9 months ago
LiaoYijun bc5d86d568 feat: 增加 HighlightsTravel 收款
perf: 更新 Logo
9 months ago
Lei OT f3b2093d8b fix: `session.updated` 推送, 排除email 10 months ago
Lei OT 13a8bca806 perf: 订单跟踪: 目录刷新: 页面活跃时 10 months ago
Lei OT f718049de4 Merge remote-tracking branch 'origin/main' 10 months ago
Lei OT 79b9e21c73 perf: 邮箱目录Tree, 虚拟滚动 10 months ago
Lei OT d6724fa0a6 fix: indexDB 初始化 10 months ago
LiaoYijun 22f07f9100 1.5.2 10 months ago
Lei OT 0ca4a7a15a perf: 订单跟踪: 查找订单: 自动展开到`收件箱`下 10 months ago
Lei OT bfdeba11ee fix: 从订单跳转到会话页面,没有新建会话 10 months ago
LiaoYijun 504366b529 1.5.1 10 months ago
LiaoYijun c69acb791d fix: 使用正式环境地址 10 months ago
LiaoYijun f24a40fe6b 1.5.0 10 months ago
Lei OT 84e9ada0e7 style: 10 months ago
Lei OT 6a3da2a537 # 10 months ago
Lei OT 535aa38775 perf: 邮件内容: 图片点击预览 10 months ago
Lei OT 22674fe498 . 10 months ago
Lei OT eabb53e0a9 perf: 邮件列表: 搜索结果 10 months ago
LiaoYijun 9ed85c81b2 perf:优化订单展开、收缩按钮位置 10 months ago
LiaoYijun 6baff26720 doc:prerealse 1.5.0-1 10 months ago
LiaoYijun 0a06f6c16a perf: 精简查找邮件目录数量 10 months ago
LiaoYijun 038db199e9 feat: 删除已读设置,增加未读。删除无用代码 10 months ago
Lei OT 210d3e7263 fix: Listener 数据对象 10 months ago
Lei OT 945f1b3651 perf: 邮箱目录更新 10 months ago
Lei OT 378c864277 perf: email quote, text/plain 10 months ago
Lei OT cbf63c5e86 feat: 查找邮件: 更新 10 months ago
Lei OT 01997b7d23 Merge remote-tracking branch 'origin/main' 10 months ago
LiaoYijun d5a522e88f feat: 增加【查找邮件】界面 10 months ago
Lei OT e0950bb773 style: 10 months ago
Lei OT 74a0e0c14e conf: build chunk 10 months ago
Lei OT 068a02ff64 perf: email quote, text/plain 10 months ago
Lei OT 2abd149655 perf: email 不在在线聊天页面通知 10 months ago
Lei OT 2b344eec43 perf: email quote 10 months ago
LiaoYijun 7365ae08ee perf: 修改 WA 后更新 State 10 months ago
LiaoYijun 5b5ebe896c perf:编辑附加信息初始值 10 months ago
LiaoYijun cd5dbb3be5 feat:增加 WA、附加信息设置 10 months ago
LiaoYijun d0fbf179fd feat: 增加设置 WA 界面 10 months ago
Lei OT 6b32601fc7 style: 固定mail list 的header 10 months ago
Lei OT 403cb4f7c7 perf: 模板 10 months ago
Lei OT c51df7fa03 style: 会话筛选 10 months ago
Lei OT 26b709a7dc Merge branch 'dev/2025a' 10 months ago
Lei OT b29b927b5c # 10 months ago
Lei OT 15acbf4f2f update: antd Modal props 10 months ago
Lei OT 8eccf74c61 style: 10 months ago
Lei OT 6211d275d1 perf: 订单信息: 催信状态 10 months ago
Lei OT 4fb5041179 feat: 历史记录: +渠道搜索 10 months ago
Lei OT 7ec91716a7 style: 格式刷图标 10 months ago
Lei OT 2d7f269a27 perf: 邮件编辑页面: 切换账号 10 months ago
Lei OT 7e84b9cb5a 邮箱默认文件夹 10 months ago
Lei OT f78130544a perf: 邮件详情: 最大化窗口 10 months ago
Lei OT 674b3cc591 邮件查找接口 10 months ago
Lei OT 29cc138b33 # 10 months ago
Lei OT 1977b8b404 perf: 邮件: 编辑草稿 10 months ago
Lei OT aabc409f6d feat: 邮件: 删除; perf: 更新数量 10 months ago
Lei OT d9686b3ef5 # 10 months ago
Lei OT acf2c02063 style: 10 months ago
Lei OT 88958977c4 perf: 邮件目录保持展开的树; style: MailBox title 10 months ago
Lei OT 812bf19c26 feat: 邮箱目录: 数量 10 months ago
Lei OT 3f9cc81b30 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT 701a6a00c0 perf: 新邮件: 前缀[称呼+订单号] 10 months ago
LiaoYijun f8246c10a3 fix: 试用 Router-Link 跳转在线聊天 10 months ago
LiaoYijun 95d6e7dd92 feat:完成订单新增字段显示 10 months ago
Lei OT ca5dcc0705 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT 70a7f25b07 feat: 查找邮箱/订单: 订单仅当前账号下 10 months ago
Lei OT b0d72e0f7b Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
LiaoYijun 890027563c feat: 完成表单信息添加 10 months ago
Lei OT f081aa46e9 perf: 邮件绑定订单: 支持商务订单 10 months ago
Lei OT 696832eba1 # 10 months ago
Lei OT b1db77bbe9 conf: 增加缓存: 附件, 优化图片加载 10 months ago
Lei OT afeef17e28 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT be07ab175b feat: 查找邮箱/订单 10 months ago
LiaoYijun bf0f85b17a perf: 优化移动端获取当前订单 10 months ago
LiaoYijun 7cd6e1b2aa perf: 在线聊天共用订单信息组件 10 months ago
Lei OT 52e6307769 perf: 邮件详情: 自动标记; 附件处理; 10 months ago
Lei OT 2d4edd6c64 查找订单→邮箱目录 10 months ago
Lei OT 318c2256b3 perf: 编辑会话: 邮箱地址 10 months ago
Lei OT 5471345cf1 fix: email detail btns key 10 months ago
Lei OT f7f9500413 feat: 设为`已处理`后, 更新列表 10 months ago
LiaoYijun 95a1b16085 perf: 调整邮件列表按钮大小、文字 10 months ago
Lei OT e9e409ed0d perf: 订单跟踪: 默认折叠`订单信息` 10 months ago
Lei OT 36f237a14a feat: 新窗口打开邮件详情 10 months ago
Lei OT d18b8fd5e5 perf: 在线聊天: 不显示邮箱地址 10 months ago
Lei OT f9999e7d06 模板接口命名 10 months ago
Lei OT 49fede675e perf: 催信模板标题 10 months ago
Lei OT df4932f325 # 10 months 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
10 months ago
Lei OT db14ce64af feat: 邮件: 打开详情: 设为`已读` 10 months ago
Lei OT 5f657b2618 feat: 数据更新: 广播; 事件; `已读`, `已处理` 不刷新请求, 仅更新缓存 10 months ago
LiaoYijun 06a3e95e1a perf:调整邮件列表样式和代码 10 months ago
LiaoYijun 095a8f3d3b perf: 增加 WA 跳转到在线聊天 10 months ago
LiaoYijun d293e3a1e5 perf: 完成订单状态、催信设置 10 months ago
Lei OT 10e6b56446 feat: 缓存策略: 自动清除7天前的, 每天检查 10 months ago
Lei OT 5d41b44270 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT 4dd404167b fix: 邮件详情 10 months ago
LiaoYijun 17993f348c perf: 已读、已处理后更新邮件列表 10 months ago
LiaoYijun 645c85a59a feat: 增加设置邮件已读、已处理 10 months ago
LiaoYijun 85c2622213 perf: 删除拨打电话链接 10 months ago
Lei OT cc471d93c8 conf: api v3 地址 10 months ago
Lei OT 05a22161cd feat: 保存草稿: 附件处理 10 months ago
Lei OT aeb0672002 conf: indexedDB version upgrade 10 months ago
Lei OT 1a0328303d perf: 邮件的订单 10 months ago
Lei OT 46fa96694f perf: 模板按钮 10 months ago
Lei OT fdfe4d3083 # 10 months ago
Lei OT e38d136cc4 perf: maillist 缓存 10 months ago
Lei OT 01f0f9bd9d Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT 5ce589654a perf: maillist 缓存 10 months ago
LiaoYijun 7d7334ffe2 perf: 不能通过号码拨打电话 10 months ago
LiaoYijun ad1f934d3b perf: 下线 Vonage Voice,价格没有谈成 10 months ago
Lei OT 8f3fdef2e6 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT e51581202d 加载模板 10 months ago
LiaoYijun d265f03a6e perf: 调整选中样式、附件图标 10 months ago
LiaoYijun 7a45245437 feat: 增加多选、全选功能 10 months ago
Lei OT 1393bf9899 perf: 邮件详情: 附件的显示 10 months ago
Lei OT be2f85a0da style: 10 months ago
Lei OT 0529cce11b Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT fe3cde0c89 feat: 保存草稿; 10 months ago
LiaoYijun 0b37f1a9a0 perf: 调整手机、WA 样式 10 months ago
Lei OT 01dbcabdd7 perf: 邮箱目录 10 months ago
Lei OT d55d55a3aa Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT 4d4e6fe1d3 style: 邮件详情 10 months ago
Lei OT e113c33fc6 新窗口: 邮件编辑 10 months ago
LiaoYijun dd9e6e9e3a perf:调整表单信息顺序;增加骨架屏 10 months ago
LiaoYijun d733303ec3 fix: 修复合并出错 10 months ago
LiaoYijun e9d7bd1e8f feat:根据订单读取客人、表单信息;去除报价列表 10 months ago
Lei OT ef3e55eefb perf: 邮件详情 10 months ago
Lei OT c4dd6b0147 feat: useEmailList + refresh 10 months ago
Lei OT af5dd4efdc perf: 邮箱目录操作; perf: 邮件详情的附件; 10 months ago
Lei OT 496861bcaa perf: MailBox 参数 10 months ago
Lei OT 1863983d02 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT f1bc44da07 perf: 待办目录: 处理订单重复 10 months ago
LiaoYijun 12b793d277 feat: 邮件列表增加分页功能 10 months ago
Lei OT fdfd633ecf perf: 待办目录+邮箱目录; `催信` 10 months ago
Lei OT d881778d78 feat: mailbox 缓存清除策略 10 months ago
Lei OT f4f956fd5e Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT c7d72d01f3 perf: 待办目录+邮箱目录 10 months ago
Lei OT a61ed7eb82 perf: 邮箱目录: 查询不需`出发日期` 10 months ago
LiaoYijun 0da3ea58af perf: 增加邮件列表分页,暂时显示第一页 10 months ago
Lei OT 1e9f84665e perf: email list 字段不需要重命名 10 months ago
Lei OT 0860a7054d perf: mailbox 参数 10 months ago
Lei OT c622138c7d style: 10 months ago
Lei OT d9082a1203 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT 3df24ab6ba perf: 邮箱目录: 保留选中 10 months ago
LiaoYijun 9a7db21d74 perf: 显示邮件总数 10 months ago
LiaoYijun ad142df232 feat: 选择订单,邮件不显示订单号;面包屑和树一致 10 months ago
LiaoYijun 6db05883de perf: 没有团号、国籍时隐藏 10 months ago
Lei OT a6cb136ccf perf: 邮箱目录: 固定的目录不触发事件 10 months ago
Lei OT 716776f96b perf: 计算mailbox 的title 10 months ago
Lei OT 74bd58529e Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 10 months ago
Lei OT 32fd97ce91 style: 订单跟踪: 订单信息的缩放 10 months ago
LiaoYijun 62615cfb98 perf: 增加高级搜索切换 10 months ago
Lei OT 1286a0c20a Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a
# Conflicts:
#	src/views/orders/components/MailBox.jsx
10 months ago
Lei OT efae99e81e feat: useEmailList hooks 10 months ago
LiaoYijun 05de1791ed perf: 取消高级搜索 Drawer;调整邮件项为两行 10 months ago
Lei OT 1d513ec038 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a
# Conflicts:
#	src/views/orders/components/MailBox.jsx
10 months ago
LiaoYijun c8ca19954f feat:增加分页按钮及数量 10 months ago
LiaoYijun 9823214e4b perf:增加面包屑;调整邮件单选框位置 11 months ago
Lei OT 2a629df2ed feat: 邮箱文件夹接口; 邮件列表接口 11 months ago
LiaoYijun 3519c4a414 perf:调整邮件列表样式,精简字体大小、行间距 11 months ago
Lei OT 9d31e69db9 feat: 邮箱文件夹接口; 邮件列表接口 11 months ago
Lei OT 9100e4b19d fix: 会话列表时间 11 months ago
Lei OT f3ead963cc fix: 会话列表时间 11 months ago
Lei OT 36068565f9 . 11 months ago
LiaoYijun a778bc475f perf: 整合原来 CustomerProfile 界面;调整批量操作按钮 11 months ago
Lei OT ab3c763238 feat: 邮箱文件夹 11 months ago
Lei OT 6bc5faa3f8 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 11 months ago
Lei OT 428524b232 fix: 合并覆盖 11 months ago
LiaoYijun e4fc6f79c0 feat: 独立邮件箱组件 11 months ago
LiaoYijun f4a64f0a03 feat: 独立订单信息组件 11 months ago
LiaoYijun 25722eff77 fix: 解决合并冲突后错误 11 months ago
LiaoYijun 8b6326380d perf:更新催信模板菜单;
feat:增加树节点选择显示
11 months ago
Lei OT b6903c7a9d 邮件文件夹 11 months ago
Lei OT 55e787b97f 邮件相关接口 11 months ago
Lei OT dfb7016240 feat: 订单跟踪: 邮件详情 11 months ago
Lei OT 0505313830 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 11 months ago
Lei OT 99862de593 # 11 months ago
Lei OT e88fa38991 feat: 订单跟踪: 邮件详情 11 months ago
Lei OT 04c71b1ff0 邮件详情; `已处理`操作 11 months ago
Lei OT 1ea4dabbc2 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 11 months ago
Lei OT 41948fed6f 新窗口: 邮件编辑 11 months ago
LiaoYijun ab36a85b22 perf:删除原有表格和无用代码 11 months ago
LiaoYijun b1efa2f4f3 feat: 增加邮件批量操作工具栏 11 months ago
Lei OT 92e5cab823 perf(前端): 历史记录: 查询`邮件`渠道; 类型 11 months ago
Lei OT ce8494fe26 perf(前端): 在线聊天: 供应商邮件; 11 months ago
Lei OT 63ec5c0a28 perf: 邮件附件预览 11 months ago
Lei OT c51012f88e Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 11 months ago
Lei OT fc87029d7a perf: 消息发送失败的 错误信息 11 months ago
LiaoYijun 8d7d5e32f4 perf: 删除非必要字段 wlemail 11 months ago
Lei OT 2ac04974a0 perf: 图文集; 支付链接 11 months ago
Lei OT df89722804 perf: 支付链接: 删除通知邮箱 11 months ago
Lei OT 5d73d04009 Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 11 months ago
Lei OT c021cd162b # 11 months ago
Lei OT ba6e017ffd feat: 编辑器: 清除格式 11 months ago
LiaoYijun b61c2baf9f perf: 删除提醒中心 11 months ago
LiaoYijun 2b6b0f9961 feat: 增加新窗口回复邮件例子 11 months ago
LiaoYijun b6efb24a87 perf: 使用四列布局订单跟踪 11 months ago
LiaoYijun 7449ad9e44 perf: 使用 Layout 布局 11 months ago
Lei OT c2f94e3e81 perf: add Icon: new mail 11 months ago
Lei OT d1fff21159 perf(前端): 语音全局入口 11 months ago
Lei OT 7b0bb05e89 test: 编辑邮件窗口
# Conflicts:
#	src/utils/commons.js
11 months ago
Lei OT 0d9dd3ad8c feat: 编辑器: 格式刷 11 months ago
Lei OT 3e8cda6700 feat: 编辑器: 字号 11 months ago
Lei OT 96daa64eb1 perf: 会话列表 顶部 11 months ago
Lei OT e126dec2ca Merge remote-tracking branch 'origin/dev/2025a' into dev/2025a 11 months ago
LiaoYijun c395ea1e8d perf: Dev 不发送 PageSpy;初步开发订单跟踪 11 months ago
Lei OT 4d3c4979c8 Merge branch 'main' into dev/2025a 11 months ago
Lei OT 1dfd2f28e1 1.4.10 11 months ago
LiaoYijun 1f972f417b perf:更新 router,zustand 小版本 11 months ago
LiaoYijun 1696d13c31 perf: 更新 Antd 小版本 11 months ago
Lei OT 8277a7b8ac style: 邮件详情 11 months ago
Lei OT 5278dc6030 perf(前端): 日志清除策略 11 months ago
Lei OT c61795ea97 perf(前端): 上传日志, 读取最近的websocket记录 11 months ago
Lei OT a7d478b667 perf(前端): 在线聊天: 会话的订单筛选; `已处理`改为`隐藏`; 会话列表每页100; 不操作`邮件`; 默认折叠`订单信息`; 隐藏供应商邮件; 隐藏报价历史; 在线聊天只能看订单信息 11 months ago
Lei OT 569039c311 style: 11 months ago
Lei OT 769fa76831 perf(WAI): outbound Status 11 months ago
Lei OT d7f1af1d49 style: 11 months ago
Lei OT 5446b7ca07 perf: 邮件列表: 无分页 11 months ago
Lei OT 4640801a53 fix: 推送错误,不提示了 11 months ago
Lei OT 0b4e02cfe1 1.4.9 11 months ago
Lei OT 2dd356ab3b fix: 推送错误 11 months ago
Lei OT 868a6441c9 feat: 邮件列表; 在线 11 months ago
Lei OT 75132e14eb feat: 邮件列表 11 months ago
Lei OT 98d885400f perf: 邮件列表组件 11 months ago
Lei OT 29a605cc11 test: # 11 months ago
Lei OT 54dac8e4ed 1.4.8 11 months ago
Ycc dcf86595fc Merge branch 'main' of github.com:hainatravel/global-sales 11 months ago
Ycc affd439f99 删除打印信息 11 months ago
hainatravel b7fb0264f3
Merge pull request #1 from hainatravel/master
已回复数据统计
11 months ago
Ycc 86ae19598a 已回复数据统计 11 months ago
Lei OT 081815fb69 perf(前端): 供应商平台的邮件正文, 处理换行 11 months ago
Lei OT 50109ab629 perf: 供应商邮件 11 months ago
Lei OT 96d0d2bdc0 perf(前端): 邮件正文 11 months ago
Lei OT 6ce6ae1492 revert: 不要处理 html 格式的文本邮件 11 months ago
Lei OT 15874bf229 perf(前端): 邮件详情 11 months ago
Lei OT b7fa9490a7 perf: 邮件绑定订单. 绑定到其他人订单 11 months ago
Lei OT c9b44f233d perf(前端): 供应商平台的邮件正文, 处理换行 11 months ago
Lei OT 35ac6c6c48 perf(前端): 邮件详情: 记住窗口位置 11 months ago
Lei OT 056d075a7c perf(前端): 邮件正文 11 months ago
Lei OT 4a9ea4311b style: 供应商邮件 11 months ago
Lei OT 099edef821 style: 11 months ago
Lei OT 9933fa7460 perf(前端): 邮件正文 11 months ago
Lei OT 888cc8214a perf(前端): 模板消息, 查看头部大图 11 months ago
Lei OT 00384ca10c perf: 语音通话 tip 12 months ago
Lei OT 8722903143 Merge remote-tracking branch 'origin/main' 12 months ago
Lei OT 815ba6ac68 style: 12 months ago
Ycc 1207e8e8b8 显示错误内容 12 months ago
Lei OT 83b25ca524 1.4.7 12 months ago
Lei OT a4f467ef0c fix: 抄送的 replyAll 12 months ago
Lei OT 3278dec31d style: 12 months ago
Lei OT 85000e35f5 fix: conf 12 months ago
Lei OT e7ae4ea264 1.4.6 12 months ago
Lei OT 1d085285b5 Merge remote-tracking branch 'origin/main' 12 months ago
Ycc b06b8e7f90 1v1统计 12 months ago
Lei OT c2f478f6d4 perf: 模板+支持: 换头图, 发券码, 复制码, 按钮url参数和显示; 新模板排序 12 months ago
Lei OT 51d474bbe5 feat: 会话 更新 推送 12 months ago
Lei OT 69dff17e29 conf: 12 months ago
Lei OT 9d7cd401d5 feat: vonage 语音 测试账户 12 months ago
Lei OT 6abf31325a test: 会话消息推送 12 months ago
Lei OT 3d04a0d376 1.4.5 12 months ago
Lei OT 18af62a843 feat: 会话信息推送 12 months ago
Lei OT f41cd0d058 Merge remote-tracking branch 'origin/main' 12 months ago
YCC 4a7ce7424d 更新会话链接 12 months ago
Lei OT 6861eee665 fix: indexDB 12 months ago
Lei OT 7f4e805367 1.4.4 12 months ago
LiaoYijun f5499e1e76 perf: 悬浮按钮桌面和移动端使用不同样式 12 months ago
Lei OT edc1569a8c Merge remote-tracking branch 'origin/main' 12 months ago
Lei OT 5ddc7f966e perf: 消息状态图标 12 months ago
Lei OT b16a2c4c8c # 12 months ago
Ycc fa52d9db7c 添加消息状态 1 year ago
Lei OT 07e1819743 1.4.3 1 year ago
Lei OT 512e8581e3 Merge remote-tracking branch 'origin/客户运营' 1 year ago
Ycc 78eddb5ce5 显示消息状态 1 year ago
Lei OT 461ed80ecb perf: 新模板排序 1 year ago
Lei OT 8de6599ab0 1.4.2 1 year ago
Lei OT 679fc3888b Merge remote-tracking branch 'origin/客户运营'
# Conflicts:
#	src/stores/AuthStore.js
1 year ago
Lei OT 4d04d9b84c fix: indexDB 1 year ago
Ycc 30201094d9 增加排序 1 year ago
Ycc ac81fb3987 更新会话链接 1 year ago
Lei OT bda195b657 1.4.1 1 year ago
Lei OT 5e74e833d0 fix: test auth 1 year ago
Lei OT 8332e5dcd4 1.4.0 1 year ago
Lei OT fbccb2ebe3 Merge remote-tracking branch 'origin/客户运营' 1 year ago
Lei OT e3fadb6d4e perf: 模板提示; 获取登录账户的名字 1 year ago
Ycc 5b0799ff03 设置默认值 1 year ago
Lei OT eacc0b7d31 style: 1 year ago
Ycc f999855166 新建客运模块 1 year ago
Lei OT d491b62d72 1.3.12 1 year ago
Ycc a49460571c 添加客户运营模块 1 year ago
Lei OT 45b01d6bc5 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 58dca13d3d feat(前端): 会话列表: `今日` 1 year ago
LiaoYijun 28ffb63da3 doc: 增加查找掉线 WA 1 year ago
Lei OT 89a7b03084 fix: 上传前端日志 1 year ago
Lei OT a80b2138c6 fix: 编辑WhatsApp号码, 处理空格 1 year ago
Lei OT d04dad5363 perf(前端): postmail ordertype 默认传统订单; 1 year ago
Lei OT 8b9b60afd6 1.3.11 1 year ago
Lei OT 4dd1824b5b perf(前端): postmail ordertype 默认传统订单; 对发件`回复全部` 1 year ago
Lei OT ebae7d272c fix(前端): template消息显示 type值不区分大小写 1 year ago
Lei OT 49059be11b Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT bb364e5cac feat(WAI): 设置号码托管; 托管账号, 单个会话 1 year ago
LiaoYijun b5e9c324bc perf: 增加忽略日志 1 year ago
Lei OT fd805dced8 docs: dump sql 1 year ago
Lei OT 0a6ed3e5a9 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT da5970a5b8 . 1 year ago
Lei OT 4d878d0dc3 docs: WABA 发送模板 1 year ago
Lei OT 3d5b8dba73 feat(WAI): 托管号码给trip planner Agent 1 year ago
LiaoYijun 38e61ed4d1 feat: 屏蔽 WA @newsletter 消息 1 year ago
Lei OT 51d867d3a5 feat(WAI): 设置WhatsApp离线 1 year ago
LiaoYijun e87bf7536d feat: 屏蔽 WA @newsletter 消息 1 year ago
Lei OT 5e347c0dd6 1.3.10 1 year ago
LiaoYijun cbe751bb80 perf: 客运商业号名字:Customer Relation Specialist 1 year ago
Lei OT 2cb8c2ae35 style: 移动端反馈按钮,不要遮挡发送 1 year ago
Lei OT b5259edd1c perf(前端): 移动端支付链接 1 year ago
Lei OT 07653ef024 Merge remote-tracking branch 'origin/main' 1 year ago
LiaoYijun 841a8c725f perf: 增加客运商业号 1 year ago
Lei OT b872eb9752 perf(前端): Email纯文本, 不限制字数 1 year ago
Lei OT c10319e8d5 perf(前端): 模板排序 1 year ago
Lei OT a8156f33ed fix: 会话绑定订单后, 获取订单信息 1 year ago
Lei OT 1187f6fdc0 perf: 从订单详情创建WhatsApp会话, 填充订单号; 没有WhatsApp允许添加 1 year ago
Lei OT 1692f70428 1.3.9 1 year ago
Lei OT 8e13c4042c feat(前端): 放大供应商邮件区域, +搜索, 上下分栏 1 year ago
Lei OT afe159a728 perf(前端): 设置订单的催信状态 1 year ago
Lei OT 9e6e24eecf perf(WAI): 号码去掉`+` 1 year ago
Lei OT ac8f12e216 style: 1 year ago
Lei OT 426a9e2bec 1.3.8 1 year ago
Lei OT 22db64e6f7 perf(前端): 邮件详情需显示抄送; 回复抄送 1 year ago
Lei OT 6b40736ef5 fix(前端): 编辑当前会话的联系人信息, 更新会话列表 1 year ago
Lei OT da75804b51 style: 1 year ago
Lei OT 2df96ad269 perf(前端): 支付链接: 币种与HT功能一致 1 year ago
Lei OT 3b0b9707c6 perf: Web界面,系统更新要更明显 1 year ago
LiaoYijun 8f49f7f060 fix: isLidUser 未导入 1 year ago
LiaoYijun d44c7bd65b 1.3.7 1 year ago
Lei OT 413b554f68 perf(WAI): 不更新写入session id; 部分字段getter 1 year ago
Lei OT 6c881e1ede perf: 不支持的消息类型 改为 `对方删除消息` 1 year ago
LiaoYijun 0bd166e9ed 1.3.6 1 year ago
Lei OT 0f47860be0 perf: 隐藏会话保持和邮件管理器一致【已处理】 1 year ago
LiaoYijun fa7c5967f5 1.3.5 1 year ago
Lei OT 586991eff5 conf: 邮件附件地址 1 year ago
LiaoYijun 7b86b1b7cc Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 96123dafc6 doc: 更新 WA 统计 SQL 1 year ago
Lei OT 47099fbdf8 style: 引用消息 1 year ago
Lei OT 806e2600b1 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT e59ef9e062 style: 字体: 删除Segoe UI 1 year ago
LiaoYijun 21ea42f630 perf: 统一判断是否是群消息 1 year ago
LiaoYijun 6ac00b2977 perf: baileys-server 已迁移到 wai-server 1 year ago
LiaoYijun c45f32822f 1.3.4 1 year ago
Lei OT 8bcf028d85 fix(前端): 临时的会话数据: channels 1 year ago
Lei OT 04afcc1cd1 style: 标记接收到的消息的对象 1 year ago
Lei OT 7aac5e76e0 perf(前端): 未分配: 读取未分配会话消息. 1 year ago
LiaoYijun 756cd7935f Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 119c7d0739 perf: WA-log 不加 channelId, QR 事件名字更改 1 year ago
Lei OT 2eb591acf5 perf(前端): 历史记录: 使用msgtime 分页 1 year ago
LiaoYijun 5408ad1d04 1.3.3 1 year ago
Lei OT 9e9dc3d551 style: 超长的引用, 溢出; 气泡宽度 1 year ago
Lei OT 68c19a62fd perf(前端): 无邮箱地址配置 1 year ago
Lei OT 3fe605c562 perf(前端): 接收消息推送, 更新会话列表 1 year ago
Lei OT 01b5991ba0 perf(前端): 消息窗口: 加载更多之后的滚动条位置 1 year ago
Lei OT cdfbea60aa perf(wai): 消息状态`error`, webhook +`errorMessage` 1 year ago
Lei OT dd3c087e24 perf(前端): 131031的错误提示 1 year ago
Lei OT 5c023c8b45 perf(前端): 发出的消息, 无状态则显示为`等待` 1 year ago
LiaoYijun 11e876ef5a 1.3.2 1 year ago
LiaoYijun 5d9a2e08cd perf: 联系人状态更新消息不处理 1 year ago
LiaoYijun 9547dab815 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 05fbb6f369 fix: 修复发消息状态无法获取 1 year ago
Lei OT 44a1cd69c9 style: 群会话, 不能使用商业号 1 year ago
Lei OT bd5cd24918 perf(前端): 不显示群id 1 year ago
LiaoYijun e1164e0067 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 045c4dedb1 perf: 删除不用的测试代码 1 year ago
Lei OT 920c33f16b Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 13d3bc1376 feat(前端): WhatsApp使用模板消息, 输入发送未纯文本 1 year ago
Lei OT 1eba78da8c fix(前端): 接收的消息更新, 无状态 1 year ago
LiaoYijun 463f19ae8b 1.3.1 1 year ago
Lei OT 9eee7d04ea fix(wai): webhook status 1 year ago
Lei OT 21100fc032 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 56afea4d75 fix(前端): 会话过期提示: 仅使用WABA渠道计算 1 year ago
LiaoYijun a1ed0df17c fix: 修改群发判断方法 1 year ago
LiaoYijun 0b26c9472f Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun f5de6e8b2d perf: 增加客人群发消息处理 1 year ago
Lei OT 8b84d3aaca fix(wai): 消息状态 1 year ago
Lei OT 4399a48ead perf(wai): 无法解析的内容不发生WhatsApp Server 1 year ago
Lei OT 75b41d73c6 fix(wai): deliver 在sent 之前触发 1 year ago
LiaoYijun 8267f1f442 doc: 没有扫码登录的顾问 1 year ago
LiaoYijun 5f04656ac0 doc: 增加国际部 WA 查询 1 year ago
Lei OT ac08255e51 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT e4ecab76d8 perf(wai): 使用队列处理消息事件 1 year ago
LiaoYijun 7323918217 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 900225b789 doc: 增加查找没扫码登录的顾问 1 year ago
Lei OT 1cd09cdfdd style: 发送文件类消息不loading了 1 year ago
Lei OT 07b2dabbed style: Email弹窗 1 year ago
Lei OT 289d9877b4 revert: feat: 设为静音 1 year ago
Lei OT 0bc4d4b662 perf(wai): request log 1 year ago
Lei OT c53c286a6c fix(前端): location对象取值 1 year ago
Lei OT b1b44a52dd perf(前端): 推送的消息, 生成会话显示名称 1 year ago
Lei OT 0a5ed806c1 conf(wai): webhook 重发间隔 5s, 20s, 5m, 30m, 1h, 2h, 4h, 24h 1 year ago
Lei OT c2f8cdb65b conf(wai): webhook 重发 1 year ago
Lei OT f38728e432 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 08498e1280 feat(前端): 标记 已读/未读 1 year ago
LiaoYijun 801de4cb96 perf: 图片和文本消息统一状态处理 1 year ago
Lei OT d39d5f4da0 feat(前端): 设为静音 1 year ago
Lei OT dd8adbf207 perf(前端): 会话列表: 分页 50 1 year ago
Lei OT 50321f6015 perf(wai): 读取已存的时间字段 → webhook 1 year ago
Lei OT 83e4ae8075 perf(wai): 重启把上一次在线的重新登录 1 year ago
Lei OT 83408b0efb perf(wai): 数据不要被空值覆盖 1 year ago
Lei OT a7c941a5da perf(前端): 历史记录: 显示会话信息 1 year ago
Lei OT 1f716e08f4 perf(前端): 会话编辑: 保留原WhatsAppId 1 year ago
Lei OT f8d2ec6b64 perf(前端): 群会话 1 year ago
Lei OT 93e63529f4 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 950de6573f perf(前端): 会话列表显示渠道图标 1 year ago
LiaoYijun 866e7e0959 1.3.0 1 year ago
LiaoYijun 8c3d5b4d90 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun d544e845df perf: 群图片消息加上组名;图片接收状态加上空值 1 year ago
Lei OT c0d70f810d perf(前端): 图片消息在引用/最近 的显示 1 year ago
Lei OT 9bef019d09 fix(wai): 引用的消息 1 year ago
Lei OT b6d96607a2 perf(wai): 避免重复存文件 1 year ago
Lei OT 6372e27d58 fix(wai): 多媒体消息, 存储内容 1 year ago
Lei OT 59f3fea840 fix(wai): 多媒体消息, 存储内容 1 year ago
Lei OT e2948a54cc fix(wai): 多媒体消息, 存储内容 1 year ago
Lei OT 7b91848326 perf(wai): filePath 1 year ago
Lei OT d9e27adc57 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT c90724f9eb Revert "perf(前端): 其他客户端同步的消息, 不提示未读"
This reverts commit 39d1946440.
1 year ago
LiaoYijun d1e328f209 perf: 优化消息处理逻辑,增加接收图片事件触发 1 year ago
Lei OT d60b1502ee Revert "perf(前端): 邮件: 取消定时收件"
This reverts commit 3e5599b155.
1 year ago
LiaoYijun 9e7f8ceb2f doc: 删除不用的注释 1 year ago
Lei OT d6613e40f1 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 94e370d209 wai: todo 1 year ago
LiaoYijun a9bbf68323 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun d418e5c860 feat: 增加获取群名字并缓存 1 year ago
Lei OT 4ca8bf359b perf(wai): message id 1 year ago
Lei OT e974f91fe5 perf(前端): 邮件: 邮件开头: 客人名字, 订单号 1 year ago
LiaoYijun b2aa2440b3 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 449a0355a4 perf: 图片增加 Caption; 1 year ago
Lei OT 7b31600ffb Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 39d1946440 perf(前端): 其他客户端同步的消息, 不提示未读 1 year ago
Lei OT 5c813c53ae 删除废弃的api 1 year ago
Lei OT 277864bcc6 feat(wai): 存储多媒体消息的文件 1 year ago
LiaoYijun f008392f8b perf: 图片 sha256 转为 base64 字符串 1 year ago
LiaoYijun 4504e4910b Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 7849e188a8 perf: WA 图片消息下载图片到临时目录 1 year ago
Lei OT 3e5599b155 perf(前端): 邮件: 取消定时收件 1 year ago
LiaoYijun 8b9fcb47e0 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 7925da2bd7 perf: logout 异步删除验证目录;统一 Baileys 发消息接口 1 year ago
Lei OT 4a1284579d perf(wai): 服务启动: 启动所有账户连接 1 year ago
Lei OT ad6f28ef85 perf(wai): 消息发送事件名称 1 year ago
Lei OT eceec840d6 . 1 year ago
Lei OT a49936dabc perf(wai): 用户wai活动日志 1 year ago
lyt 3158595b30 conf(wai): eslint 1 year ago
Lei OT 086683f782 perf: 请求登录qrcode: 已连接 1 year ago
Lei OT 75fddcf3f8 conf(wai): 日志 1 year ago
Lei OT 7dc14871f1 perf: 请求登录qrcode; 全局提示状态 1 year ago
Lei OT d05709eae4 # 1 year ago
Lei OT 31808bcefc perf(wai): webhook msg类型 1 year ago
Lei OT 997f5a3174 perf(wai): message id + `to` 1 year ago
LiaoYijun 7c9ea1e671 perf: 更新 socket.browser 1 year ago
LiaoYijun fa6dfe2700 perf: 删除不用的 console,调整 WA AUTH 目录 1 year ago
LiaoYijun f9bd3141d5 perf: Emitter+Handler 处理发消息 1 year ago
LiaoYijun c1d3f14f42 1.3.0-beta.1 1 year ago
LiaoYijun dfa8d27f95 perf: WA 号码只能保留数字 1 year ago
LiaoYijun 4200895ee2 perf:WA 日志存到 logs 目录;删除不用的配置文件 1 year ago
LiaoYijun 12a3ac3589 perf: 使用 NodeCache 替换 Map 保存 externalId 1 year ago
LiaoYijun 5a973ea114 feat: 使用个人 WA 权限根据是否有服务器地址判断;Web 端收定时件时间改为两分钟;优化收件和小虫子大小、位置 1 year ago
Lei OT d5763ba820 fix(wai): 读取数据库数据发webhook 1 year ago
Lei OT 703e028a5d fix(前端): 最近消息 1 year ago
LiaoYijun b8768c32d0 1.3.0-beta.0 1 year ago
LiaoYijun f37fd7fe1b doc: 增加 preminor 例子 1 year ago
LiaoYijun 9562192494 perf: WA 服务端地址从接口获取;增加 WA API VER 前缀; 1 year ago
LiaoYijun 17f5d275bc doc: 增加 pm2 启动注意 1 year ago
Lei OT ddc2b35ac4 perf(前端): 不清理未读 1 year ago
Lei OT 4db2172795 style: 历史记录: 区分渠道信息 1 year ago
Lei OT a2b9f84271 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT b04d5cfa06 perf(前端): wai 发文件 1 year ago
Lei OT e6f7af245f perf(前端): wai 发消息 1 year ago
Lei OT 37e952c7f0 perf(WAI): 日志 1 year ago
Lei OT 7363ebcb9a feat(WAI): 按类型发消息 1 year ago
Jimmy ec8b65b6cc perf: 删除自测功能;删除监听事件;增加 TODO 1 year ago
Lei OT 8c5db33155 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	src/channel/bubbleMsgUtils.js
1 year ago
Lei OT 37d2f9b1af fix(前端): 二维码的推送解析 1 year ago
LiaoYijun 96c0b2613a fix: 解决获取二维码解析错误 1 year ago
Lei OT 43cae1723c feat(WAI): creds update事件 的格式 1 year ago
Lei OT b802532c50 perf(前端): wai 发消息 1 year ago
Lei OT 2d2eee7704 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT e7d1d4be70 perf(前端): 会话列表 1 year ago
Lei OT 7d0fbb96d1 perf(前端): wai 二维码扫码; 1 year ago
Jimmy adf35291e2 perf: 修改 emitter 发文本消息名字 1 year ago
Jimmy dacb49f273 perf: 忽略没有状态的更新;增加 emitter 发送图片 1 year ago
Lei OT 34d7e06007 feat(WAI): 推送登录状态 1 year ago
Lei OT 6c28b0cacc feat(WAI): 推送二维码 1 year ago
Jimmy 92e84e4ad0 perf: 使用 emitter 请求发消息 1 year ago
Lei OT 724d3df6dc Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 75a1818929 conf: eslint 1 year ago
Jimmy 45e12909be feat: 增加 creds:update 事件 1 year ago
Jimmy ad3bae2eab perf: emmiter+externalId 1 year ago
Jimmy b954ab6ac3 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
Jimmy 3bb78fea32 perf: 去掉 promise, 使用 emitter 发消息。 1 year ago
Lei OT 8c6e2df266 perf(前端): wai 历史记录 1 year ago
Lei OT 3fecbc8122 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 7a1e11a752 前端 1 year ago
Jimmy a3e68df317 perf: 使用 emitter 发消息 1 year ago
Lei OT 87b53da7e7 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT d67ab1f573 perf(前端): wai解析 1 year ago
Jimmy 397c24db74 perf: 同步 no-promise 版本 1 year ago
Jimmy c71679b099 feat: 本地测试 WA 1 year ago
Jimmy f9ced872f3 减少 WhatsApp 一层属性 1 year ago
Jimmy 68ebd591c6 perf: 增加 no promise 版 1 year ago
Lei OT 44caaf40fd perf(前端): waba提示 1 year ago
Lei OT 4d71f3b59d 前端 1 year ago
Lei OT 83f05f90e7 前端 1 year ago
Lei OT 1e88be0988 conf: eslint 1 year ago
Lei OT e144c00b6f 不管saved 事件 1 year ago
Lei OT 396c6a6c28 前端 `wai` 1 year ago
Lei OT 608d4ab774 前端解析 `wai` 1 year ago
Lei OT 7317616cd4 区分 inbound outbound 的messageId 1 year ago
Lei OT 449d482adb . 1 year ago
Lei OT e3d8ee2991 跳过一次 pending/saved 事件 1 year ago
Lei OT 56dc5a365d 区分 inbound outbound 的messageId 1 year ago
Lei OT ab64fda1ea Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT a12920048c perf: 前端的渠道类型: `wai` 1 year ago
LiaoYijun 23d1a1baac Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 285b3b3ee0 perf: 删除测试代码;在线状态发送消息;增加定时测试代码; 1 year ago
Lei OT 606be58147 # 1 year ago
Lei OT b62f1317f2 消息事件:` failed` 1 year ago
Lei OT 03975bf0f0 # 1 year ago
Lei OT c331a04a02 消息事件:` pending` , `saved` 1 year ago
Lei OT a1a73d2cf5 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT b71892b117 消息事件:` pending` , `saved` 1 year ago
LiaoYijun dc599d7ad3 perf: 正式环境使用正式 Webhook 地址; 1 year ago
LiaoYijun 7da8586cbc perf: 在消息更新时也加上 externalId 1 year ago
Lei OT d7cd08aaa4 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 622a730ac3 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 433cedb7e5 发送保存消息 1 year ago
LiaoYijun ae9217b833 perf: 增加发送消息成功和失败事件通知 1 year ago
LiaoYijun 789ab4a8b2 perf: 增加 WASocket 超时设置 1 year ago
LiaoYijun 4293718988 perf: 发送消息支持 externalId 1 year ago
LiaoYijun 5e6cd6b9e6 perf: 删除重复发消息;注释下载图片 1 year ago
LiaoYijun 8c670d1174 perf: 链接事件增加 channelId 1 year ago
Lei OT 0bf953ab3a 保存消息, webhook 需要actionId 1 year ago
Lei OT d0fe6bd1b9 Merge remote-tracking branch 'origin/main' 1 year ago
LiaoYijun 25cd0047ab perf: 删除无用json 1 year ago
Lei OT 891db24af7 保存消息, webhook 1 year ago
LiaoYijun 499c720810 perf: 增加消息来源;增加 WA 服务器地址; 1 year ago
Lei OT 5bd8122c50 发送消息, 同步的结果; 测试服务: externalId为负 1 year ago
Lei OT 644c3f4496 发送消息, 同步的结果; 测试服务: externalId为负 1 year ago
Lei OT ba95206a7e 发送消息, webhook 1 year ago
Lei OT 9b4b7d666d 修改路径 1 year ago
Lei OT a638b7a315 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 25d52e75e7 发送消息, webhook 1 year ago
LiaoYijun 91ed37a7e9 perf: 独立储存 store 文件 1 year ago
LiaoYijun 03127e3910 perf: 二维码失效后退出 WA 1 year ago
LiaoYijun 526363ff2d 1.2.3 1 year ago
LiaoYijun 34eb530d0a perf: 备注服务器地址;增加三位主管 WA 权限;延长模拟扫码成功时间;增加 info 转存日志;取消 WA 自动重连;增加 WA 本地测试;增加事件来源 1 year ago
Lei OT 8427c542ab wai: webhook 不重试 1 year ago
Lei OT 4046207872 sendText 格式 1 year ago
Lei OT 91ca645c41 fix: 样式丢失 1 year ago
LiaoYijun a5c2557c77 1.2.2 1 year ago
LiaoYijun 51f46f9da1 perf: 使用 await 处理消息发送;模拟扫码登录成功 1 year ago
Lei OT e3b4eda48d Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 17bc472b58 启动后再登出 1 year ago
LiaoYijun 817dc7f22c fix:解决自测消息死循环 1 year ago
Lei OT 3c918f411e todo: externalId 1 year ago
Lei OT 8485aa5910 todo: service 1 year ago
Lei OT 04294aea95 删除一个中间件: forward 1 year ago
Lei OT a03e6ae114 服务启动, 登出所有账号连接 1 year ago
Lei OT 2025cc4bb6 +models: webhook_logs, outbound_messages 1 year ago
Lei OT a47c9a1461 删除一个open事件 1 year ago
LiaoYijun c3a08d8ddd Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 09369e9031 perf: 使用网前 IP 测试;增加 pm2 安装; 1 year ago
Lei OT 23cc6787d8 style: chat window 1 year ago
Lei OT 2b58263211 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 0b0bd8bcfe webhook 字段 1 year ago
LiaoYijun 81e63430c6 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun 46142b8a4b perf: 增加发送消息错误捕获;webhook 重试一次 1 year ago
Lei OT cb69f181c1 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 3f4576799e feat: /channels/ 所有连接 1 year ago
LiaoYijun 81bb209b54 perf: 完善 msg.stauts 格式;增加 WA 消息同步处理 1 year ago
Lei OT 448693d304 sendMessage: 不等待 1 year ago
Lei OT 2abdc6ccaf perf: 上传最近 1 小时的日志, 直接upload 所有日志: 413 Payload Too Large 1 year ago
Lei OT bec63bbfc2 每次重新扫码; 1 year ago
Lei OT 913b88dc38 webhook 字段 1 year ago
Lei OT e7af89e945 conf: webhook whatsapp_qqs 1 year ago
Lei OT 47549ee986 docs: dump mysql 1 year ago
Lei OT 96c7b8da54 conf: production 1 year ago
LiaoYijun d983b2254b feat: 增加消息类型 conversation.type 1 year ago
LiaoYijun 32d197121f fix: 无法获取 QRCode 1 year ago
Lei OT fdc7539864 当前登录连接; 是否本机; 1 year ago
Lei OT 0e8cf01f2c 删除测试路由 1 year ago
Lei OT f505575f6a 修改API 路由: `/api/v1` 1 year ago
Lei OT d166e9d2fa Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 3df7ff4b80 连接事件 1 year ago
Lei OT 3fb6f10cd9 修改API 路由: `/api/v1` 1 year ago
LiaoYijun 07a78f7dee perf: 增加获取 WA 二维码 Action 1 year ago
Lei OT db46025929 todo: 路由转发; 删除opi_sn 字段; 删除测试路由 1 year ago
LiaoYijun 7fc40dc8e0 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun a772d9f978 perf: 增加 direction;使用 Baileys Utils;捕获消息发送错误,防止 WA 实例退出 1 year ago
Lei OT 9aa9a574af 修改wai-server API 路由: `/wai-server/v1` 1 year ago
Lei OT ed1e7ce2a0 修改wai-server API 路由: `/wai-server/v1` 1 year ago
Lei OT 65511e1036 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 3c70cecc14 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 394b3b568d connection写数据库; 请求日志; +支持multi-form; whatsapp connection event; 1 year ago
LiaoYijun d06eee3c1c feat: WA 二维码更新请求方式;WA 可以保存;修改 WA 消息格式; 1 year ago
LiaoYijun db041577dc perf: 更新测试代码,用来处理疑难杂症 1 year ago
Lei OT b041a819e0 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT fb32273d41 perf: 个人WhatsApp消息气泡 1 year ago
LiaoYijun 8f6d7e15a7 perf: 增加群联系人更新;模拟 macOS; 1 year ago
LiaoYijun 24cb081232 perf: 增加 WA messages.update 处理 1 year ago
LiaoYijun 2bfd4896da perf: baileys_auth_info 独立目录;群消息使用 participant 1 year ago
Lei OT 3d299afcc5 WhatsApp发送消息 1 year ago
Lei OT 4d4e21b16c webhooksource 1 year ago
Lei OT 587b0a328c Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT 8e6179558b webhook event data body 1 year ago
LiaoYijun 1cddc89488 perf: 使用全局 Emitter 1 year ago
Lei OT bda1e1c7ed emit webhook 1 year ago
Lei OT c5f413f543 emit webhook 1 year ago
Lei OT 8300b5f957 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT bcac67771f conf: WhatsApp权限 1 year ago
LiaoYijun d6757954cb feat: 增加接收消息、发送消息事件 1 year ago
LiaoYijun d3b39a7fbd feat: 增加请求二维码功能;有权限才能使用;
fix: 接收图片保存出错
1 year ago
LiaoYijun e924508b1d fix: 解决 WA 扫码后无法发消息 1 year ago
LiaoYijun ccfaf25c0a perf: 迁移 Baileys 单独问题 1 year ago
LiaoYijun f2623eff8a 1.2.1 1 year ago
LiaoYijun 7bbc018b56 Merge branch 'main' of github.com:hainatravel/global-sales 1 year ago
LiaoYijun b734016aa7 perf: 开放邮件功能给所有人 1 year ago
Lei OT 5f93c43e2d conf: server 1 year ago
Lei OT 5977ec2351 测试: qrcode, send text 1 year ago
Lei OT aacfae32be perf: 历史记录: 显示邮件消息 1 year ago
LiaoYijun 501557c87d 1.2.1-beta.11 1 year ago
LiaoYijun e0367960cf fix: 暂时取消 hooks 1 year ago
LiaoYijun 45f9b86cc5 feat: 集成 Baileys 和 HTTP 框架; 暂时去掉eslint 1 year ago
Lei OT 4c485016c6 wai-server 开发配置 1 year ago
Lei OT 0ec9233e85 订单切换了清空 1 year ago
Lei OT 1cb938fd0d perf: 历史记录: 修改参数 1 year ago
Lei OT 151a220dee Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT e58ebd4cf0 perf: 会话没有WhatsApp号码的提示 1 year ago
LiaoYijun e4bae796c7 feat: WA 收到图片消息下载图片 1 year ago
Lei OT 6319b7de5b perf: 消息气泡显示的名称; fix: 引用消息的waba账户检测 1 year ago
Lei OT 7ef3b6fbe5 +`ws`; 修改模拟的配置 1 year ago
Lei OT ad3c732125 Merge remote-tracking branch 'origin/main' 1 year ago
Lei OT f94a34beb0 创建路由; 模拟websocket连接 1 year ago
Lei OT bc0ffbc2c4 perf: 会话列表更新时, 保证当前会话显示在页面上 1 year ago
LiaoYijun a44821e09d perf: 精简不需要的代码,增加 WA 版本信息 1 year ago
LiaoYijun 92294b1e4e perf: 完善发送图片消息 1 year ago
Lei OT d6a35fb7f8 1.2.1-beta.10 1 year ago
Lei OT ec480735c1 fix: 会话coli sn null 时,发送失败. 异常`"Exception": "Could not convert variant of type (Null) into type (String)"` 1 year ago
Lei OT b81ee33b9e 调整日志上传 1 year ago

21
.gitignore vendored

@ -10,10 +10,12 @@ lerna-debug.log*
node_modules
dist
dist-ssr
*.local
distTest
dev-dist
*.local
distTest
dev-dist
tmp
schema*
.gitkeep
# Editor directories and files
.vscode/*
@ -27,5 +29,14 @@ tmp
*.sw?
/package-lock.json
**/LexicalEditor0
pnpm-lock.yaml
**/LexicalEditor0
*.zip
.env.*
vonage-client*
**/test
*.bak

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

@ -1,20 +0,0 @@
.DS_Store
.env
.yarn/
*.tgz
*/.DS_Store
auth_info*.json
baileys_auth_info*
baileys_store*.json
browser-messages.json
browser-token.json
decoded-ws.json
docs
lib
messages*.json
node_modules
output.csv
Proxy
test.ts
TestData
wa-logs.txt

@ -1,185 +0,0 @@
import {
makeWASocket,
WAProto,
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
makeInMemoryStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys'
import NodeCache from 'node-cache'
import P from 'pino'
import fs from "fs"
const logger = P({ timestamp: () => `,"time":"${new Date().toJSON()}"` }, P.destination('./wa-logs.txt'))
logger.level = 'trace'
// external map to store retry counts of messages when decryption/encryption fails
// keep this out of the socket itself, so as to prevent a message decryption/encryption loop across socket restarts
const msgRetryCounterCache = new NodeCache()
// the store maintains the data of the WA connection in memory
// can be written out to a file & read from it
const store = makeInMemoryStore({ logger })
store?.readFromFile('./baileys_store_multi.json')
// save every 10s
setInterval(() => {
store?.writeToFile('./baileys_store_multi.json')
}, 10_000)
// start a connection
const startSock = async () => {
const channelId = '创建时赋值,唯一标识'
const phone = '手机号'
const createTimestamp = '创建时间戳'
const status = 'close, open, connecting'
const sendTextMessage = (whatsAppNo, content) => {
sock.sendMessage(whatsAppNo + '@s.whatsapp.net', { text: content })
}
const sendImageMessage = (whatsAppNo, content) => {
sock.sendMessage(whatsAppNo + '@s.whatsapp.net', {
image: { url: 'https://images.chinahighlights.com/allpicture/2024/09/4e2099ca48384c3c84cde6a44e47ee84_cut_1140x360_241_1726745163.webp'},//fs.readFileSync("d:\\Workspace\\1.jpg"),
caption: "我是图片",
gifPlayback: true,
ptv: false
})
}
const { state, saveCreds } = await useMultiFileAuthState('baileys_auth_info')
// fetch latest version of WA Web
const { version, isLatest } = await fetchLatestBaileysVersion()
console.log(`using WA v${version.join('.')}, isLatest: ${isLatest}`)
const sock = makeWASocket({
version,
logger,
auth: {
creds: state.creds,
/** caching makes the store faster to send/recv messages */
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
msgRetryCounterCache,
generateHighQualityLinkPreview: true,
// ignore all broadcast messages -- to receive the same
// comment the line below out
// shouldIgnoreJid: jid => isJidBroadcast(jid),
// implement to handle retries & poll updates
getMessage,
})
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update
if(connection === 'close') {
console.info('链接断了')
} else if(connection === 'open') {
console.info('扫码成功')
} else if(connection === 'connecting') {
console.info('二维码:', qr)
}
})
sock.ev.on('messages.upsert', (upsert) => {
console.log('收到消息:', JSON.stringify(upsert, undefined, 2))
if (upsert.type === 'notify') {
for (const msg of upsert.messages) {
if (msg.message?.conversation || msg.message?.extendedTextMessage?.text) {
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text
if (text.indexOf('图片') > -1){
sendImageMessage('8617607730395', '文本消息' + new Date().toString())
} else if (text.indexOf('文本') > -1){
sendTextMessage('8617607730395', '文本消息' + new Date().toString())
}
}
}
}
})
// 不绑定不会影响扫码登录
// store?.bind(sock.ev)
// the process function lets you process all events that just occurred
// efficiently in a batch
sock.ev.process(
// events is a map for event name => event data
async (events) => {
// something about the connection changed
// maybe it closed, or we received all offline message or connection opened
if (events['connection.update']) {
const update = events['connection.update']
const { connection, lastDisconnect, qr } = update
if (connection === 'close') {
console.log('链接断开:', lastDisconnect)
if (lastDisconnect?.error?.output?.statusCode !== DisconnectReason.loggedOut) {
startSock()
} else {
sock.end((error) => console.error('end.error: ', error))
sock.logout((msg) => console.error('logout.msg: ', msg))
console.log('Connection closed. You are logged out.')
}
}
// 扫码成功,可以发送消息
if (update.connection === 'open') {
await sock.sendMessage('8617607730395' + '@s.whatsapp.net', { text: 'OPEN: ' + new Date().toString() })
}
// WebSocket 创建成功等待扫码,如果没有扫码会更新 qr
if (update.connection === 'connecting') {
// qr
}
console.log('connection update', update)
}
// credentials updated -- save them
if (events['creds.update']) {
await saveCreds()
}
// history received
if (events['messaging-history.set']) {
const { chats, contacts, messages, isLatest, progress, syncType } = events['messaging-history.set']
if (syncType === WAProto.HistorySync.HistorySyncType.ON_DEMAND) {
console.log('received on-demand history sync, messages=', messages)
}
console.log(`recv ${chats.length} chats, ${contacts.length} contacts, ${messages.length} msgs (is latest: ${isLatest}, progress: ${progress}%), type: ${syncType}`)
}
// received a new message
if (events['messages.upsert']) {
const upsert = events['messages.upsert']
console.log('收到消息:', JSON.stringify(upsert, undefined, 2))
if (upsert.type === 'notify') {
for (const msg of upsert.messages) {
if (msg.message?.conversation || msg.message?.extendedTextMessage?.text) {
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text
console.log('收到 notify', text)
}
}
}
}
},
)
return sock
async function getMessage(key) {
if (store) {
const msg = await store.loadMessage(key.remoteJid, key.id)
return msg?.message || undefined
}
// only if store is present
return WAProto.Message.fromObject({})
}
}
startSock()

File diff suppressed because it is too large Load Diff

@ -1,5 +0,0 @@
{
"dependencies": {
"@whiskeysockets/baileys": "^6.7.9"
}
}

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "es2018",
"module": "CommonJS",
"experimentalDecorators": true,
"allowJs": false,
"checkJs": false,
"outDir": "lib",
"strict": false,
"strictNullChecks": true,
"skipLibCheck": true,
"noImplicitThis": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"lib": ["es2020", "esnext.array", "DOM"]
},
"include": ["./src/**/*.ts"],
"exclude": ["node_modules", "src/Tests/*", "src/Binary/GenerateStatics.ts"]
}

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

@ -1,3 +1,25 @@
## 查找使用邮件功能人数
select group_concat(opi_sn_value separator ''',''') as 'opi_list' from (
SELECT
SUBSTRING_INDEX(SUBSTRING_INDEX(request_uri, 'opi_sn=', -1), '&', 1) AS opi_sn_value,
COUNT(*) AS count
FROM log_message
WHERE request_uri LIKE '%/v3/dir_count%'
GROUP BY opi_sn_value
) a
select DISTINCT OPI_RealName from V_Operator_Info voi
where OPI2_OPI_SN in ('513','5130','466','621','404','622','383','609','510','512','582','586','633','0','415','639','641','640','577','654','601','602','599','535','568','496','648','691','690','525','540','626','162','634','487','585','594','628','611','624','674','637','522','676','606','631','451','551','489','583','495','503','719','698','644','605','587','588','589','509','552','526','227','501','515','581','216','575','484','687','679','370','580','490','617','618','619','261','600','603','604','114','579','481','387','629','354','492','632','414','660','574','79','486','663','391','584','482','252','264','265','376','453','649','651','650','210','212','343','565','143','591','590','328','476','593','514','576','595','536','543','564','178','528','541','625','119','571','598','573','332','413','155','330','627','550','742','612','444','360','519','421','146','553','441')
## 查找出掉线的 WhatsApp
select *
from whatsapp_individual.connections
where status IN ('offline')
and wa_id not in ('8618777396951', '8613557032060','8613317835586')
and wa_id <> 'null'
//
SELECT group_concat(opi_sn separator ',') as 'sn_list' FROM (
SELECT
@ -116,3 +138,60 @@ WHERE tos = '5534999923993' AND opi_sn = 587;
* end
* ---------------------------------------------------------
*/
/**
WhatsApp
*/
select * from whatsapp_user
##where wau_opi_sn in (252, 261,264,265,330,360,376,413,421,453,605,620) ## 国际部
where wau_opi_sn in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587,354, 414, 599, 606, 639, 648, 654, 662, 674, 676,391, 451, 476, 501, 512, 525, 528, 585, 586, 644) ## GH
-- 查找每个服务器在线的 WA 数量
select connect_name, count(*)
from whatsapp_individual.connections
where status IN ('open')
group by connect_name
set SESSION group_concat_max_len=4294967295;
-- 查找已经配置 WAI 服务的顾问
select group_concat(wau_whatsapp separator ''',''') as 'sn_list'
from sale_system.whatsapp_user
where wau_wai_server is not null
## 查找在线的 WhatsApp
SELECT group_concat(sesson_id separator ''',''') as 'sesson_list'
FROM whatsapp_individual.connections
where status = 'open'
-- 查找掉线的顾问
select group_concat(sesson_id separator ''',''') as 'sesson_list' from whatsapp_individual.connections
where status IN ('offline')
and wa_id not in ('8618777396951', '8613557032060','8613317835586')
## 查找 GH 没有扫码登录的顾问
select group_concat(wau_opi_sn separator ',') as 'sn_list'
from whatsapp_user
where wau_whatsapp not in ('8613317835586','8617607732272','8613978392676','8618378304803','8617607730629','8619107833371','8619107835971','8617607731491','8615080129281','8617607737720','8618777396951','8618078444860','8615778462307','8617774702925','8615078398450','8619178340224','8617607731153','8617607735120','8617607737646','8618877388203','8615778493040','8613617733956','8618290167273','8617776515283','8617607736381','8613557032060','8613667839691','8618378388403','8613635132972','8617607734598','8617607732512','8615878340720')
and wau_opi_sn in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587,354, 414, 599, 606, 639, 648, 654, 662, 674, 676,391, 451, 476, 501, 512, 525, 528, 585, 586, 644)
-- 查找使用 WhatsApp 顾问信息
SELECT
OPI_SN, OPI_Code,OPI_Name,OPI_DEI_SN,OPI_FirstName,OPI_RealName, DeleteFlag
FROM
dbo.OperatorInfo WHERE
OPI_SN in (143,476,528,391)
--OPI_SN in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587)
SELECT OPI_RealName + '(' + CAST(OPI_SN AS VARCHAR(100)) + ')',OPI_SN
FROM
dbo.OperatorInfo
where
--DeleteFlag = 0 and
OPI_SN in (79,85,114,119,135,143,155,162,178,210,216,222,225,252,261,264,265,273,293,296,311,330,343,347,348,354,360,370,376,387,391,412,413,414,421,441,444,451,453,456,466,468,476,495,497,501,509,512,513,514,517,519,522,525,527,528,539,550,573,585,586,587,592,599,600,605,606,611,617,620,639,644,648,654,656,659,662,663,674,676,690,691)
and OPI_RealName in ('兰芬','孙俊垚','王继伟','曾君','潘宏宇','郑美珍','张丽娟','张倩倩','赵泽菲','王影','陆力影','吕燕珍','何秋云','沈慧香')

@ -1,7 +1,7 @@
{
"name": "global-sales",
"private": true,
"version": "1.2.1-beta.9",
"version": "1.6.7",
"type": "module",
"scripts": {
"dev": "vite",
@ -10,32 +10,46 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@dckj/react-better-modal": "^0.1.2",
"@lexical/react": "^0.20.0",
"@vonage/client-sdk": "^1.7.2",
"antd": "^5.22.2",
"@haina/utils-commons": "https://research.hainatravel.com/npm/utils-commons-0.1.2.tgz",
"@haina/utils-pagespy": "https://research.hainatravel.com/npm/utils-pagespy-0.1.2.tgz",
"@haina/utils-request": "https://research.hainatravel.com/npm/utils-request-0.1.2.tgz",
"@lexical/code": "^0.34.0",
"@lexical/hashtag": "^0.34.0",
"@lexical/html": "^0.34.0",
"@lexical/link": "^0.34.0",
"@lexical/list": "^0.34.0",
"@lexical/markdown": "^0.34.0",
"@lexical/react": "^0.34.0",
"@lexical/rich-text": "^0.34.0",
"@lexical/selection": "^0.34.0",
"@lexical/table": "^0.34.0",
"@lexical/utils": "^0.34.0",
"@vonage/client-sdk": "^2.0.0",
"antd": "^5.25.2",
"dayjs": "^1.11.13",
"dingtalk-jsapi": "^3.0.41",
"emoji-picker-react": "^4.12.0",
"lexical": "^0.20.0",
"lexical": "^0.34.0",
"prismjs": "^1.30.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-chat-elements": "^12.0.18",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"zustand": "^4.5.5",
"react-chat-elements": "^12.0.17",
"react-router-dom": "^6.30.1",
"rxjs": "^7.8.1",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"workbox-window": "^7.3.0",
"zustand": "^4.5.7"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"@vonage/client-sdk": "^1.7.2",
"autoprefixer": "^10.4.20",
"eslint": "^8.45.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.14",
"eslint": "^9.33.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"vite": "^4.5.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@ -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="M11 4H21V6H11V4ZM6 7V11H4V7H1L5 3L9 7H6ZM6 17H9L5 21L1 17H4V13H6V17ZM11 18H21V20H11V18ZM9 11H21V13H9V11Z"></path></svg>

After

Width:  |  Height:  |  Size: 208 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

@ -1,4 +1,4 @@
import { fetchJSON, postForm, } from '@/utils/request';
import { fetchJSON, postForm, } from '@haina/utils-request';
import { API_HOST } from '@/config';
/**

@ -1,9 +1,9 @@
import { groupBy, isNotEmpty, pick, sortArrayByOrder, sortBy } from '@/utils/commons';
import { fetchJSON, postJSON, postForm } from '@/utils/request'
import { groupBy, isNotEmpty, pick, sortArrayByOrder, sortBy } from '@haina/utils-commons';
import { fetchJSON, postJSON, postForm } from '@haina/utils-request'
import { parseRenderMessageList } from '@/channel/bubbleMsgUtils';
import { API_HOST } from '@/config';
import { isEmpty } from '@/utils/commons';
import { isEmpty } from '@haina/utils-commons';
import dayjs from 'dayjs';
/**
@ -11,24 +11,95 @@ import dayjs from 'dayjs';
*/
export const fetchTemplates = async (params) => {
const data = await fetchJSON(`${API_HOST}/listtemplates`, params);
const canUseTemplates = (data?.result?.items || [])
.filter((_t) => _t.status === 'APPROVED' && !['say_hello_from_trip_advisor','free_style_7','free_style_1','free_style_2'].includes(_t.name))
.map((ele, i) => ({ ...ele, components_origin: ele.components, components: groupBy(ele.components, (_c) => _c.type.toLowerCase()), key:ele.name, displayName: templatesDisplayNameMap?.[ele.name] || ele.name}));
const leftPageCnt = Math.ceil( data?.result?.total/100 || 0)-1;
const leftData = await Promise.all(Array.from({ length: leftPageCnt }, (_, i) => fetchJSON(`${API_HOST}/listtemplates`, {...params, page: i+2})));
const leftItems = leftData.map(item => item.result.items).flat();
const topName = [
'order_updated_specialist_assigned_sharon',
'agent_intro_with_update_v1',
'online_inquiry_received',
'say_hello_again',
'first_message_for_not_reply',
'order_updated_specialist_assigned_christy',
'order_resumed_specialist_followup_schedule_sharon',
'travel_service_update_v2',
'travel_service_update_v1',
'order_updated_specialist_assigned_sharon',
'travel_service_update_v3',
'first_message_for_not_reply',
// 'free_style_3',
// 'free_style_4',
// CR
'notification_of_following_up_by_cr_v3','notification_of_following_up_by_cr_v1',
'notification_of_following_up_by_cr_v2',
'membership_activation_update_by_cr_v1', '45d_before_the_trip_referral_voucher_by_cr_v0',
'membership_activation_update_by_cr_v0',
'departure_reminder_by_cr_v5','departure_reminder_by_cr_v2',
];
// shouwcase
const scNames = ['trip_planner_showcase', 'showcase_different', 'order_status_updated'];
// 客运
const crNames = [
'notification_of_next_trip_planning',
// 'notification_of_following_up_by_cr_v3',
'notification_of_one_day_before_ending_the_trip_by_cr_v2',
'one_day_after_payment_by_yuni',
'notification_of_status_changed',
'notification_of_one_day_before_ending_the_trip_by_cr','one_day_after_payment_by_customer_relations',
'one_day_before_ending_the_trip_contacted_by_yuni','one_day_before_ending_the_trip_first_time_by_yuni',
'post_booking_confirmation_welcome',
];
const crNamesAuto = [
'notification_of_status_changed', 'notification_of_next_trip_planning_by_cr_v2',
'notification_of_payment_received_3_asea_by_cr', 'one_day_after_payment_by_yuni',
'30_days_after_end_of_the_trip_asean_referral_voucher_by_cr',
'7notification_of_one_day_before_ending_the_trip_only_asean_by_cr_v7',
'notification_of_one_day_before_ending_the_trip_by_cr_v2',
];
const NamesOmit = [
'birthday_greetings_by_marketing','one_day_before_ending_the_trip_by_marketing',
'introduce_the_voucher_one_day_before_ending_the_trip_by_marketing',
'birthday_greetings_by_customer_relations_0',
'post_trip_voucher_issued',
'account_updated_order_ref',
'post_trip_account_updated_from_cr',
'post_trip_account_updated',
'account_update_birthday',
'post_trip_birthday_reward',
'birthday_greetings_by_customer_relations_2',
'birthday_greetings_by_customer_relations_1',
'notification_of_account_updated_by_cr',
'birthday_greetings_by_customer_relations',
'one_day_before_ending_the_trip_by_customer_relations',
'network_troubleshooting',
]
const top = sortArrayByOrder( canUseTemplates.filter((_t) => topName.includes(_t.name)), 'name', topName);
const canUseTemplates = (data?.result?.items || []).concat(leftItems)
.filter((_t) => _t.status === 'APPROVED' && !['say_hello_from_trip_advisor', 'free_style_7', 'free_style_1', 'free_style_2'].includes(_t.name))
.map((ele, i) => ({
...ele,
components_origin: ele.components,
components: groupBy(ele.components, (_c) => _c.type.toLowerCase()),
key: ele.name,
// displayName: ele.name.startsWith('order_updated') ? templatesDisplayNameMap['order_updated']+`_${i}` : templatesDisplayNameMap?.[ele.name] || ele.name,
displayName: templatesDisplayNameMap?.[ele.name] || (ele.name.startsWith('order_updated') ? templatesDisplayNameMap['order_updated'] + `_${i}` : ele.name),
displayLanguage: NamesOmit.includes(ele.name)
? '--'
: crNamesAuto.includes(ele.name)
? '客运Task'
: crNames.includes(ele.name) || ele.name.includes('by_cr')
? ele.language + '-客运'
: scNames.includes(ele.name)
? ele.language + '-示例'
: ele.language.slice(0, 2),
}))
const top2Name = topName.concat(canUseTemplates.filter(_t => _t.name.startsWith('order_updated')).map(_tem => _tem.name));
const top = sortArrayByOrder( canUseTemplates.filter((_t) => top2Name.includes(_t.name)), 'name', topName);
const second = canUseTemplates.filter(_t => _t.name.includes('free_style'));
const secondS = second.sort(sortBy('name'));
const raw = canUseTemplates.filter((_t) => !topName.includes(_t.name) && !_t.name.includes('free_style'));
return [...top, ...secondS, ...raw];
const raw = canUseTemplates.filter((_t) => !top2Name.includes(_t.name) && !_t.name.includes('free_style'));
// 剩下的排序
const rawS = sortArrayByOrder(raw, 'name', [...crNames, ...scNames, ...NamesOmit ]);
return [...top, ...secondS, ...rawS];
};
/**
* 上面的模板名称bak
@ -39,12 +110,14 @@ export const fetchTemplates = async (params) => {
*/
const templatesDisplayNameMap = {
'order_updated_specialist_assigned_sharon': 'specialist_followup',
'order_updated_specialist_assigned_christy': 'specialist_followup',
'order_updated_specialist_assigned_christy': 'specialist_followup_1',
'online_inquiry_received': 'online_inquiry_received/say_hello',
'order_resumed_specialist_followup_schedule_sharon': 'order_resumed/specialist_followup',
'order_updated': 'specialist_followup',
'agent_intro_with_update_v1': 'quick_update_v1',
};
export const CONVERSATION_PAGE_SIZE = 20;
export const CONVERSATION_PAGE_SIZE = 100;
/**
*
* @param {object} params { opisn }
@ -74,7 +147,7 @@ export const fetchConversationsList = async (params) => {
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
coli_id: ele.COLI_ID,
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
}))
return list;
@ -129,6 +202,7 @@ export const fetchConversationItemClose = async (body) => {
* @param {object} body { phone_number, name }
*/
export const postNewOrEditConversationItem = async (body) => {
body.whatsapp_phone_number = `${body.whatsapp_phone_number || ''}`.trim();
const formData = new FormData();
Object.keys(body).forEach(function (key) {
formData.append(key, body[key]);
@ -159,7 +233,7 @@ export const postEditConversationItemColiAction = async (params) => {
};
/**
* @param {object} params { opisn, whatsappid }
* @param {object} params { opisn, conversationid }
*/
export const fetchCleanUnreadMsgCount = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/clean_unread_msg_count`, params);
@ -205,9 +279,10 @@ export const fetchConversationsSearch = async (params) => {
opi_sn: ele.OPI_SN || ele.opi_sn || 0,
OPI_Name: `${ele.OPI_Name || ele.opi_name || ''}`.trim(),
opi_name: `${ele.OPI_Name || ele.opi_name || ''}`.trim(),
dateText: dayjs((ele.lasttime || ele.lasttime)).format('MM-DD HH:mm'),
dateText: dayjs((ele.lasttime)).format('MM-DD HH:mm'),
matchMsgList: parseRenderMessageList((ele.msglist_AsJOSN || [])), // .reverse()),
coli_id: '',
show_default: ele.session_memo || ele.whatsapp_name || ele?.whatsapp_phone_number || ele?.guest_email || '',
}));
return list;
};
@ -218,14 +293,15 @@ export const fetchConversationsSearch = async (params) => {
*/
export const fetchMessagesHistory = async (params) => {
const defaultParams = {
opisn: '',
whatsappid: '',
// opisn: '',
// whatsappid: '',
conversationid: '',
lasttime: '2024-01-01T00:00:00',
pagesize: MESSAGE_PAGE_SIZE,
pagedir: 'next',
};
const _params = pick(params, Object.keys(defaultParams));
if (isEmpty(_params.whatsappid)) {
if (isEmpty(_params.conversationid)) {
return [];
}
const { errcode, result } = await fetchJSON(`${API_HOST}/get_item_messages`, {...defaultParams, ..._params});

@ -1,12 +1,36 @@
import { fetchJSON, postForm } from '@/utils/request';
import { API_HOST, EMAIL_HOST } from '@/config';
import { fetchJSON, postForm, postJSON } from '@haina/utils-request';
import { API_HOST, API_HOST_V3, DATE_FORMAT, DATEEND_FORMAT, DATETIME_FORMAT, EMAIL_HOST, EMAIL_HOST_v3 } from '@/config';
import { buildTree, groupBy, isEmpty, objectMapper, omitEmpty, uniqWith } from '@haina/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 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 {
const { result } = await fetchJSON(`${EMAIL_HOST}/email_sign`, params)
const { SignContent: html } = result
return parseHTMLString(html);
const bodyContent = parseHTMLString(html);
return bodyContent;
} catch (error) {
return '';
}
@ -28,6 +53,7 @@ export const getSalesSignatureAction = async (params) => {
*/
export const postSendEmail = async (body) => {
const { attaList=[], atta, content, ...bodyData } = body;
bodyData.ordertype = 227001;
const formData = new FormData();
Object.keys(bodyData).forEach(function (key) {
formData.append(key, bodyData[key]);
@ -55,10 +81,18 @@ export const postResendEmailAction = async (body) => {
const encodeEmailInfo = (info) => {
const encodeQuote = (str = '') => str.replace(/"/g, ''); //.replace(/</g,'&lt;').replace(/>/g,'&gt;')
const CSsClean = encodeQuote(info.MAI_CS.replace(';', ',')).split(',');
const tosClean = (encodeQuote(info.MAI_To.replace(';', ',')).split(',')).map(e => e.trim()).filter(s => s);
const replyTo = info.MAI_Direction === 1 ? info.MAI_To : info.MAI_From;
const replyToAll = [].concat([info.MAI_From], tosClean);
return {
...info,
MAI_From: encodeQuote(info.MAI_From),
MAI_To: encodeQuote(info.MAI_To),
tos: [...new Set(tosClean)],
ccs: [...new Set(CSsClean)],
replyToAll,
replyTo,
}
};
/**
@ -66,12 +100,45 @@ const encodeEmailInfo = (info) => {
* @param {object} { mai_sn }
*/
export const getEmailDetailAction = async (params) => {
// const cacheKey = params.mai_sn;
// const readCache = await readIndexDB(cacheKey, 'mailinfo', 'mailbox');
// if (!isEmpty(readCache)) { // todo: 除了草稿
// return readCache.data;
// }
const { result } = await fetchJSON(`${EMAIL_HOST}/getmail`, params);
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 }) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getorderinfo`, { colisn })
return errcode === 0 ? { ...result[0], customerDetail: result[0].contact[0] } : {}
}
/**
* 主动收邮件, 单个账户
* @param {object} { opi_sn, }
@ -101,3 +168,323 @@ export const fetchEmailBindOrderAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/mailinfo_bindorder`, params)
return errcode === 0 ? true : false;
}
const todoTypes = {
// 1新订单2WhatsApp未读消息3需一催4需二催5需三催6未处理邮件入境提醒coli_ordertype=7余款提醒coli_ordertype=8
1: '新订单',
2: '未读WhatsApp',
3: '一催',
4: '二催',
5: '三催',
6: '老邮件',
7: '入境提醒',
8: '余款提醒',
}
/**
* 顾问的邮箱目录
* @param {object} params { opi_sn, year, by_start_date, by_success, important, if_want_book, if_thinking }
* @param {boolean} retOrder 是否直接返回订单列表 -- 忽略
*/
export const getEmailDirAction = async (params = { opi_sn: '' }, retOrder=false) => {
const defaultParams = { opi_sn: 0, year: dayjs().year(), by_start_date: -1, by_success: -1, important: -1, if_want_book: -1, if_thinking: -1 }
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/email_dir`, { ...defaultParams, ...params })
const mailboxSort = result //.sort(sortBy('MDR_Order'));
let tree = buildTree(mailboxSort, { key: 'VKey', parent: 'VParent', name: 'VName', iconIndex: 'ImageIndex', rootKeys: [1], ignoreKeys: [-227001, -227002] })
tree = tree.filter((ele) => ele.key !== 1)
const retTree = errcode === 0 ? tree : [];
const orderList = groupBy(result, row => `${row.IsTrue}`)?.['0'] || [];
return retOrder !== false ? orderList : { [`${params.opi_sn}`]: retTree }
};
export const getMailboxCountAction = async (params = { opi_sn: '' }, update = true) => {
const defaultParams = {
opi_sn: 0,
// date1: dayjs().subtract(1, 'year').startOf('year').format(DATE_FORMAT),
date1: dayjs().subtract(180, 'days').format(DATE_FORMAT),
date2: dayjs().format(DATEEND_FORMAT)
}
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/dir_count`, {...defaultParams, ...params})
const ret = errcode !== 0 ? { [`${params.opi_sn}`]: {} } : { [`${params.opi_sn}`]: result }
// 更新数量
if (update !== false) {
const readCacheDir = (await readIndexDB(Number(params.opi_sn), 'dirs', 'mailbox')) || {};
const mailboxDir = isEmpty(readCacheDir) ? [] : readCacheDir.tree.filter(node => node?._raw?.IsTrue === 1);
const _MapDir = new Map(mailboxDir.map((obj) => [obj.key, obj]))
Object.keys(result).map(dirKey => {
_MapDir.set(Number(dirKey), {..._MapDir.get(Number(dirKey)), count: result[dirKey]});
})
const _newToUpdate = Array.from(_MapDir.values());
const _MapRoot = new Map((readCacheDir?.tree || []).map((obj) => [obj.key, obj]))
_newToUpdate.forEach((row) => {
_MapRoot.set(row.key, row)
})
const _newRoot = Array.from(_MapRoot.values())
writeIndexDB([{ ...readCacheDir, key: Number(params.opi_sn), tree: _newRoot }], 'dirs', 'mailbox')
notifyMailboxUpdate({ type: 'dirs', key: Number(params.opi_sn) })
}
return ret;
};
export const getTodoOrdersAction = async (params) => {
const opi_arr = params.opisn.split(',')
const defaultStickyTree = opi_arr.reduce(
(a, ele) => ({
...a,
[ele]: [
{
key: ele + '-today',
title: '今日任务',
getMails: false,
iconIndex: 'star',
children: [],
COLI_SN: 0,
},
{
key: ele + '-todo',
title: '待办任务',
getMails: false,
iconIndex: 'calendar',
children: [],
COLI_SN: 0,
},
// {
// key: ele.OPI_DEI_SN + '-reminder',
// title: '催信',
// getMails: false,iconIndex: 'reminder',
// icon: <BellTwoTone />,
// children: [], COLI_SN: 0,
// },
],
}),
{},
)
const { errcode, result } = await fetchJSON(`${API_HOST}/getwlorder`, params)
// 订单重复时, 取后一个状态, 因此翻转两次
const _result_unique = uniqWith(result.reverse(), (a, b) => a.COLI_SN === b.COLI_SN).reverse();
const orderList = errcode === 0 ? _result_unique : []
const byOPI = groupBy(orderList, 'OPI_SN')
const byState = Object.keys(byOPI).reduce((acc, key) => {
const sticky = groupBy(byOPI[key], (ele) => ([1, 6].includes(ele.coli_ordertype) ? 0 : [2, 3, 4, 5].includes(ele.coli_ordertype) ? 1 : 2))
const treeNode = [
{
key: key + '-today',
title: '今日任务',
getMails: false,
iconIndex: 'star',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[0] || []).map((o) => ({
key: `today-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-today',
parentTitle: '今日任务',
parentIconIndex: 'star',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-today', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-today' },
})),
},
{
key: key + '-todo',
title: '待办任务',
getMails: false,
iconIndex: 'calendar',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[2] || []).map((o) => ({
key: `todo-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-todo',
parentTitle: '待办任务',
parentIconIndex: 'calendar',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-todo', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-todo' },
})),
},
...(!isEmpty(sticky[1] || [])
? [
{
key: key + '-reminder',
title: '催信',
getMails: false,
iconIndex: 'reminder',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[1] || []).map((o) => ({
key: `reminder-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-reminder',
parentTitle: '催信',
parentIconIndex: 'reminder',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-reminder', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-reminder' },
})),
},
]
: []),
]
return { ...acc, [key]: treeNode }
}, defaultStickyTree)
return errcode === 0 ? byState : defaultStickyTree
};
/**
* 获取待办目录和邮箱目录
* @param {object} params { opi_sn, userIdStr }
* @param {number} params.opi_sn
* @param {string} params.userIdStr - 用户ID字符串默认为空
*/
export const getRootMailboxDirAction = async ({ opi_sn = 0, userIdStr = '' } = {}) => {
const [stickyTree, ...mailboxDir] = await Promise.all([
getTodoOrdersAction({ opisn: userIdStr || String(opi_sn), otype: 'today' }),
...(userIdStr.split(',').map(_opi => getEmailDirAction({ opi_sn: _opi }))),
])
const mailBoxCount = await Promise.all(userIdStr.split(',').map(_opi => getMailboxCountAction({ opi_sn: _opi }, false)));
const mailboxDirCountByOPI = mailBoxCount.reduce((a, c) => ({ ...a, ...c, }), {})
const mailboxDirByOPI = mailboxDir.reduce((a, c) => ({ ...a, ...(Object.keys(c).reduce((a, opi) => ({...a, [opi]: c[`${opi}`].map((dir) => ({ ...dir, count: mailboxDirCountByOPI[opi][`${dir.key}`] })) }), {} )) }), {})
const rootTree = Object.keys(stickyTree).map((opi) => ({ key: Number(opi), tree: [...stickyTree[opi], ...(mailboxDirByOPI?.[opi] || [])], treeTimestamp: Date.now() }))
writeIndexDB(rootTree, 'dirs', 'mailbox')
const _mapped = groupBy(rootTree, 'key')
return _mapped[opi_sn]?.[0]?.tree || []
}
/**
* 获取邮件列表
* @usage 邮件目录下的邮件列表
* @usage 订单的邮件列表
*/
export const queryEmailListAction = async ({ opi_sn = '', pagesize = 10, last_id = '', node = {}, } = {}) => {
const _params = {
vkey: 0,
vparent: 0,
order_source_type: 0,
// mai_senddate1: dayjs().subtract(1, 'year').startOf('year').format(DATE_FORMAT),
mai_senddate1: dayjs().subtract(180, 'days').format(DATE_FORMAT),
mai_senddate2: dayjs().format(DATEEND_FORMAT),
...omitEmpty({
...node,
opi_sn,
}),
}
_params.mai_senddate1 = dayjs(_params.mai_senddate1).format(DATE_FORMAT)
const cacheKey = isEmpty(_params.coli_sn) ? `dir-${node.vkey}` : `order-${node.vkey}`;
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/mail_list`, _params)
const ret = errcode === 0 ? result : []
if (!isEmpty(ret)) {
const listids = [...new Set(ret.map(ele => ele.MAI_SN))];
writeIndexDB([{key: cacheKey, data: listids}], 'maillist', 'mailbox')
writeIndexDB(ret.map(ele => ({ data: {...ele, listKey: cacheKey }, key: ele.MAI_SN})), 'listrow', 'mailbox')
}
return ret;
}
export const searchEmailListAction = async ({opi_sn = '', mailboxtype = 'ALL', sender = '', receiver = '', subject = '', content=''}={}) => {
const formData = new FormData()
formData.append('opi_sn', opi_sn)
formData.append('mailboxtype', mailboxtype)
formData.append('sender', sender)
formData.append('receiver', receiver)
formData.append('subject', subject)
// formData.append('content', content)
const { errcode, result } = await postForm(`${API_HOST_V3}/mail_search`, formData)
const ret = errcode === 0 ? result : []
notifyMailboxUpdate({ type: 'maillist-search-result', query: [sender, receiver, subject].filter(s => s).join(' '), data: ret.map(ele => ({...ele, key: ele.MAI_SN, showFolder: true })) })
return ret;
}
const removeFromCurrentList = async (params) => {
const readRow0 = await readIndexDB(params.mai_sn_list[0], 'listrow', 'mailbox')
const listKey = readRow0?.data?.listKey || ''
if (listKey) {
const readCache = await readIndexDB(listKey, 'maillist', 'mailbox')
const updatedMailList = readCache.data.filter((mai_sn) => !params.mai_sn_list.includes(mai_sn))
writeIndexDB([{ key: listKey, data: updatedMailList }], 'maillist', 'mailbox')
notifyMailboxUpdate({ type: 'listrow', listKey, affectKeys: params.mai_sn_list })
}
}
const updateEmailKeyMap = { read: 'MOI_ReadState' };
const updateEmailKeyFun = {
read: async (params) => {
const readCache = await readIndexDB(params.mai_sn_list, 'listrow', 'mailbox')
const updateField = Object.keys(params.set).reduce((a, c) => ({ ...a, [updateEmailKeyMap[c]]: params.set[c] }), {})
writeIndexDB(
params.mai_sn_list.map((ele) => ({ data: { ...(readCache.get(ele)?.data || {}), ...updateField }, key: ele })),
'listrow',
'mailbox',
)
// 通知邮件列表数据更新
const listKey = readCache.get(params.mai_sn_list[0])?.data?.listKey || '';
const notificationPayload = { type: 'listrow', listKey, affectKeys: params.mai_sn_list }
notifyMailboxUpdate(notificationPayload)
},
processed: removeFromCurrentList,
delete: removeFromCurrentList
}
/**
* 更新邮件属性
*/
export const updateEmailAction = async (params = { opi_sn: 0, mai_sn_list: [], set: {} }) => {
if (isEmpty(params.mai_sn_list)) {
throw new Error('没有需要更新的邮件');
}
const { errcode, result } = await postJSON(`${API_HOST_V3}/mail_update`, params)
if (errcode === 0 ) {
for (const [key, value] of Object.entries(params.set)) {
const updateFun = updateEmailKeyFun[key] || (() => {});
updateFun(params)
}
}
getMailboxCountAction({ opi_sn: params.opi_sn });
return errcode === 0 ? result : {}
}
/**
* 获取邮件模板
* @param {object} params - Parameters for the email template request.
* @param {number} [params.coli_sn] - Customer order line item serial number.
* @param {number} [params.lgc] - Language code.
* @param {number} [params.opi_sn] - Order product item serial number.
* @param {string} [params.remind_type] - Type of reminder.
* @param {number} [params.remind_index] - Index of the reminder.
*/
export const getReminderEmailTemplateAction = async (params = { coli_sn: 0, lgc: 1, opi_sn: 0, remind_type: '', remind_index: 0 }) => {
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/reminder_letter`, params)
const { html, bodyContent, bodyText } = parseHTMLString(result?.MailContent, true) ;
return errcode === 0 ? {...result, bodyContent }: {}
}
/**
* 保存邮件草稿
* @param {object} body - The body of the email.
* @param {boolean} [isDraft=false] - Whether the email is a draft.
*/
export const saveEmailDraftOrSendAction = async (body, isDraft = false) => {
const url = isDraft !== false ? `${API_HOST_V3}/email_draft_save` : `${EMAIL_HOST_v3}/sendmail`;
const { attaList=[], atta, content, ...bodyData } = body;
bodyData.ordertype = 227001;
const formData = new FormData();
Object.keys(bodyData).forEach(function (key) {
formData.append(key, bodyData[key] || '');
});
// 附件只传新增的
attaList.filter(ele => !ele.fullPath).forEach(function (item) {
formData.append('attachment', item);
});
const { errcode, result } = await postForm(url, formData);
return errcode === 0 ? (result || {}) : {}
};
/**
* 删除邮件附件
*/
export const deleteEmailAttachmentAction = async (ati_sn_list) => {
const { errcode, result } = await postJSON(`${API_HOST_V3}/mail_attachment_delete`, { ati_sn_list })
return errcode === 0 ? result : {}
};
export const queryHTOrderListAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/query_order`, params)
return errcode !== 0 ? [] : result
}
export const queryOPIOrderAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getdvancedwlorder`, params)
return errcode !== 0 ? [] : result
};

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@ -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

@ -1,4 +1,4 @@
import { cloneDeep, isEmpty, olog, fixTo2Decimals, pick } from "@/utils/commons";
import { cloneDeep, isEmpty, olog, fixTo2Decimals, pick, objectMapper } from "@haina/utils-commons";
import dayjs from "dayjs";
import { v4 as uuid } from "uuid";
@ -40,7 +40,45 @@ export const WABAccounts = [
"decision": "DEFERRED",
"requestedVerifiedName": "Global Highlights",
"rejectionReason": "NONE"
}
},
{
"id": "563254206874812",
"phoneNumber": "+639454682947",
"wabaId": "190290134156880",
"verifiedName": "Customer Relation Specialist",
"qualityRating": "UNKNOWN",
"messagingLimit": "TIER_1K",
"isOfficialBusinessAccount": false,
"codeVerificationStatus": "VERIFIED",
"status": "CONNECTED",
"displayPhoneNumber": "+63 945 468 2947",
"nameStatus": "DECLINED",
"newNameStatus": "NONE",
"decision": "DEFERRED",
"requestedVerifiedName": "Customer Relation Specialist",
"rejectionReason": "NONE"
},
{
"id": "955633124303178",
"phoneNumber": "+85265210895",
"wabaId": "190290134156880",
"verifiedName": "Customer Relation Specialist at Highlights",
"qualityRating": "UNKNOWN",
"qualityUpdateEvent": "ONBOARDING",
"messagingLimit": "TIER_2K",
"whatsappBusinessManagerMessagingLimit": "TIER_2K",
"isOfficialBusinessAccount": false,
"codeVerificationStatus": "VERIFIED",
"status": "CONNECTED",
"displayPhoneNumber": "+852 6521 0895",
"nameStatus": "APPROVED",
"newName": "Customer Relation Specialist at Highlights",
"newNameStatus": "NONE",
"decision": "APPROVED",
"requestedVerifiedName": "Customer Relation Specialist at Highlights",
"rejectionReason": "NONE",
"isOnBizApp": false
},
];
export const WABAccountsMapped = WABAccounts.reduce((a, c) => ({ ...a, [removeFirstPlus(c.phoneNumber)]: c, [c.phoneNumber]: c }), {})
@ -49,14 +87,18 @@ export const replaceTemplateString = (str, replacements) => {
let keys = str.match(/{{(.*?)}}/g).map(key => key.replace(/{{|}}/g, ''));
for (let i = 0; i < keys.length; i++) {
let replaceValue = replacements[i];
let template = new RegExp(`{{${keys[i]}}}`, 'g');
result = result.replace(template, replaceValue);
const replaceValue = replacements[i];
result = result.replaceAll(`{{${keys[i]}}}`, replaceValue);
}
return result;
}
export const whatsappTemplateBtnParamTypesMapped = {
'copy_code': 'coupon_code',
// 'quick_reply': 'payload',
};
/**
* @deprecated 在渲染时处理
*/
@ -81,6 +123,7 @@ const mediaMsg = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgcontent: {
@ -96,7 +139,7 @@ const mediaMsg = {
...msg,
actionId: msg.id,
conversationid: msg.id.split('.')[0],
data: { ...msg.data, status: { download: msg.data?.loading ? false : true, click: true, loading: msg.data.loading } },
data: { ...msg.data, status: { download: true, click: true, loading: 0 } },
...(msg.context
? {
reply: {
@ -116,6 +159,7 @@ export const sentMsgTypeMapped = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgtype: 'text',
@ -199,6 +243,7 @@ export const sentMsgTypeMapped = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgtype: 'template',
@ -206,18 +251,18 @@ export const sentMsgTypeMapped = {
...msg.template,
components: [
...msg.template.components.filter((com) => !['footer', 'buttons'].includes(com.type.toLowerCase())),
...(msg.template.components.filter((com) => 'buttons' === com.type.toLowerCase()).length > 0
? msg.template.components
.filter((com) => 'buttons' === com.type.toLowerCase())[0]
// .buttons.filter((btns) => ! ['phone_number', 'url'].includes( btns.type.toLowerCase()))
.buttons.filter((btns) => !isEmpty(btns.example)) // 静态按钮不发
.map((btn, btnI) => ({
type: 'button',
sub_type: btn.type.toLowerCase(),
index: btnI,
// parameters: [{ text: 'lq1FTtA8', type: 'text' }]
}))
: []),
// ...(msg.template.components.filter((com) => 'buttons' === com.type.toLowerCase()).length > 0
// ? msg.template.components
// .filter((com) => 'buttons' === com.type.toLowerCase())[0]
// // .buttons.filter((btns) => ! ['phone_number', 'url'].includes( btns.type.toLowerCase()))
// .buttons.filter((btns) => !isEmpty(btns.example)) // 静态按钮不发
// .map((btn, btnI) => ({
// type: 'button',
// sub_type: btn.type.toLowerCase(),
// index: btnI,
// // parameters: [{ text: 'lq1FTtA8', type: 'text' }]
// }))
// : []),
],
},
}),
@ -285,19 +330,21 @@ const whatsappMsgMapped = {
'whatsapp.inbound_message.received': {
getMsg: (result) => {
// console.log('whatsapp.inbound_message.received', result);
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id']);
return isEmpty(result?.whatsappInboundMessage) ? null : { ...result.whatsappInboundMessage, ...data1, messageorigin: result.messageorigin, msg_source: 'WABA', msg_direction: 'inbound' };
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id'])
return isEmpty(result?.whatsappInboundMessage) ? null : { ...result.whatsappInboundMessage, ...data1, messageorigin: result.messageorigin, msg_source: 'WABA', msg_direction: 'inbound' }
},
contentToRender: (contentObj) => {
// console.log('whatsapp.inbound_message.received to render', contentObj);
return parseRenderMessageItem(contentObj);
return parseRenderMessageItem(contentObj)
},
contentToUpdate: () => null,
},
'whatsapp.message.updated': {
getMsg: (result) => {
// console.log('getMsg', result);
return isEmpty(result?.whatsappMessage) ? null : { ...result.whatsappMessage, conversationid: result.conversationid, messageorigin: result.messageorigin, msg_source: 'WABA', msg_direction: 'outbound' };
return isEmpty(result?.whatsappMessage)
? null
: { ...result.whatsappMessage, conversationid: result.conversationid, messageorigin: result.messageorigin, msg_source: 'WABA', msg_direction: 'outbound' }
},
contentToRender: (contentObj) => {
if (contentObj?.status === 'failed' && ['130472', 'BAD_REQUEST'].includes(contentObj.errorCode)) {
@ -307,22 +354,88 @@ const whatsappMsgMapped = {
text: { body: `${whatsappError?.[contentObj.errorCode] || contentObj.errorMessage}` }, // contentObj.errorMessage // Message failed to send.
id: contentObj.id,
wamid: contentObj.id,
};
return parseRenderMessageItem(contentObj);
}
return parseRenderMessageItem(contentObj)
}
// * 仅更新消息状态, 没有输出
return null;
return null
},
contentToUpdate: (msgcontent) => ({
...msgcontent,
...parseRenderMessageItem(msgcontent),
id: msgcontent.wamid,
status: msgStatusRenderMapped[(msgcontent?.status || 'failed')],
status: msgStatusRenderMapped[msgcontent?.status || 'accepted'],
sender: 'me',
dateString: msgcontent.status==='failed' ? `发送失败 ${whatsappError?.[msgcontent.errorCode] || msgcontent.errorMessage || ''}` : '',
dateString: msgcontent.status === 'failed' ? `发送失败 ${whatsappError?.[msgcontent.errorCode] || msgcontent.errorMessage || ''}` : '',
}),
},
};
'wai.message.received': {
getMsg: (result) => {
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id'])
return isEmpty(result?.waiMessage)
? null
: { ...result.waiMessage, ...data1, messageorigin: result.messageorigin, msg_source: 'wai', ...objectMapper(result.waiMessage, { direction: { key: 'msg_direction' } }) }
},
contentToRender: (contentObj) => {
return parseRenderMessageItem(contentObj)
},
contentToUpdate: () => null,
},
'wai.message.updated': {
getMsg: (result) => {
return isEmpty(result?.waiMessage)
? null
: {
...result.waiMessage,
conversationid: result.conversationid,
messageorigin: result.messageorigin,
msg_source: 'wai',
...objectMapper(result.waiMessage, { direction: { key: 'msg_direction' } }),
}
},
contentToRender: (contentObj) => {
if (contentObj?.status === 'failed') {
contentObj = {
...contentObj,
type: 'error',
text: { body: `` }, // contentObj.errorMessage // Message failed to send.
id: contentObj.id,
wamid: contentObj.id,
}
return parseRenderMessageItem(contentObj)
}
// * 仅更新消息状态, 没有输出
return null
},
contentToUpdate: (msgcontent) => ({
...msgcontent,
...parseRenderMessageItem(msgcontent),
id: msgcontent.wamid,
status: msgcontent.msg_direction === 'outbound' ? msgStatusRenderMapped[msgcontent?.status || 'accepted'] : '',
sender: msgcontent.msg_direction === 'outbound' ? 'me' : msgcontent?.customerProfile?.name || '',
dateString: msgcontent.status === 'failed' ? `发送失败 ❌` : '',
}),
},
'wai.creds.update': {
getMsg: (result) => {
return isEmpty(result?.waiMessage)
? {}
: { ...result.waiMessage, conversationid: result.conversationid, msg_source: 'wai', }
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
contentToNotify: (contentObj) => {
return {
...contentObj,
status: contentObj?.status || '',
key: contentObj.to || '',
content: `WhatsApp号码: ${contentObj.to}`,
title: (contentObj.status === 'offline') ? `WhatsApp 断开连接` : '',
type: (contentObj.status === 'offline') ? 'warning' : 'info',
};
},
},
}
const emailMsgMapped = {
'email.inbound.received': {
getMsg: (result) => {
@ -348,7 +461,7 @@ const emailMsgMapped = {
...msgcontent,
...parseRenderMessageItem({...msgcontent, }),
id: msgcontent.id,
status: msgStatusRenderMapped[(msgcontent?.status || 'failed')],
status: msgStatusRenderMapped[(msgcontent?.status || 'accepted')],
sender: 'me',
dateString: msgcontent.status==='failed' ? `发送失败 ❌` : '',
}),
@ -371,6 +484,47 @@ const emailMsgMapped = {
},
}
}
const sessionMsgMapped = {
'session.new': {
getMsg: (result) => {
// sessionItem 是数组
return isEmpty(result?.sessionItem)
? null
: result.sessionItem.map((ele) => ({
...ele,
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
msg_source: 'session',
msg_direction: 'inbound',
}))
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
},
'session.updated': {
getMsg: (result) => {
// sessionItem 是数组
return isEmpty(result?.sessionItem)
? []
: result.sessionItem.map((ele) => ({
...ele,
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
msg_source: 'session',
msg_direction: 'inbound',
// last_message: {...ele.last_message, text: { body: ele.last_message?.text_body || '', preview_url: null }},
}))
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
},
}
export const msgStatusRenderMapped = {
'accepted': 'waiting', // 'sent', // 接口的发送请求
'sent': 'sent',
@ -396,7 +550,7 @@ export const receivedMsgTypeMapped = {
...msgcontent,
actionId: msgcontent.actionId,
id: msgcontent.wamid,
status: msgStatusRenderMapped[(msgcontent?.status || 'failed')],
status: msgStatusRenderMapped[(msgcontent?.status || 'accepted')],
conversationid: msgcontent.actionId.split('.')[0], // msgcontent.conversation.sn,
date: msgcontent.createTime,
sender: 'me',
@ -407,6 +561,8 @@ export const receivedMsgTypeMapped = {
getMsg: (result) => result,
contentToRender: () => null,
contentToUpdate: (msgcontent) => {
if (isEmpty(msgcontent)) return null;
if (isEmpty(msgcontent.error)) return null;
let apiErrorCode,
apiErrorMsg = '';
const waCode = msgcontent.error.message.match(/\(#(\d+)\)/);
@ -428,6 +584,7 @@ export const receivedMsgTypeMapped = {
},
},
...cloneDeep(emailMsgMapped),
...cloneDeep(sessionMsgMapped),
};
/**
* 消息类型处理, 合并各渠道类型
@ -438,10 +595,12 @@ export const whatsappMsgTypeMapped = {
error: {
type: (_m) => ({ type: 'system' }),
data: (msg) => ({ id: msg.wamid, text: msg.errorCode ? msg.errorMessage : msg.text.body }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.errorCode ? msg.errorMessage : msg.text.body }),
},
system: {
type: 'system',
data: (msg) => ({ id: msg.wamid, text: msg.system?.body }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg?.text?.body || msg?.text }),
},
text: {
type: 'text',
@ -477,7 +636,7 @@ export const whatsappMsgTypeMapped = {
width: 'auto',
height: 200,
alt: msg.image?.caption || '',
message: msg.image?.caption || '',
message: msg.image?.caption || '[图片]',
}),
},
sticker: {
@ -552,13 +711,23 @@ export const whatsappMsgTypeMapped = {
// unsupported: { type: 'system', data: (msg) => ({ text: 'Message type is currently not supported.' }) },
unsupported: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: `[暂不支持此消息类型](${msg.wamid})`, dateString: `${dayjs(msg.sendTime).format('MM-DD HH:mm')} [ WhatsApp未提供消息内容 ] 可能是客人删除消息/会话, \n可询问客人截图/详细内容 或 忽略 📌` }),
renderForReply: (msg) => ({ id: msg?.wamid || msg.id, text: `[Message type unsupported](${msg.wamid})` }),
data: (msg) => ({ id: msg.wamid, text: `[对方删除消息](${msg.wamid})`, dateString: `${dayjs(msg.sendTime).format('MM-DD HH:mm')} [ WhatsApp未提供消息内容 ] 客人删除消息/会话` }),
renderForReply: (msg) => ({ id: msg?.wamid || msg?.id || '', message: `[Message type unsupported](${msg.wamid})` }),
},
unresolvable: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: `[无法解析](${msg.wamid})`, }),
renderForReply: (msg) => ({ id: msg?.wamid || msg?.id || '', message: `[无法解析](${msg.wamid})` }),
},
reaction: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: msg.reaction?.emoji || '' }),
renderForReply: (msg) => ({ id: msg.wamid, text: msg.reaction?.emoji || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.reaction?.emoji || '' }),
},
button: {
type: 'text', // todo: 后端返回 type='button' button: { payload, text }
data: (msg) => ({ id: msg.wamid, text: msg.button?.payload || msg.button?.text || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.button?.payload || msg.button?.text || '' }),
},
document: {
type: 'file',
@ -593,11 +762,11 @@ export const whatsappMsgTypeMapped = {
type: 'location',
data: (msg) => ({
id: msg.wamid,
title: `位置信息 ${msg.location.name || ''} 已转高德地图, ↓点击打开`,
text: msg.location.address, // 地址
// src: `https://uri.amap.com/marker?position=${msg.location.longitude},${msg.location.latitude}&callnative=1`,
title: `位置信息 ${msg.location?.name || ''} 已转高德地图, ↓点击打开`,
text: msg.location?.address, // 地址
// src: `https://uri.amap.com/marker?position=${msg.location?.longitude},${msg.location?.latitude}&callnative=1`,
src: 'https://cdn.pixabay.com/photo/2016/03/22/04/23/map-1272165_1280.png',
href: `https://uri.amap.com/marker?position=${msg.location.longitude},${msg.location.latitude}&callnative=1`,
href: `https://uri.amap.com/marker?position=${msg.location?.longitude},${msg.location?.latitude}&callnative=1`,
data: {
longitude: msg.location?.longitude,
latitude: msg.location?.latitude,
@ -612,11 +781,11 @@ export const whatsappMsgTypeMapped = {
template: {
type: 'text',
data: (msg) => {
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [v.type]: v }), {}) : {};
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [(v.type).toLowerCase()]: v }), {}) : {};
return { id: msg.wamid, text: autoLinkText(templateDataMapped?.body?.text || `......${(templateDataMapped?.body?.parameters || []).map(pv => pv?.text || '').join('......')}......`), title: '模板消息', }; // msg.template.name
},
renderForReply: (msg) => {
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [v.type]: v }), {}) : null;
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [(v.type).toLowerCase()]: v }), {}) : null;
return { id: msg.wamid, message: templateDataMapped?.body?.text || templateDataMapped?.body?.parameters?.[0]?.text || '', title: '模板消息', }; // `${msg.template.name}`
},
},
@ -639,6 +808,7 @@ export const parseRenderMessageItem = (msg) => {
// console.log('parseRenderMessageItem', msg);
const thisMsgType = Object.keys(whatsappMsgTypeMapped).includes(msg.type) ? msg.type : 'unsupported';
return {
...msg,
opi_sn: msg.opi_sn || '',
msgOrigin: msg,
date: msg?.sendTime || msg?.createTime || '',
@ -650,15 +820,15 @@ export const parseRenderMessageItem = (msg) => {
dateString: dayjs(msg?.sendTime || msg.createTime).format('MM-DD HH:mm'),
from: msg.from,
sender: msg.from,
senderName: msg?.customerProfile?.name || msg?.fromName || msg?.from || 'me',
senderName: msg.msg_direction === 'outbound' ? 'me' : msg?.customerProfile?.name || msg?.fromName || msg?.from || '',
customer_name: msg?.customerProfile?.name || '',
whatsapp_name: msg?.customerProfile?.name || '',
whatsapp_phone_number: isEmpty(msg?.customerProfile) ? msg.to : msg.from,
// whatsapp_msg_type: msg.msg_source==='WABA' ? msg.type : '',
// whatsapp_msg_type: (msg.msg_source || 'WABA') === 'WABA' ? msg.type : '', // 1.0接口没有msg_source
statusCN: msgStatusRenderMappedCN[msg?.status || 'failed'],
statusTitle: msgStatusRenderMappedCN[msg?.status || 'failed'],
replyButton: !['waiting', 'failed'].includes(msg?.status || '') ,
statusCN: msgStatusRenderMappedCN[msg?.status || 'accepted'],
statusTitle: msgStatusRenderMappedCN[msg?.status || 'accepted'],
replyButton: !['accepted', 'waiting', 'failed'].includes(msg?.status || '') ,
...((isEmpty(msg.context) && isEmpty(msg.reaction)) || msg.context?.forwarded === true // || isEmpty(msg.messageorigin)
? {}
: {
@ -676,15 +846,18 @@ export const parseRenderMessageItem = (msg) => {
origin: msg.context,
}),
msg_source: msg?.msg_source || msg.type,
whatsapp_msg_type: '',
waba: '',
wabaName: '',
...((msg.msg_source) === 'WABA' ? {
whatsapp_msg_type: msg.type,
waba: msg.msg_direction === 'outbound' ? msg.from : msg.to,
wabaName: WABAccountsMapped[msg.msg_direction === 'outbound' ? msg.from : msg.to]?.verifiedName,
} : {
whatsapp_msg_type: '',
waba: '',
wabaName: '',
}),
} : {}),
...((msg.msg_source) === 'wai' ? {
whatsapp_msg_type: msg.type,
wabaName: '个人号',
} : {}),
};
};
/**
@ -706,18 +879,18 @@ export const parseRenderMessageList = (messages) => {
// }
}
const msgContent = typeof msgtext === 'string' ? JSON.parse(msgContentString) : (msgtext || {});
const msgType = isEmpty(msgContent) ? msg.msgtype : (Object.keys(whatsappMsgTypeMapped).includes(msgContent.type) ? msgContent.type : 'unsupported')
const msgType = isEmpty(msgContent) ? msg.msgtype : (Object.keys(whatsappMsgTypeMapped).includes(msgContent.type) ? msgContent.type : 'unresolvable')
msgContent.template = msg.msgtype === 'template' ? { ...msgContent.template, ...template } : {};
// const parseMethod = msgContent.bizType === 'whatsapp' ? cloneDeep(whatsappMsgTypeMapped) : {};
let waCode, waError = '';
if ((msgContent?.status || 'failed') === 'failed' && msgContent.errorMessage && msg.msg_direction === 'outbound') {
(waCode = msgContent.errorMessage.match(/\(#(\d+)\)/));
(waError = (whatsappError?.[waCode?.[1]] || whatsappError?.[msgContent.errorCode] || msgContent.errorMessage));
if ((msgContent?.status || 'accepted') === 'failed' && (msgContent.errorMessage || msg.errors_code) && msg.msg_direction === 'outbound') {
(waCode = (msgContent.errorMessage || msg.errors_code).match(/\(#(\d+)\)/));
(waError = (whatsappError?.[waCode?.[1]] || whatsappError?.[msgContent.errorCode] || msgContent.errorMessage || whatsappError?.[msg.errors_code]));
if (!isEmpty(msgContent.whatsappApiError)) {
waError = whatsappError?.[msgContent.whatsappApiError.code] || msgContent.whatsappApiError.message;
// 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;
}
}
@ -737,20 +910,20 @@ export const parseRenderMessageList = (messages) => {
from: msgContent.from,
sender: msgContent.from,
senderName: msgContent?.customerProfile?.name || msgContent.from || 'me',
replyButton: !['waiting', 'failed'].includes(msgContent?.status || '') ,
replyButton: !['accepted', 'waiting', 'failed'].includes(msgContent?.status || '') ,
// 用forwarded表示Resend, 与Reply互斥
forwarded: msg.msg_direction === 'outbound' && msg.msg_source === 'email' && ['email'].includes(msgContent.type) && (msgContent?.status || 'failed') === 'failed',
forwarded: msg.msg_direction === 'outbound' && msg.msg_source === 'email' && ['email'].includes(msgContent.type) && (msgContent?.status || 'accepted') === 'failed',
...(msg.msg_direction === 'outbound'
? {
sender: 'me',
senderName: 'me',
status: msgStatusRenderMapped[msgContent?.status || 'failed'],
dateString: msgStatusRenderMapped[msgContent?.status || 'failed'] === 'failed' ? `${(msg.msgtime || '').replace('T', ' ')} 发送失败 ${waError}` : '',
statusCN: msgStatusRenderMappedCN[msgContent?.status || 'failed'],
statusTitle: msgStatusRenderMappedCN[msgContent?.status || 'failed'],
id: (msgContent?.status || 'failed') === 'failed' ? msgContent.actionId : (msgTypeData.id || msg.id || msg.sn),
status: msgStatusRenderMapped[msgContent?.status || 'accepted'],
dateString: msgStatusRenderMapped[msgContent?.status || 'accepted'] === 'failed' ? `${(msg.msgtime || '').replace('T', ' ')} 发送失败 ${waError}` : '',
statusCN: msgStatusRenderMappedCN[msgContent?.status || 'accepted'],
statusTitle: msgStatusRenderMappedCN[msgContent?.status || 'accepted'],
id: (msgContent?.status || 'accepted') === 'failed' ? (msgContent.actionId || msgContent.id) : (msgTypeData.id || msg.id || msg.sn),
actionId: msgContent.actionId,
replyButton: !['waiting', 'failed'].includes(msgContent?.status || 'failed') ,
replyButton: !['accepted', 'waiting', 'failed'].includes(msgContent?.status || 'accepted') ,
}
: {}),
...(isEmpty(messageorigin) && (isEmpty(msgContent.context) || msgContent.context?.forwarded === true)
@ -773,15 +946,18 @@ export const parseRenderMessageList = (messages) => {
// conversationid: conversationid,
// title: msg.customerProfile.name,
// whatsapp_msg_type: (msg.msg_source || 'WABA') === 'WABA' ? msgContent.type : '', // 1.0接口没有msg_source
whatsapp_msg_type: '',
waba: '',
wabaName: '',
...((msg.msg_source) === 'WABA' ? {
whatsapp_msg_type: msgContent.type,
waba: msg.msg_direction === 'outbound' ? msgContent.from : msgContent.to,
wabaName: WABAccountsMapped[msg.msg_direction === 'outbound' ? msgContent.from : msgContent.to]?.verifiedName,
} : {
whatsapp_msg_type: '',
waba: '',
wabaName: '',
}),
} : {}),
...((msg.msg_source) === 'wai' ? {
whatsapp_msg_type: msgContent.type,
wabaName: '个人号',
} : {}),
}
});
};
@ -794,12 +970,12 @@ export const whatsappError = {
'100': '参数错误, 请联系技术组',
'FORBIDDEN': '[FORBIDDEN] ',
'4': '[4] 无法连接WhatsApp.\n请稍后重试', // (#4) Application request limit reached
'131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用邮件联系',
'131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用 邮件/个人WhatsApp 联系',
'131047': '[131047] 会话未激活. \n请使用模板消息💬发送',
'131053': '[131053] 文件上传失败.',
'131048': '[131048] 账户被风控.', // 消息发送太多, 达到垃圾数量限制
'131049': '[131049] 号码触发风控. \n请暂停发送营销消息, 使用跟进模板\n或引导客户主动发起会话.', // 消息发送太多, 营销限制
'131031': '[131031] 账户已锁定.',
'131049': '[131049] 号码触发风控. \n请暂停发送营销消息, 使用"触达率高"模板\n或引导客户主动发起会话.', // 消息发送太多, 营销限制
'131031': '[131031] 账户已被禁用.',
'130472': '[130472] 此号码不接收商业号消息\n请使用邮件联系 或 引导客户主动发起会话.',
};
@ -918,3 +1094,120 @@ export const phoneNumberToWAID = (input) => {
}
export const uploadProgressSimulate = () => fixTo2Decimals(Math.random() * (0.8 - 0.2) + 0.2);
// Parse text segments for URLs and numbers
const parseTextForMarkdown = (text) => {
// Find URLs and four-digit numbers
const urlRegex = /https?:\/\/[^\s]+/g;
const numberRegex = /\d{4,}/g;
const matches = [];
// Find all URLs
let match;
while ((match = urlRegex.exec(text)) !== null) {
matches.push({ start: match.index, end: match.index + match[0].length, type: 'url', content: match[0] });
}
// Find all 4+ digit numbers
numberRegex.lastIndex = 0; // Reset regex
while ((match = numberRegex.exec(text)) !== null) {
matches.push({ start: match.index, end: match.index + match[0].length, type: 'number', content: match[0] });
}
// Sort matches by position
matches.sort((a, b) => a.start - b.start);
// Remove overlapping matches (URLs take priority)
const filteredMatches = [];
for (const current of matches) {
const isOverlapping = filteredMatches.some(existing =>
(current.start >= existing.start && current.start < existing.end) ||
(current.end > existing.start && current.end <= existing.end)
);
if (!isOverlapping) {
filteredMatches.push(current);
}
}
// Split text into segments
const segments = [];
let currentIndex = 0;
for (const match of filteredMatches) {
if (currentIndex < match.start) {
const textContent = text.slice(currentIndex, match.start);
if (textContent) {
segments.push({ type: 'text', content: textContent });
}
}
segments.push(match);
currentIndex = match.end;
}
if (currentIndex < text.length) {
const textContent = text.slice(currentIndex);
if (textContent) {
segments.push({ type: 'text', content: textContent });
}
}
return segments.length > 0 ? segments : [{ type: 'text', content: text }];
};
// Parse markdown with nesting support, autolinks, and number recognition
export const parseSimpleMarkdown = (text) => {
const tokens = [];
let current = '';
let i = 0;
while (i < text.length) {
const char = text[i];
if (char === '*' || char === '_') {
// Save any accumulated text before processing markdown
if (current) {
tokens.push(...parseTextForMarkdown(current));
current = '';
}
// Find the closing marker
const marker = char;
let j = i + 1;
let content = '';
let found = false;
while (j < text.length) {
if (text[j] === marker) {
found = true;
break;
}
content += text[j];
j++;
}
if (found && content) {
// Recursively parse the content inside the markers
tokens.push({
type: marker === '*' ? 'bold' : 'italic',
content: parseSimpleMarkdown(content)
});
i = j + 1; // Skip past the closing marker
} else {
// If no closing marker found, treat as regular text
current += char;
i++;
}
} else {
current += char;
i++;
}
}
// Add any remaining text
if (current) {
tokens.push(...parseTextForMarkdown(current));
}
return tokens;
};

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

@ -2,7 +2,7 @@ import { createContext, useEffect, useState } from 'react';
import {} from 'antd';
import Modal from '@dckj/react-better-modal';
import '@dckj/react-better-modal/dist/index.css';
import { isEmpty } from '@/utils/commons';
import { isEmpty } from '@haina/utils-commons';
import useStyleStore from '@/stores/StyleStore';
const DnDModal = ({ children, open, setOpen, onCancel, onMove, onResize, initial = {}, title, footer=null, ...props }) => {
@ -46,7 +46,7 @@ const DnDModal = ({ children, open, setOpen, onCancel, onMove, onResize, initial
maskClosable={false}
// theme='dark'
// className={'!border !border-solid !border-indigo-500 rounded !p-2' }
titleBarClassName='!bg-neutral-100 !rounded !rounded-b-none !border-none !p-3 !font-bold !text-slate-600'
titleBarClassName={`!bg-neutral-100 !rounded !rounded-b-none !border-none !p-3 !font-bold !text-slate-600 ${props.titleClassName}`}
contentClassName='!p-2'
footerClassName='!p-2'
className={`!rounded-t !rounded-b-none !border !border-solid !shadow-heavy ${props.rootClassName}`}

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import LexicalEditor from '@/components/LexicalEditor'
import { isEmpty } from '@/utils/commons';
import { isEmpty } from '@haina/utils-commons';
/**
* 封装的编辑组件, 用于在Form.Item 中使用

@ -15,7 +15,9 @@ class ErrorBoundary extends PureComponent {
componentDidCatch(error, info) {
console.error('Sorry, Something went wrong.')
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 })
}

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

@ -45,11 +45,13 @@ import TableCellResizer from './plugins/TableCellResizer';
// import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import FormatPaintPlugin from './plugins/FormatPaint';
import { TextNode, $getRoot, $getSelection, $createParagraphNode } from 'lexical';
import { $generateHtmlFromNodes, $generateNodesFromDOM, } from '@lexical/html';
// import { } from '@lexical/clipboard';
import { isEmpty } from '@/utils/commons';
import { isEmpty } from '@haina/utils-commons';
import {useSettings} from './context/SettingsContext';
import './styles.css';
@ -203,6 +205,7 @@ export default function Editor({ isRichText, isDebug, editorRef, onChange, defau
<EditorRefPlugin editorRef={editorRef} />
<ImagesPlugin />
<InlineImagePlugin />
<FormatPaintPlugin />
<MyOnChangePlugin onChange={onChange}/>
</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,
$createParagraphNode,
$getNodeByKey,
$isTextNode,
} from 'lexical';
import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link';
import {
@ -25,12 +26,14 @@ import {
// $wrapNodes,
$isAtNodeEnd,
} 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 { 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 { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode';
import {$isDecoratorBlockNode} from '@lexical/react/LexicalDecoratorBlockNode';
import {$isTableSelection} from '@lexical/table';
import DropDown, { DropDownItem } from './../ui/DropDown';
import DropdownColorPicker from '../ui/DropdownColorPicker';
import {
@ -39,6 +42,7 @@ import {
// InsertImagePayload,
} from './ImagesPlugin';
import {InsertInlineImageDialog} from './InlineImagePlugin';
import FormatPainterToolbarButton from './FormatPaint/FormatPainterToolbarButton';
import useModal from './../hooks/useModal';
@ -70,16 +74,32 @@ const FONT_FAMILY_OPTIONS = [
const FONT_SIZE_OPTIONS = [
['10px', '10px'],
['11px', '11px'],
// ['11px', '11px'],
['12px', '12px'],
['13px', '13px'],
['14px', '14px'],
['15px', '15px'],
// ['15px', '15px'],
['16px', '16px'],
['17px', '17px'],
// ['17px', '17px'],
['18px', '18px'],
['19px', '19px'],
// ['19px', '19px'],
['20px', '20px'],
['24px', '24px'],
['32px', '32px'],
// ['48px', '48px'],
];
const LINE_SPACING_OPTIONS = [
['1', '1'],
['1.25', '1.25'],
['1.5', '1.5'],
['2', '2'],
['2.5', '2.5'],
['3', '3'],
// ['3.5', '3.5'],
// ['4', '4'],
// ['4.5', '4.5'],
// ['5', '5'],
];
const ELEMENT_FORMAT_OPTIONS = {
@ -91,6 +111,62 @@ const ELEMENT_FORMAT_OPTIONS = {
start: { icon: 'left-align', iconRTL: 'right-align', name: 'Start Align' },
};
// toolbar utils
const clearFormatting = (editor) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
const anchor = selection.anchor;
const focus = selection.focus;
const nodes = selection.getNodes();
const extractedNodes = selection.extract();
if (anchor.key === focus.key && anchor.offset === focus.offset) {
return;
}
nodes.forEach((node, idx) => {
// We split the first and last node by the selection
// So that we don't format unselected text inside those nodes
if ($isTextNode(node)) {
// Use a separate variable to ensure TS does not lose the refinement
let textNode = node;
if (idx === 0 && anchor.offset !== 0) {
textNode = textNode.splitText(anchor.offset)[1] || textNode;
}
if (idx === nodes.length - 1) {
textNode = textNode.splitText(focus.offset)[0] || textNode;
}
/**
* If the selected text has one format applied
* selecting a portion of the text, could
* clear the format to the wrong portion of the text.
*
* The cleared text is based on the length of the selected text.
*/
// We need this in case the selected text only has one format
const extractedTextNode = extractedNodes[0];
if (nodes.length === 1 && $isTextNode(extractedTextNode)) {
textNode = extractedTextNode;
}
if (textNode.__style !== '') {
textNode.setStyle('');
}
if (textNode.__format !== 0) {
textNode.setFormat(0);
$getNearestBlockElementAncestorOrThrow(textNode).setFormat('');
}
node = textNode;
} else if ($isHeadingNode(node) || $isQuoteNode(node)) {
node.replace($createParagraphNode(), true);
} else if ($isDecoratorBlockNode(node)) {
node.setFormat('');
}
});
}
});
};
function dropDownActiveClass(active) {
if (active) {
return 'active dropdown-item-active';
@ -471,6 +547,26 @@ function BlockOptionsDropdownList({ editor, blockType, toolbarRef, setShowBlockO
);
}
const FontDropDownMapped = {
'font-family': {
buttonAriaLabel: 'Formatting options for font family',
IconClassName: 'icon icon2 block-type font-family',
options: FONT_FAMILY_OPTIONS,
styleName: 'fontFamily',
},
'font-size': {
buttonAriaLabel: 'Formatting options for font size',
IconClassName: '',
options: FONT_SIZE_OPTIONS,
styleName: 'fontSize',
},
'line-height': {
buttonAriaLabel: 'Formatting options for line spacing',
IconClassName: 'icon icon2 line-height',
options: LINE_SPACING_OPTIONS,
styleName: 'lineHeight',
},
}
function FontDropDown({ editor, value, style, disabled = false }) {
const handleClick = useCallback(
(option) => {
@ -486,25 +582,23 @@ function FontDropDown({ editor, value, style, disabled = false }) {
[editor, style]
);
const buttonAriaLabel = style === 'font-family' ? 'Formatting options for font family' : 'Formatting options for font size';
const buttonIconClassName = FontDropDownMapped[style].IconClassName;
const buttonAriaLabel = FontDropDownMapped[style].buttonAriaLabel;
const dropdownOptions = FontDropDownMapped[style].options;
return (
<DropDown
disabled={disabled}
buttonClassName={'toolbar-item ' + style}
// buttonLabel={value}
buttonIconClassName={style === 'font-family' ? 'icon block-type font-family' : ''}
buttonAriaLabel={buttonAriaLabel}>
{(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map(([option, text]) => (
<DropDown disabled={disabled} buttonClassName={'toolbar-item ' + style} buttonLabel={value} buttonIconClassName={buttonIconClassName} buttonAriaLabel={buttonAriaLabel}>
{dropdownOptions.map(([option, text]) => (
<DropDownItem
className={`item font-m-${option.replace(/\s+/g, '_')} ${dropDownActiveClass(value === option)} ${style === 'font-size' ? 'fontsize-item' : ''}`}
style={{ fontFamily: style === 'font-family' ? option : undefined, fontSize: style === 'font-size' ? option : undefined, }}
onClick={() => handleClick(option)}
key={option}>
<span className='text'>{text}</span>
</DropDownItem>
))}
</DropDown>
);
)
}
function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) {
@ -605,9 +699,11 @@ export default function ToolbarPlugin() {
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const [fontFamily, setFontFamily] = useState('Arial');
const [fontSize, setFontSize] = useState('16px');
const [fontColor, setFontColor] = useState('#000');
const [bgColor, setBgColor] = useState('#fff');
const [elementFormat, setElementFormat] = useState('left');
const [lineSpacing, setLineSpacing] = useState('1.5');
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null);
const [isSmallWidthViewport, setIsSmallWidthViewport] = useState(false);
@ -692,6 +788,12 @@ export default function ToolbarPlugin() {
setFontFamily(
$getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'),
);
setFontSize(
$getSelectionStyleValueForProperty(selection, 'font-size', '16px'),
);
setLineSpacing(
$getSelectionStyleValueForProperty(selection, 'line-height', '1.5'),
);
let matchingParent;
if ($isLinkNode(parent)) {
// If node is a link, we need to fetch the parent paragraph node to set format
@ -785,6 +887,14 @@ export default function ToolbarPlugin() {
aria-label='Redo'>
<i className='format redo' />
</button>
<FormatPainterToolbarButton />
<button type='button'
onClick={() => clearFormatting(activeEditor)}
className='toolbar-item'
title="清除格式"
aria-label='Clear'>
<i className='format clear' />
</button>
<Divider />
{supportedBlockTypes.has(blockType) && (
<>
@ -814,6 +924,18 @@ export default function ToolbarPlugin() {
value={fontFamily}
editor={editor}
/>
<FontDropDown
disabled={!isEditable}
style={'font-size'}
value={fontSize}
editor={editor}
/>
<FontDropDown
disabled={!isEditable}
style={'line-height'}
value={lineSpacing}
editor={editor}
/>
<Divider />
<button type='button'
onClick={() => {

@ -80,13 +80,14 @@
.editor-input {
min-height: 150px;
resize: none;
font-size: 15px;
font-size: 16px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 10px;
caret-color: #444;
line-height: 1.5;
}
.editor-pure-input {
@ -404,6 +405,7 @@ pre::-webkit-scrollbar-thumb {
padding: 8px;
cursor: pointer;
vertical-align: middle;
word-break: keep-all;
}
.toolbar button.toolbar-item:disabled {
@ -414,7 +416,7 @@ pre::-webkit-scrollbar-thumb {
margin-right: 2px;
}
.toolbar button.toolbar-item i.format {
.toolbar button.toolbar-item i.format, .toolbar button.toolbar-item .icon2 {
background-size: contain;
display: inline-block;
height: 18px;
@ -430,7 +432,8 @@ pre::-webkit-scrollbar-thumb {
}
.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 {
@ -475,12 +478,12 @@ pre::-webkit-scrollbar-thumb {
.toolbar .toolbar-item .text {
display: flex;
line-height: 20px;
width: 200px;
/* width: 200px; */
vertical-align: middle;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
width: 70px;
width: 3rem;
overflow: hidden;
height: 20px;
text-align: left;
@ -604,7 +607,8 @@ i.chevron-down {
background-size: contain;
}
button.item.dropdown-item-active {
background-color: #dfe8fa4d;
/* background-color: #dfe8fa4d; */
background-color: #eef2ff;
}
.dropdown .item:first-child {
@ -761,6 +765,9 @@ i.undo {
i.redo {
background-image: url(/images/icons/arrow-clockwise.svg);
}
i.clear{
background-image: url(/images/icons/eraser-line.svg);
}
.icon.paragraph {
background-image: url(/images/icons/text-paragraph.svg);
@ -836,6 +843,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")
}
i.painter {
background-image: url(/images/icons/brush-3-line.svg);
}
i.bold {
background-image: url(/images/icons/type-bold.svg);
}
@ -879,6 +890,10 @@ i.justify-align {
background-image: url(/images/icons/justify.svg);
}
i.line-height, .icon.line-height {
background-image: url(/images/icons/line-height.svg);
}
.editor-container span.editor-image {
cursor: default;
display: inline-block;

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

@ -0,0 +1,77 @@
import { useState } from "react";
import { Popover, message, FloatButton, Button, Form, Input } from "antd";
import { BugOutlined } from "@ant-design/icons";
import useAuthStore from '@/stores/AuthStore'
import { sendNotify } from "@/utils/pagespy";
import { uploadPageSpyLog } from "@haina/utils-pagespy";
function LogUploader() {
const [open, setOpen] = useState(false);
const hide = () => {
setOpen(false);
};
const handleOpenChange = (newOpen) => {
setOpen(newOpen);
};
const [loginUser] = useAuthStore((s) => [s.loginUser]);
const [messageApi, contextHolder] = message.useMessage();
const [formBug] = Form.useForm();
const popoverContent = (
<Form
layout={"vertical"}
form={formBug}
initialValues={{ problem: '' }}
scrollToFirstError
onFinish={async (values) => {
const success = await uploadPageSpyLog();
messageApi.success("Thanks for the feedback😊");
if (success) {
sendNotify(`${loginUser?.username}(${loginUser?.userIdStr})说:${values.problem}`);
} else {
sendNotify(`${loginUser?.username}(${loginUser?.userIdStr})上传日志失败`);
}
hide();
formBug.setFieldsValue({problem: ''});
}}
>
<Form.Item
name="problem"
label="Need help?"
rules={[{ required: true, message: "Specify issue needing support." }]}
>
<Input.TextArea rows={3} />
</Form.Item>
<Button
type="primary"
htmlType="submit"
color="cyan"
variant="solid"
block
>
Submit
</Button>
</Form>
);
return (
<>
{contextHolder}
<Popover
content={popoverContent}
trigger={["click"]}
placement="topRight"
open={open}
onOpenChange={handleOpenChange}
fresh
destroyOnHidden
>
<FloatButton icon={<BugOutlined />} tooltip={<div>上传日志给研发部</div>} />
</Popover>
</>
);
}
export default LogUploader;

@ -0,0 +1,363 @@
import {
WhatsAppOutlined,
FileAddOutlined,
MailOutlined,
PhoneOutlined,
UserOutlined,
FieldNumberOutlined,
CompassOutlined,
CalendarOutlined,
EditOutlined,
CheckOutlined,
CopyOutlined
} 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, Empty, Form, Input } from 'antd'
import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions, remindStatusOptions } from '@/stores/OrderStore'
import { copy, isEmpty } from '@haina/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 = structuredClone(OrderLabelDefaultOptions)
orderLabelOptions.unshift({ value: 0, label: '未设置', disabled: true })
const orderStatusOptions = structuredClone(OrderStatusDefaultOptions)
const [orderDetail, customerDetail, fetchOrderDetail, setOrderPropValue,
appendOrderComment, updateWhatsapp, updateExtraInfo, remindCheckList, updateRemindState] = useOrderStore((s) => [
s.orderDetail,
s.customerDetail,
s.fetchOrderDetail,
s.setOrderPropValue,
s.appendOrderComment,
s.updateWhatsapp,
s.updateExtraInfo,
s.remindCheckList,
s.updateRemindState
])
const loginUser = useAuthStore((state) => state.loginUser)
const currentOrder = useConversationStore(useShallow((state) => state.currentConversation?.coli_sn || ''))
const orderId = coliSN || currentOrder
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) => {
try {
await updateRemindState(coliSN, checkedValue)
message.success('设置成功')
} catch (error) {
notification.warning({ message: '设置失败', description: error.message, placement: 'top', duration: 60 })
}
}
const getCustomerName = () => {
if (orderDetail.buytime > 0) return customerDetail.name + '(R' + orderDetail.buytime + ')'
return customerDetail.name
}
const getPlanStatus = () => {
return orderDetail.DidPlan === 0 ? '未做计划' : '已做计划'
}
const renderOrderDetail = () => {
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}
<CopyOutlined onClick={() => {
navigator.clipboard.writeText(orderDetail.order_no)
message.success('已复制😀')
}}/>
</Typography.Text>
<Typography.Text>
<UserOutlined className=' pr-1' />
{getCustomerName()}
</Typography.Text>
<Typography.Text>
<CompassOutlined className=' pr-1' />
{orderDetail.MEI_Country}
</Typography.Text>
<Typography.Text>
<PhoneOutlined className=' pr-1' />
{customerDetail.phone}
</Typography.Text>
<Typography.Text>
<MailOutlined className='pr-1' />
{customerDetail.email}
</Typography.Text>
<Typography.Text>
<WhatsAppOutlined className='pr-1' />
{isEmpty(customerDetail.whatsapp_phone_number) ? (
<Button type='text' onClick={() => setOpenWhatsApp(true)} size='small'>
设置 WhatsApp
</Button>
) : (
<Link to={`/order/chat/${coliSN}`} state={{...orderDetail, coli_guest_WhatsApp: customerDetail.whatsapp_phone_number, }}>
{customerDetail.whatsapp_phone_number}
</Link>
)}
</Typography.Text>
<Typography.Text>
<Tooltip title='出发日期'>
<CalendarOutlined className='pr-1' />
{orderDetail.COLI_OrderStartDate}
</Tooltip>
</Typography.Text>
<Typography.Text>
<Tooltip title='计划状态'>
<CheckOutlined className='pr-1' />
{getPlanStatus()}
</Tooltip>
</Typography.Text>
</Flex>
<Divider orientation='left'>
<Typography.Text strong>订单状态</Typography.Text>
</Divider>
<Flex gap='small' vertical={false} justify='space-between'>
<Select
className={`[&_.ant-select-selection-item]:text-gray-950`}
key={'orderlabel'}
size='small'
style={{
width: '100%',
}}
variant='underlined'
onSelect={(value) => {
setOrderPropValue(coliSN, 'orderlabel', value)
.then(() => {
message.success('设置成功')
})
.catch((reason) => {
notification.error({
message: '设置出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}
value={orderDetail.tags}
options={orderLabelOptions}
/>
<Select
className={`[&_.ant-select-selection-item]:text-gray-950`}
key={'orderstatus'}
size='small'
style={{
width: '100%',
}}
variant='underlined'
onSelect={(value) => {
setOrderPropValue(coliSN, 'orderstatus', value)
.then(() => {
message.success('设置成功')
})
.catch((reason) => {
notification.error({
message: '设置出错',
description: reason.message,
placement: 'top',
duration: 60,
})
})
}}
value={orderDetail.states}
options={orderStatusOptions}
/>
</Flex>
<Divider orientation='left'>
<Typography.Text strong>催信</Typography.Text>
</Divider>
<Checkbox.Group key='substatus' className='px-2' value={remindCheckList} 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>
</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>
</>
)
}
const renderDefaultEmpty = () => {
return (
<Empty description={<span>没有订单关联</span>}>
</Empty>
)
}
if (orderId) {
return renderOrderDetail()
} else {
return props.renderEmpty ? props.renderEmpty() : renderDefaultEmpty()
}
}
export default OrderProfile

@ -1,6 +1,6 @@
import React, { useMemo, useRef, useState } from 'react';
import { Select, Spin } from 'antd';
import { debounce } from '@/utils/commons';
import { debounce } from '@haina/utils-commons';
function DebounceSelect({ fetchOptions, debounceTimeout = 800, ...props }) {
const [fetching, setFetching] = useState(false);

@ -6,19 +6,29 @@
// export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_144'; // prod: Slave
// debug:
// export const API_HOST = 'http://202.103.68.144:8889/v2';
// export const WS_URL = 'ws://202.103.68.144:8889';
// export const EMAIL_HOST = 'http://202.103.68.231:888/service-mail';
export const EMAIL_ATTA_HOST = 'https://p9axztuwd7x8a7.mycht.cn/attatchment'; // 邮件附件
// prod:
// export const API_HOST = 'http://202.103.68.144:8888/v2';
// export const WS_URL = 'ws://202.103.68.144:8888';
// export const WAI_HOST = 'http://47.83.248.120/api/v1'; // 香港服务器
// export const WAI_HOST = 'http://47.254.53.81/api/v1'; // 美国服务器
// export const WAI_HOST = 'http://localhost:3031/api/v1'; // 美国服务器
export const WAI_SERVER_KEY = 'G-STR:WAI_SERVER'
// prod:--------------------------------------------------------------------------------------------------
export const EMAIL_ATTA_HOST = 'https://p9axztuwd7x8a7.mycht.cn/attachment'; // 邮件附件
export const WAI_HOST = 'https://wai-server-qq4qmtq7wc9he4.mycht.cn/api/v1';
export const EMAIL_HOST = 'https://p9axztuwd7x8a7.mycht.cn/mail-server/service-mail';
export const EMAIL_HOST_v3 = 'https://p9axztuwd7x8a7.mycht.cn/mail-server/service-mail';
export const API_HOST = 'https://p9axztuwd7x8a7.mycht.cn/whatsapp_server/v2';
export const API_HOST_V3 = 'https://p9axztuwd7x8a7.mycht.cn/whatsapp_server/v3';
export const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_server'; // prod:
export const VONAGE_URL = 'https://p9axztuwd7x8a7.mycht.cn/vonage-server'; // 语音和视频接口:
export const HT3 = process.env.NODE_ENV === 'production' ? 'https://p9axztuwd7x8a7.mycht.cn/ht3' : 'https://p9axztuwd7x8a7.mycht.cn/ht3';
export const DATE_FORMAT = 'YYYY-MM-DD';
export const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export const DATEEND_FORMAT = 'YYYY-MM-DD 23:59';
export const ERROR_IMG = 'https://hiana-crm.oss-accelerate.aliyuncs.com/WAMedia/afe412d4-3acf-4e79-a623-048aeb4d696a.png';
export const OSS_URL_CN = 'https://haina-sale-system.oss-cn-shenzhen.aliyuncs.com/WAMedia/';
export const OSS_URL_AP = 'https://hiana-crm.oss-ap-southeast-1.aliyuncs.com/WAMedia/';
export const OSS_URL = OSS_URL_AP;
@ -28,6 +38,9 @@ export const DEFAULT_WABA = '+8617607730395';
const __BUILD_VERSION__ = `__BUILD_VERSION__`.replace(/"/g, '')
const __BUILD_DATE__ = `__BUILD_DATE__`;
const __GIT_HEAD__ = `__GIT_HEAD__`
export const BUILD_VERSION = import.meta.env.PROD ? __BUILD_VERSION__ : import.meta.env.MODE;
export const BUILD_DATE = import.meta.env.PROD ? __BUILD_DATE__ : new Date().toLocaleString();
export const GIT_HEAD = import.meta.env.PROD ? __GIT_HEAD__ : 'current';
export const BUILD_VERSION = process.env.NODE_ENV === 'production' ? __BUILD_VERSION__ : process.env.NODE_ENV;
export const BUILD_DATE = process.env.NODE_ENV === 'production' ? __BUILD_DATE__ : new Date().toLocaleString();
export const POPUP_FEATURES = 'left=20,top=20,width=1000,height=800,toolbar=no,menubar=no,scrollbars=yes,resizable=yes,location=no,status=no';

@ -1,7 +1,7 @@
import useAuthStore from '@/stores/AuthStore';
import useConversationStore from '@/stores/ConversationStore';
import { fetchConversationsList, fetchOrderConversationsList, postNewOrEditConversationItem } from '@/actions/ConversationActions';
import { isEmpty } from '@/utils/commons';
import { isEmpty } from '@haina/utils-commons';
const CHAT_ITEM_RECORD = {
"sn": null,
@ -36,6 +36,7 @@ export function useConversationNewItem() {
state.setCurrentConversation,
state.updateConversationItem,
]);
const updateCurrentConversation = useConversationStore((state) => state.updateCurrentConversation);
const conversationsList = useConversationStore((state) => state.conversationsList);
const addToConversationList = useConversationStore((state) => state.addToConversationList);
const userId = useAuthStore((state) => state.loginUser.userId);
@ -93,7 +94,7 @@ export function useConversationNewItem() {
phone_number: createdNew.guest_phone,
whatsapp_phone_number: createdNew.whatsapp_phone_number,
},
conversation_memo: createdNew.remark,
conversation_memo: createdNew.guest_name || createdNew.remark,
// lasttime: createdNew.session_creatime,
show_default: createdNew.remark || createdNew.guest_name || createdNew.guest_phone || createdNew.guest_email || createdNew.whatsapp_phone_number || '',
}
@ -117,7 +118,11 @@ export function useConversationNewItem() {
// })
return ;
}
updateConversationItem(buildChatItem);
if (currentConversation.sn === createdNew.conversationid) {
updateCurrentConversation(buildChatItem);
} else {
updateConversationItem(buildChatItem);
}
};

@ -1,16 +1,19 @@
import { useState, useEffect, useCallback } from 'react'
import { isEmpty } from '@/utils/commons'
import { getEmailDetailAction, postResendEmailAction, getSalesSignatureAction } from '@/actions/EmailActions'
import { isEmpty, objectMapper, olog, } from '@haina/utils-commons'
import { readIndexDB } from '@/utils/indexedDB'
import { getEmailDetailAction, postResendEmailAction, getSalesSignatureAction, getEmailOrderAction, queryEmailListAction, searchEmailListAction, getReminderEmailTemplateAction, saveEmailDraftOrSendAction, updateEmailAction, getEmailChangesChannel, EMAIL_CHANNEL_NAME } from '@/actions/EmailActions'
import { App } from 'antd'
import useConversationStore from '@/stores/ConversationStore';
import { msgStatusRenderMapped } from '@/channel/bubbleMsgUtils';
import { POPUP_FEATURES } from '@/config';
import { internalEventEmitter } from '@/utils/EventEmitterService';
export const useEmailSignature = (opi_sn) => {
const [signature, setSignature] = useState('')
const getSignature = useCallback(async () => {
if (isEmpty(opi_sn)) {
if (isEmpty(Number(opi_sn))) {
return false
}
try {
@ -23,61 +26,418 @@ export const useEmailSignature = (opi_sn) => {
useEffect(() => {
getSignature()
}, [opi_sn])
}, [getSignature])
return { signature, getSignature }
return { signature }
}
/**
* 邮件详情
*
* @param mai_sn 邮件编号ID
* @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 [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 [maiSN, setMaiSN] = useState(mai_sn);
const [coliSN, setColiSN] = useState(oid);
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 getEmailDetail = async () => {
if (isEmpty(mai_sn)) {
return false
}
try {
setLoading(true)
const data = await getEmailDetailAction({ mai_sn })
setMailData(data)
setLoading(false)
} catch (err) {
setLoading(false)
notification.error({
message: "请求失败",
description: err.message || '网络异常',
placement: "top",
duration: 3,
});
// const [updateMessageItem] = useConversationStore(state => [state.updateMessageItem]);
const getEmailDetail = useCallback(async () => {
if (isEmpty(Number(maiSN)) && isEmpty(Number(mai_sn))) {
return false
}
if (!isEmpty(data)) {
setMailData(data)
return false
}
try {
setLoading(true)
const data = await getEmailDetailAction({ mai_sn: Number(mai_sn) || maiSN })
// console.log(data)
setMailData(data)
setColiSN(oid === false ? 0 : data.info.MAI_COLI_SN)
setLoading(false)
// `已读`
if (markRead !== false && data.info?.MOI_ReadState !== 1) {
updateEmailAction({
opi_sn: data.info.MAI_OPI_SN,
mai_sn_list: [Number(mai_sn) || maiSN],
set: { read: 1 },
})
}
} 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(() => {
getEmailDetail();
}, [getEmailDetail])
useEffect(() => {
getOrderDetail()
}, [getOrderDetail])
const postEmailResend = async ({ mai_sn, conversationid: externalid, actionId: actionid, ...body }) => {
if (isEmpty(mai_sn)) {
if (isEmpty(Number(mai_sn))) {
return false
}
await postResendEmailAction({ mai_sn, externalid, actionid, token: 0 })
// 重发没有状态返回值, 此处前端处理为'待发送', todo: 刷新页面后消息仍为上一个状态'失败'
updateMessageItem({ conversationid: externalid, actionid, id: actionid, status: msgStatusRenderMapped['accepted'] })
}
return { loading, mailData, 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}) => {
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: '一催模版1鼓励客人回复和讨论行程' },
{ type: 'RemindOneWL', index: 2, key: '2@RemindOneWL', value: '2@RemindOneWL', label: '一催模版2询问客人对于报价是否有疑问' },
{ type: 'divider' },
{ type: 'RemindTwoWL', index: 1, key: '1@RemindTwoWL', value: '1@RemindTwoWL', label: '二催模版1省钱的方式' },
{ type: 'RemindTwoWL', index: 2, key: '2@RemindTwoWL', value: '2@RemindTwoWL', label: '二催模版2Why us' },
{ type: 'divider' },
{ type: 'RemindThreeWL', index: 1, key: '1@RemindThreeWL', value: '1@RemindThreeWL', label: '三催模版1再次强调服务' },
{ type: 'RemindThreeWL', index: 2, key: '2@RemindThreeWL', value: '2@RemindThreeWL', label: '三催模版2客人常见问题询问' },
];
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: '已处理邮件' },
]

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

@ -0,0 +1,124 @@
import { useCallback } from 'react';
import useConversationStore from '@/stores/ConversationStore';
const useShortUrlChange = () => {
const setGlobalNotify = useConversationStore((state) => state.setGlobalNotify);
const apiPrefix = {
"japanhighlights.com": "https://www.japanhighlights.com/index.php",
"chinahighlights.com": "https://www.chinahighlights.com/guide-use.php",
"highlightstravel.com": "https://www.highlightstravel.com/index.php",
"asiahighlights.com": "https://www.asiahighlights.com/index.php",
"globalhighlights.com": "https://www.globalhighlights.com/index.php",
};
const fetchNowConversationsitems = async (base64Url, apiUrl) => {
try {
const formData = new FormData();
formData.append(' url', base64Url);
formData.append('type', 'info');
const response = await fetch(`${apiUrl}/apps/short_link/index/create`, {
method: 'POST',
body: formData,
});
const data = await response.json();
if (data[0].name == 'ok') {
return data[0].value;
}
return null;
} catch (error) {
console.error('获取短链接转换内容失败:', error);
return null;
}
};
const urlBase64 = (longUrl) => {
try {
const extracted2 = longUrl.match(/https:\/\/www\.([^\/]+)/)?.[1] || '';
const encoder = new TextEncoder();
const utf8Bytes = encoder.encode(longUrl);
const base64Url = btoa(String.fromCharCode(...utf8Bytes));
return { base64Url, extracted2 };
} catch (error) {
setGlobalNotify([{
key: Date.now().toString(),
title: '错误',
content: '转换失败请检查输入的URL是否正确',
type: 'error'
}]);
console.error('URL转换错误:', error);
return { base64Url: '', extracted2: '' };
}
};
const normalizeUrl = (longUrl) => {
try {
const hasProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(longUrl);
const url = new URL(hasProtocol ? longUrl : `http://${longUrl}`);
const params = Object.fromEntries(url.searchParams.entries());
return {
url: url.href,
valid: true,
origin: url.origin,
hostname: url.hostname,
pathname: url.pathname,
params
};
} catch (e) {
return { valid: false, error: "Invalid URL" };
}
};
const convertUrl = useCallback(async (longUrl) => {
const normalizedUrl = normalizeUrl(longUrl);
if (!normalizedUrl.valid || Object.keys(normalizedUrl?.params || {}).length === 0) {
setGlobalNotify([{
key: Date.now().toString(),
title: '错误',
content: '没有需要转换的长链接, 请重新输入',
type: 'warning'
}]);
return null;
}
const { base64Url, extracted2 } = urlBase64(normalizedUrl.url);
if (base64Url) {
const apiUrl = apiPrefix[extracted2] || apiPrefix["chinahighlights.com"];
const extracted1 = apiUrl.match(/^https?:\/\/[^\/]*\.com/)?.[0] || '';
const data = await fetchNowConversationsitems(base64Url, apiUrl);
if (data) {
const resultShortUrl = extracted1 + data.isl_link;
setGlobalNotify([{
key: Date.now().toString(),
title: '成功',
content: '转换成功!',
type: 'success'
}]);
return resultShortUrl;
} else {
setGlobalNotify([{
key: Date.now().toString(),
title: '错误',
content: '转换失败请检查输入的URL是否正确',
type: 'error'
}]);
return null;
}
} else {
setGlobalNotify([{
key: Date.now().toString(),
title: '错误',
content: 'URL格式不正确请输入完整的URL',
type: 'error'
}]);
return null;
}
}, [setGlobalNotify]);
return { convertUrl };
};
export default useShortUrlChange;

@ -17,7 +17,7 @@ import MobileConversation from '@/views/mobile/Conversation'
import MobileChat from '@/views/mobile/Chat'
import CallCenter from '@/views/CallCenter'
import MobileSecondHeader from '@/views/mobile/SecondHeaderWrapper'
import CustomerProfile from '@/views/Conversations/Online/order/CustomerProfile'
import OrderProfile from '@/components/OrderProfile'
import SnippetList from '@/views/accounts/SnippetList'
import GeneratePayment from '@/views/accounts/GeneratePayment'
@ -27,9 +27,19 @@ import ChatAssign from '@/views/Conversations/ChatAssign'
import DingdingLogin from '@/views/dingding/Login'
import DingdingQRCode from '@/views/dingding/QRCode'
import DingdingAuthCode from '@/views/dingding/AuthCode'
import LocalWhatsAppQRCode from '@/views/accounts/LocalWhatsAppQRCode'
import useAuthStore from '@/stores/AuthStore'
import '@/assets/index.css'
import CustomerRelation from '@/views/customer_relation/index'
import NewEmail from '@/views/NewEmail'
import EmailDetailWindow from '@/views/EmailDetailWindow'
import { executeDailyCleanupTask, setupDailyMidnightCleanupScheduler } from '@/utils/indexedDB'
import { appendRequestHeader } from '@haina/utils-request';
import { BUILD_VERSION } from '@/config'
appendRequestHeader('X-Web-Version', BUILD_VERSION);
useAuthStore.getState().loadUserSession()
const isMobileApp =
@ -58,7 +68,7 @@ const router = createBrowserRouter([
{
element: <MobileSecondHeader />,
children: [
{ path: 'm/order', element: <CustomerProfile /> },
{ path: 'm/order', element: <OrderProfile /> },
{ path: 'callcenter/call', element: <CallCenter /> },
{ path: 'callcenter/call/:phonenumber', element: <CallCenter /> },
],
@ -81,8 +91,14 @@ const router = createBrowserRouter([
{ path: 'chat/unassign', element: <Unassign /> },
{ path: 'callcenter/call', element: <CallCenter /> },
{ path: 'callcenter/call/:phonenumber', element: <CallCenter /> },
{ path: 'customer_relation/index', element: <CustomerRelation /> },
],
},
{ path: 'email/view/:mailid', element: <EmailDetailWindow />},
{ path: 'email/:action/:quoteid/:oid/:templateKey', element: <NewEmail />},
{ path: 'email/:action/:quoteid/:oid', element: <NewEmail />},
{ path: 'email/:action/:quoteid', element: <NewEmail />},
// { path: 'email/new/0/:oid', element: <NewEmail />},
],
},
{
@ -95,6 +111,7 @@ const router = createBrowserRouter([
{ path: 'dingding/callback', element: <DingdingCallback /> },
{ path: 'dingding/qr-code', element: <DingdingQRCode /> },
{ path: 'dingding/auth-code', element: <DingdingAuthCode /> },
{ path: 'whatsapp/qr-code', element: <LocalWhatsAppQRCode /> },
],
},
])
@ -132,3 +149,13 @@ createRoot(root).render(
</ThemeContext.Provider>
</React.Suspense>,
)
// --- Global Setup After React App Mounts ---
// This part will run once when the application script is loaded and executed.
document.addEventListener('DOMContentLoaded', () => {
console.log(`[${new Date().toLocaleTimeString()}] Application fully loaded. Initiating global daily cleanup checks.`);
executeDailyCleanupTask();
setupDailyMidnightCleanupScheduler();
});

@ -1,4 +1,4 @@
import { clearAllCaches } from '@/utils/commons';
import { clearAllCaches } from '@haina/utils-commons';
import { BUILD_VERSION, BUILD_DATE } from '@/config'
const MaintenancePage = () => {

@ -1,14 +1,18 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { fetchJSON } from '@/utils/request'
import { isEmpty, isNotEmpty } from '@/utils/commons'
import { fetchJSON } from '@haina/utils-request'
import { isEmpty, isNotEmpty } from '@haina/utils-commons'
import { API_HOST, BUILD_VERSION } from '@/config'
import { usingStorage } from '@/utils/usingStorage';
export const PERM_MERGE_CONVERSATION = 'merge-conversation'
export const PERM_ASSIGN_NEW_CONVERSATION = 'assign-new-conversation'
export const PERM_USE_EMAL = 'use-email'
export const PERM_USE_WHATSAPP = 'use-whatsapp'
export const PERM_IMPORT_EMAIL = 'import-email'
const WAI_SERVER_KEY = 'G-STR:WAI_SERVER'
const useAuthStore = create(devtools((set, get) => ({
loginUser: {
userId: -1,
@ -27,10 +31,15 @@ const useAuthStore = create(devtools((set, get) => ({
loginStatus: 0,
isPermitted: (perm) => {
const { waiServer } = usingStorage(WAI_SERVER_KEY)
const { loginUser } = get()
if (perm === PERM_USE_WHATSAPP) {
return isNotEmpty(waiServer) // ['370', '143', '495', '404', '383', '227'].includes(loginUser.userId)
}
if (perm === PERM_USE_EMAL) {
return ['501', '466', '599', '495', '143', '370', '639', '513', '654', '404', '383', '227'].includes(loginUser.userId)
return true//['501', '466', '599', '495', '143', '370', '639', '513', '654', '404', '383', '227'].includes(loginUser.userId)
}
// 导入邮件消息,需要配置才能使用
@ -50,6 +59,7 @@ const useAuthStore = create(devtools((set, get) => ({
},
login: async (authCode) => {
const { setStorage } = usingStorage()
const { saveUserSession, setLoginStatus } = get()
setLoginStatus(200)
@ -60,6 +70,8 @@ const useAuthStore = create(devtools((set, get) => ({
)
if (json.errcode === 0 && isNotEmpty(json.result.opisn)) {
const waiServer = json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].wai_server : ''
setStorage(WAI_SERVER_KEY, waiServer)
set(() => ({
loginUser: {
userId: json.result.opisn,
@ -78,6 +90,7 @@ const useAuthStore = create(devtools((set, get) => ({
}
}),
whatsAppBusiness: json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].whatsapp_waba : '',
whatsAppNo: json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].whatsapp_wa : '',
accountName: json.result.opicode,
username: json.result.nick,
avatarUrl: json.result.avatarUrl,
@ -94,6 +107,67 @@ const useAuthStore = create(devtools((set, get) => ({
}
},
parseLoginJson: (json) => {
const { setStorage } = usingStorage()
if (json.errcode === 0 && isNotEmpty(json.result.opisn)) {
const waiServer = json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].wai_server : ''
setStorage(WAI_SERVER_KEY, waiServer)
const parsedUser = {
userId: json.result.opisn,
userIdStr: json.result?.accountlist
.map((acc) => {
return acc.OPI_SN
})
.join(','),
emailList: json.result?.emaillist.map(item => {
return {
opi_sn: item.opi_sn,
mat_sn: item.mat_sn,
email: item.email,
default: item.Isdefaultemail == 1,
backup: item.Isbakemail == 1,
}
}),
whatsAppBusiness: json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].whatsapp_waba : '',
whatsAppNo: json.result.whatsappinfo.length > 0 ? json.result.whatsappinfo[0].whatsapp_wa : '',
accountName: json.result.opicode,
username: json.result.nick,
avatarUrl: json.result.avatarUrl,
mobile: '+' + json.result.stateCode + '-' + json.result.mobile,
email: json.result.email,
openId: json.result.openId,
accountList: json.result.accountlist,
}
return parsedUser
} else { return null }
},
// 钉钉免登后获取用户信息
loginByJSAuth: async (authCode) => {
const { saveUserSession, setLoginStatus, parseLoginJson } = get()
setLoginStatus(200)
const json = await fetchJSON(
'https://p9axztuwd7x8a7.mycht.cn/dingtalk/dingtalkwork/Getusers_auth_wa',
{ code: authCode },
)
const parsedUser = parseLoginJson(json)
if (parsedUser) {
set(() => ({
loginUser: parsedUser,
}))
saveUserSession()
setLoginStatus(302)
} else {
setLoginStatus(403)
}
},
getPrimaryEmail: () => {
const { loginUser } = get()
@ -145,11 +219,6 @@ const useAuthStore = create(devtools((set, get) => ({
loadUserSession: () => {
let sessionData = window.sessionStorage.getItem('GLOBAL_SALES_LOGIN_USER')
// if (window.location.hostname === '202.103.68.93' && window.location.port === '4173' && isEmpty(sessionData)) {
// sessionData = `{"userId":"383","userIdStr":"383,609","emailList":[{"opi_sn":383,"mat_sn":760,"email":"lyj@asiahighlights.com","default":false,"backup":false},{"opi_sn":383,"mat_sn":759,"email":"lyj@chinahighlights.com","default":true,"backup":false},{"opi_sn":383,"mat_sn":758,"email":"lyj@hainatravel.com","default":false,"backup":false}],"username":"廖一军","avatarUrl":"https://static-legacy.dingtalk.com/media/lALPBDDrhXr716HNAoDNAoA_640_640.png","mobile":"+86-18777396951","email":"lyj@hainatravel.com","whatsAppBusiness":"8617458471254","openId":"iioljiPmZ4RPoOYpkFiSn7IKAiEiE","accountList":[{"OPI_SN":383,"OPI_Code":"LYJ","OPI_NameCN":"廖一军","OPI_DEI_SN":7,"OPI_NameEN":"Jimmy Liow"},{"OPI_SN":609,"OPI_Code":"LYJAH","OPI_NameCN":"廖一军ah","OPI_DEI_SN":28,"OPI_NameEN":"Jimmy Liow"}]}`
// window.localStorage.setItem('GLOBAL_SALES_LOGIN_USER', sessionData)
// }
if (import.meta.env.DEV && isEmpty(sessionData)) {
sessionData = window.localStorage.getItem('GLOBAL_SALES_LOGIN_USER')
}
@ -170,11 +239,11 @@ const useAuthStore = create(devtools((set, get) => ({
)
},
setWhatsAppBusiness: async (userId, whatsAppBusiness) => {
setWhatsAppProfile: async (userId, whatsAppBusiness, whatsAppNo) => {
const { loginUser, saveUserSession } = get()
const postWABAUrl = `${API_HOST}/v2/set_whatsapp_info`
const params = {opi_sn: userId, whatsapp_waba: whatsAppBusiness};
const params = {opi_sn: userId, whatsapp_waba: whatsAppBusiness, whatsapp_wa: whatsAppNo.replace(/\D/g, '')};
return fetchJSON(postWABAUrl, params)
.then(json => {
@ -182,6 +251,7 @@ const useAuthStore = create(devtools((set, get) => ({
set(() => ({
loginUser: {
...loginUser,
whatsAppNo: whatsAppNo,
whatsAppBusiness: whatsAppBusiness,
}
}))
@ -191,26 +261,6 @@ const useAuthStore = create(devtools((set, get) => ({
}
})
},
sendNotify: async () => {
const { loginUser } = get()
const notifyUrl = 'https://p9axztuwd7x8a7.mycht.cn/dingtalk/dingtalkwork/SendMDMsgByDingRobotToGroup'
const params = {
groupid: 'cidFtzcIzNwNoiaGU9Q795CIg==',
msgTitle: '有人求助',
msgText: loginUser.username + '上传了销售平台' + BUILD_VERSION + '的日志'
};
return fetchJSON(notifyUrl, params)
.then(json => {
if (json.errcode === 0) {
console.info('发送通知成功')
} else {
throw new Error(json?.errmsg + ': ' + json.errcode)
}
})
},
}), { name: 'authStore' }))
export default useAuthStore

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

@ -1,17 +1,29 @@
import { create } from 'zustand';
import { RealTimeAPI } from '@/channel/realTimeAPI';
import { olog, isEmpty, groupBy, sortArrayByOrder, logWebsocket, pick } from '@/utils/commons';
import { olog, isEmpty, groupBy, sortArrayByOrder, pick, sortKeys, omit, sortObjectsByKeysMap, merge } from '@haina/utils-commons';
import { logWebsocket, clean7DaysWebsocketLog } from '@/utils/indexedDB'
import { receivedMsgTypeMapped, handleNotification } from '@/channel/bubbleMsgUtils';
import { fetchConversationsList, fetchTemplates, fetchConversationsSearch, UNREAD_MARK, fetchTags } from '@/actions/ConversationActions';
import { devtools } from 'zustand/middleware';
import { WS_URL, DATETIME_FORMAT } from '@/config';
import dayjs from 'dayjs';
import EmailSlice from './EmailSlice';
const replaceObjectsByKey = (arr1, arr2, key) => {
const map = new Map(arr2.map(ele => [ele[key], ele]));
return arr1.map(item => map.has(item[key]) ? map.get(item[key]) : item);
const map2 = new Map(arr2.map(ele => [ele[key], ele]));
return arr1.map(item => map2.has(item[key]) ? map2.get(item[key]) : item);
}
const sortConversationList = (list) => {
const mergedListMapped = groupBy(list, 'top_state');
const topValOrder = Object.keys(mergedListMapped).filter(ss => ss !== '1').sort((a, b) => b - a);
const pagelist = topValOrder.reduce((r, topVal) => r.concat(mergedListMapped[String(topVal)]), []);
return {
topList: mergedListMapped['1'] || [],
pageList: pagelist,
}
};
// const WS_URL = 'ws://202.103.68.144:8888/whatever/';
// const WS_URL = 'ws://120.79.9.217:10022/whatever/';
const conversationRow = {
@ -26,6 +38,7 @@ const conversationRow = {
customer_name: '',
whatsapp_phone_number: '',
top_state: 0,
session_type: 0,
};
const initialConversationState = {
@ -54,6 +67,9 @@ const initialConversationState = {
msgListLoading: false,
detailPopupOpen: false,
wai: {},
};
const globalNotifySlice = (set) => ({
@ -64,6 +80,11 @@ const globalNotifySlice = (set) => ({
clearGlobalNotify: () => set(() => ({ globalNotify: [] })),
})
const waiSlice = (set) => ({
wai: {},
setWai: (wai) => set({ wai }),
});
// 顾问的自定义标签
const tagsSlice = (set) => ({
tags: [],
@ -158,26 +179,34 @@ const websocketSlice = (set, get) => ({
logWebsocket(data, 'I');
// olog('websocket Messages ----', data);
// console.log(data);
const { updateMessageItem, sentOrReceivedNewMessage, addGlobalNotify } = get();
const { updateMessageItem, sentOrReceivedNewMessage, addGlobalNotify, setWai, addToConversationList, updateMailboxCount } = get()
const { errcode, errmsg, result } = data;
if (!result) {
return false;
}
let resultType = result?.action || result.type;
let resultType = result?.action || result?.type;
if (errcode !== 0) {
// addError('Error Connecting to Server');
resultType = 'error';
}
// console.log(resultType, 'result.type');
const msgObj = receivedMsgTypeMapped[resultType].getMsg(result);
const msgRender = receivedMsgTypeMapped[resultType].contentToRender(msgObj);
const msgUpdate = receivedMsgTypeMapped[resultType].contentToUpdate(msgObj);
// console.log('msgRender msgUpdate', msgRender, msgUpdate);
if (['email.updated', 'email.inbound.received',].includes(resultType)) {
updateMailboxCount({ opi_sn: msgObj.opi_sn })
// if (!isEmpty(msgRender)) {
// const msgNotify = receivedMsgTypeMapped[resultType].contentToNotify(msgObj);
// addGlobalNotify(msgNotify);
// }
return false;
}
if ([
'whatsapp.message.updated', 'message', 'error',
'email.updated',
].includes(resultType)) {
'email.updated', 'wai.message.updated',
].includes(resultType) && !isEmpty(msgUpdate)) {
updateMessageItem(msgUpdate);
}
if (!isEmpty(msgRender)) {
@ -194,6 +223,27 @@ const websocketSlice = (set, get) => ({
const msgNotify = receivedMsgTypeMapped[resultType].contentToNotify(msgObj);
addGlobalNotify(msgNotify);
}
// WhatsApp creds update
if ([
'wai.creds.update'
].includes(resultType)) {
const _data = receivedMsgTypeMapped[resultType].getMsg(result);
setWai(_data)
if (['offline', 'close'].includes(_data.status)) {
const msgNotify = receivedMsgTypeMapped[resultType].contentToNotify(msgObj);
addGlobalNotify(msgNotify);
}
// setTimeout(() => {
// setWai({}); // 60s 后清空
// }, 60_000);
}
// 会话表 更新
if (['session.new', 'session.updated'].includes(resultType)
&& result.webhooksource !== 'email'
) {
const sessionList = receivedMsgTypeMapped[resultType].getMsg(result);
addToConversationList(sessionList || [], 'top')
}
// console.log('handleMessage*******************');
},
});
@ -224,15 +274,29 @@ const conversationSlice = (set, get) => ({
* 搜索结果
*/
setConversationsList: (conversationsList) => {
const { activeConversations, } = get();
const { activeConversations, currentConversation } = get();
// 让当前会话显示在页面上
let _tmpCurrentMsgs = [];
if (currentConversation.sn) {
// _tmpCurrentMsgs = activeConversations[currentConversation.sn];
}
const conversationsMapped = conversationsList.reduce((r, v) => ({ ...r, [`${v.sn}`]: [] }), {});
const indexCurrent = currentConversation.sn ? conversationsList.findIndex(ele => Number(ele.sn) === Number(currentConversation.sn)) : -1;
if (indexCurrent !== -1) {
const [currentElement] = conversationsList.splice(indexCurrent, 1);
conversationsList.unshift(currentElement); // Add to top
// const hasCurrent = Object.keys(conversationsMapped).findIndex(sn => Number(sn) === Number(currentConversation.sn)) !== -1;
// conversationsMapped[currentConversation.sn] = _tmpCurrentMsgs;
// conversationsList.unshift(hasCurrent ? )
// hasCurrent ? 0 : conversationsList.unshift(currentConversation);
}
const conversationsTopStateMapped = groupBy(conversationsList, 'top_state');
const { topList, pageList } = sortConversationList(conversationsList);
return set({
topList: conversationsTopStateMapped[1] || [],
topList,
// conversationsList: conversationsTopStateMapped[0],
pageList: conversationsTopStateMapped[0] || [],
pageList,
conversationsList,
activeConversations: { ...conversationsMapped, ...activeConversations }
})
@ -243,7 +307,7 @@ const conversationSlice = (set, get) => ({
return set({ closedConversationsList, activeConversations: { ...activeConversations, ...listMapped } });
},
addToConversationList: (newList, position='top') => {
const { activeConversations, conversationsList, } = get();
const { activeConversations, conversationsList, currentConversation } = get();
// const conversationsIds = Object.keys(activeConversations);
const conversationsIds = conversationsList.map((chatItem) => `${chatItem.sn}`);
const newConversations = newList.filter((conversation) => !conversationsIds.includes(`${conversation.sn}`));
@ -255,15 +319,27 @@ const conversationSlice = (set, get) => ({
const updateList = replaceObjectsByKey(conversationsList, newList, 'sn');
const mergedList = position==='top' ? [...newList, ...withoutNew] : [...updateList, ...newConversations];
const mergedListMapped = groupBy(mergedList, 'top_state');
const mergedListMsgs = { ...newConversationsMapped, ...activeConversations, };
const needUpdateCurrent = -1 !== newList.findIndex(row => Number(row.sn) === Number(currentConversation.sn));
const updateCurrent = needUpdateCurrent ? { currentConversation: newList.find(row => Number(row.sn) === Number(currentConversation.sn)) } : {};
// 让当前会话显示在页面上
const indexCurrent = currentConversation.sn ? mergedList.findIndex(ele => Number(ele.sn) === Number(currentConversation.sn)) : -1;
if (indexCurrent !== -1 ) {
const [currentElement] = mergedList.splice(indexCurrent, 1);
mergedList.unshift(currentElement); // Add to top
// hasCurrent ? 0 : mergedList.unshift(currentConversation);
}
const refreshTotalNotify = mergedList.reduce((r, c) => r+(c.unread_msg_count === UNREAD_MARK ? 0 : c.unread_msg_count), 0);
const { topList, pageList } = sortConversationList(mergedList)
return set((state) => ({
topList: mergedListMapped[1] || [],
pageList: mergedListMapped[0] || [],
...updateCurrent,
topList,
pageList,
conversationsList: mergedList,
activeConversations: { ...newConversationsMapped, ...activeConversations, },
activeConversations: mergedListMsgs,
totalNotify: refreshTotalNotify,
// totalNotify: state.totalNotify + newConversations.map((ele) => ele.unread_msg_count).reduce((acc, cur) => acc + (cur || 0), 0),
}));
@ -273,11 +349,11 @@ const conversationSlice = (set, get) => ({
const targetId = conversation.sn;
const targetIndex = conversationsList.findIndex((ele) => String(ele.sn) === String(targetId));
conversationsList.splice(targetIndex, 1);
const mergedListMapped = groupBy(conversationsList, 'top_state');
const { topList, pageList } = sortConversationList(conversationsList)
return set({
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
topList,
pageList,
conversationsList: [...conversationsList],
activeConversations: { ...activeConversations, [`${targetId}`]: [] },
currentConversation: {},
@ -322,11 +398,11 @@ const conversationSlice = (set, get) => ({
...conversation,
})
: null;
const mergedListMapped = groupBy(conversationsList, 'top_state');
const { topList, pageList } = sortConversationList(conversationsList)
return set({
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
topList,
pageList,
conversationsList: [...conversationsList]
});
},
@ -361,7 +437,8 @@ const messageSlice = (set, get) => ({
} else if (String(ele.id) === String(message.id)) {
// console.log('id', message.id, ele.id)
// WABA: 异步的后续状态更新, id已更新为wamid
// console.log('old msg ele', ele);
// console.log('coming msg', message.type, message);
// console.log('old msg ele', ele.type, ele);
const renderStatus = message?.data?.status ? { status: { ...ele.data.status, loading: 0, download: true } } : {};
const keepReply = ele.reply ? { reply: ele.reply } : {};
const keepTemplate = ele.template ? { template: ele.template, template_origin: ele.template_origin, text: ele.text } : {};
@ -375,130 +452,25 @@ const messageSlice = (set, get) => ({
targetMsgs.push(message);
}
const targetIndex = conversationsList.findIndex((ele) => String(ele.sn) === String(targetId));
let newConversations = [];
if (targetIndex !== -1) { // 'delivered'
// 更新列表的时间
conversationsList.splice(targetIndex, 1, {
...conversationsList[targetIndex],
last_received_time: message.status === 'received' ? dayjs(message.deliverTime).add(8, 'hours').format(DATETIME_FORMAT) : conversationsList[targetIndex].last_received_time,
conversation_expiretime: message?.conversation?.expireTime || conversationsList[targetIndex].conversation_expiretime || '', // 保留使用UTC时间
});
} else if (targetIndex === -1) {
// 当前客户端不存在的会话
// todo: 设置为当前(在WhatsApp返回号码不一致时)
newConversations = [{
...conversationRow,
...message,
sn: targetId,
opi_sn: currentConversation.opi_sn, // todo: coli sn
last_received_time: message.date,
unread_msg_count: 0,
whatsapp_name: message.to, //message?.senderName || message?.sender || '',
customer_name: message.to, // message?.senderName || message?.sender || '',
conversation_expiretime: message?.conversation?.expireTime || '', // 保留使用UTC时间
whatsapp_phone_number: message.type === 'email' ? null : message.to,
show_default: message.to || '',
last_message: message,
channels: {
"email": message.type === 'email' ? message.from : null,
"phone_number": message.type === 'email' ? null : message.from,
"whatsapp_phone_number": message.type === 'email' ? null : message.from,
},
}];
}
const mergedList = [...newConversations, ...conversationsList]
const mergedListMapped = groupBy(mergedList, 'top_state');
setFilter({ loadNextPage: true });
return set({
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
conversationsList: mergedList,
activeConversations: { ...activeConversations, [String(targetId)]: targetMsgs },
});
},
sentOrReceivedNewMessage: (targetId, message) => {
// msgRender:
// console.log('sentOrReceivedNewMessage', targetId, message)
const { activeConversations, conversationsList, currentConversation, totalNotify, setFilter } = get();
const { activeConversations, setFilter } = get();
const targetMsgs = activeConversations[String(targetId)] || [];
const targetIndex = conversationsList.findIndex((ele) => Number(ele.sn) === Number(targetId));
const lastReceivedTime = (message.type !== 'system' && message.sender !== 'me') ? dayjs(message.date).add(8, 'hours').format(DATETIME_FORMAT) : null;
const newConversation =
targetIndex !== -1
? {
...conversationsList[targetIndex],
last_received_time: lastReceivedTime || conversationsList[targetIndex].last_received_time,
unread_msg_count:
Number(targetId) !== Number(currentConversation.sn) && message.sender !== 'me'
? conversationsList[targetIndex].unread_msg_count + 1
: conversationsList[targetIndex].unread_msg_count,
last_message: message,
}
: {
...conversationRow,
...message,
sn: 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 : message.from,
show_default: message?.senderName || message?.sender || message.from || '',
last_message: message,
channels: {
"email": message.type === 'email' ? message.from : null,
"phone_number": message.type === 'email' ? null : message.from,
"whatsapp_phone_number": message.type === 'email' ? null : message.from,
},
};
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));
const mergedListMapped = groupBy(conversationsList, 'top_state');
setFilter({ loadNextPage: true });
const isCurrent = Number(targetId) === Number(currentConversation.sn);
const updatedCurrent = isCurrent
? {
...currentConversation,
last_received_time: dayjs(message.date).add(8, 'hours').format(DATETIME_FORMAT),
conversation_expiretime: dayjs(message.date).add(24, 'hours').format(DATETIME_FORMAT),
last_message: message,
}
: {...currentConversation, last_message: message,};
return set({
currentConversation: updatedCurrent,
topList: mergedListMapped['1'] || [],
pageList: mergedListMapped['0'] || [],
conversationsList: [...conversationsList],
totalNotify: totalNotify + (message.sender === 'me' ? 0 : 1),
activeConversations: { ...activeConversations, [String(targetId)]: [...targetMsgs, message] },
});
},
});
/**
* Email
*/
const emailSlice = (set, get) => ({
emailMsg: { 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(
devtools((set, get) => ({
...initialConversationState,
@ -511,7 +483,8 @@ export const useConversationStore = create(
...tagsSlice(set, get),
...filterSlice(set, get),
...globalNotifySlice(set, get),
...emailSlice(set, get),
...EmailSlice(set, get),
...waiSlice(set, get),
// state actions
addError: (error) => set((state) => ({ errors: [...state.errors, error] })),
@ -519,7 +492,10 @@ export const useConversationStore = create(
// side effects
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 });
addToConversationList(conversationsList);

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

@ -0,0 +1,189 @@
import { getEmailDirAction, getMailboxCountAction, getRootMailboxDirAction, getEmailChangesChannel, EMAIL_CHANNEL_NAME } from '@/actions/EmailActions'
import { buildTree, isEmpty, olog, sortArrayByOrder } from '@haina/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 }),
msgListParams: {},
setMsgListParams: (msgListParams) => set(state => ({ msgListParams: {...state.msgListParams, ...msgListParams} })),
ImageAlbum: [],
setImageAlbum: (ImageAlbum) => set({ ImageAlbum }),
ImagePreviewSrc: '',
setImagePreviewSrc: (ImagePreviewSrc) => set({ ImagePreviewSrc }),
EmailList: [],
setEmailList: (EmailList) => set({ EmailList }),
// 订单跟踪页面
orderFollowForm: {
type: 'today',
@ -28,6 +32,6 @@ export const useFormStore = create(
setOrderFollowForm: (orderFollowForm) => set({ orderFollowForm }),
orderFollowAdvanceChecked: false,
setOrderFollowAdvanceChecked: (orderFollowAdvanceChecked) => set({ orderFollowAdvanceChecked }),
}))
}), { name: 'form-store' })
);
export default useFormStore;

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

@ -1,8 +1,8 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { fetchJSON, postForm } from '@/utils/request'
import { fetchJSON, postForm } from '@haina/utils-request'
import { API_HOST } from '@/config'
import { copy } from '@/utils/commons'
import { copy } from '@haina/utils-commons'
const useSnippetStore = create(devtools((set, get) => ({
@ -41,7 +41,7 @@ const useSnippetStore = create(devtools((set, get) => ({
const mapTypeList = json?.result?.type.map(item => {
return { value: item.vsn, label: item.vname }
})
const mapTypeAllList = copy(mapTypeList);
const mapTypeAllList = structuredClone(mapTypeList);
mapTypeAllList.unshift({ value: '', label: '全部' });
set(() => ({
ownerList: json?.result?.owner.map(item => {

@ -0,0 +1,21 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useUrlStore = create(devtools((set, get) => ({
drawerOpen: false,
openDrawer: () => {
set(() => ({
drawerOpen: true
}))
},
closeDrawer: () => {
set(() => ({
drawerOpen: false
}))
},
}), { name: 'urlStore' }))
export default useUrlStore

@ -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();

@ -1,3 +1,4 @@
console.warn('Warning: `commons.js` is deprecated and will be removed in next version.');
export function copy(obj) {
return JSON.parse(JSON.stringify(obj))
}
@ -119,6 +120,26 @@ export const sortKeys = (obj) =>
.sort()
.reduce((a, k2) => ({ ...a, [k2]: obj[k2] }), {});
export function sortObjectsByKeysMap(objects, keyOrder) {
if (!objects) return {} // Handle null/undefined input
if (!keyOrder || keyOrder.length === 0) return objects
const objectMap = new Map(Object.entries(objects))
const sortedMap = new Map()
for (const key of keyOrder) {
if (objectMap.has(key)) {
sortedMap.set(key, objectMap.get(key))
objectMap.delete(key) // Optimization: Remove from original map after adding
}
}
// Add remaining keys
for (const [key, value] of objectMap) {
sortedMap.set(key, value)
}
return Object.fromEntries(sortedMap)
}
/**
* 数组排序, 给定排序数组
* @param {array} items 需要排序的数组
@ -168,6 +189,8 @@ export function merge(...objects) {
* 数组分组
* - 相当于 lodash _.groupBy
* @see https://www.lodashjs.com/docs/lodash.groupBy#_groupbycollection-iteratee_identity
* ECMAScript 2021 原生
* - Object.groupBy(items, callbackFn)
*/
export function groupBy(array = [], callback) {
return array.reduce((groups, item) => {
@ -206,6 +229,15 @@ export function omit(object, keysToOmit) {
return Object.fromEntries(Object.entries(object).filter(([key]) => !keysToOmit.includes(key)));
}
/**
* 去除无效的值: undefined, null, '', []
* * 只删除 null undefined: flush 方法;
*/
export const omitEmpty = _object => {
Object.keys(_object).forEach(key => (_object[key] == null || _object[key] === '' || _object[key].length === 0) && delete _object[key]);
return _object;
};
/**
* 深拷贝
*/
@ -572,58 +604,77 @@ export const TagColorStyle = (tag, outerStyle = false) => {
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) => {
var open = indexedDB.open('LogWebsocketData', 1)
open.onupgradeneeded = function () {
var db = open.result
var store = db.createObjectStore('LogStore', { keyPath: 'id', autoIncrement: true })
}
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() })
tx.oncomplete = function () {
db.close()
}
function createTreeNode(key, name, parent = null, keyMap={}, _raw={}) {
return {
key: key,
title: name,
parent: parent,
icon: _raw?.icon,
iconIndex: _raw?.[keyMap.iconIndex],
_raw: _raw,
children: [],
parentTitle: '',
parentIconIndex: '',
};
}
/**
* Builds a tree structure from a flat list of nodes.
* @returns {Array<object>} An array of root tree nodes.
*/
export const buildTree = (list, keyMap={ rootKeys: [], ignoreKeys: [] }) => {
if (!list || list.length === 0) {
return []
}
};
export const readWebsocketLog = () => {
return new Promise((resolve, reject) => {
let openRequest = indexedDB.open('LogWebsocketData')
openRequest.onerror = function (e) {
reject('Error opening database.')
}
openRequest.onsuccess = function (e) {
let db = e.target.result
let transaction = db.transaction('LogStore')
let store = transaction.objectStore('LogStore')
let request = store.getAll()
request.onerror = function (e) {
reject('Error getting all records.')
const nodeMap = new Map()
const treeRoots = []
list.forEach((item) => {
const node = createTreeNode(item[keyMap.key], item[keyMap.name], item[keyMap.parent], keyMap, item)
nodeMap.set(item[keyMap.key], node)
})
list.forEach((item) => {
const node = nodeMap.get(item[keyMap.key])
if (keyMap.rootKeys.includes(item[keyMap.parent]) || item[keyMap.parent] === null || item[keyMap.parent] === undefined) {
// This is a root node
treeRoots.push(node)
} else {
const parentNode = nodeMap.get(item[keyMap.parent])
if (keyMap.ignoreKeys.includes(item[keyMap.parent])) {
const grandParentNode = nodeMap.get(parentNode.parent);
node.rawParent = node.parent;
node.parent = parentNode.parent;
node.parentTitle = parentNode.title;
node.parentIconIndex = parentNode.iconIndex;
grandParentNode.children.push(node)
} else if (keyMap.ignoreKeys.includes(item[keyMap.key])) {
//
}
request.onsuccess = function (e) {
let data = e.target.result
sessionStorage.setItem('websocketLogData', JSON.stringify(data))
console.log(JSON.stringify(data))
resolve(data)
else if (parentNode) {
node.parentTitle = parentNode.title;
node.parentIconIndex = parentNode.iconIndex;
parentNode.children.push(node)
} else {
console.warn(`Parent with key '${item[keyMap.parent]}' not found for node '${item[keyMap.key]}'. This node will be treated as a root.`)
treeRoots.push(node)
}
}
})
};
export const clearWebsocketLog = () => {
let openRequest = indexedDB.open('LogWebsocketData')
openRequest.onerror = function (e) {}
openRequest.onsuccess = function (e) {
let db = e.target.result
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) {}
}
return treeRoots
}

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

@ -1,51 +1,125 @@
import { loadScript } from '@/utils/commons'
import { loadScript } from '@haina/utils-commons'
import { fetchJSON } from '@haina/utils-request'
import { readWebsocketLog } from '@/utils/indexedDB'
import { BUILD_VERSION, BUILD_DATE } from '@/config'
export const loadPageSpy = (title) => {
if (import.meta.env.DEV || window.$pageSpy) return
const PageSpyConfig = { api: 'page-spy.mycht.cn', project: 'Sales CRM', title: title + '(v' + BUILD_VERSION + ')', autoRender: false, offline: true }
const PageSpySrc = [
'https://page-spy.mycht.cn/page-spy/index.min.js' + `?${BUILD_DATE}`,
'https://page-spy.mycht.cn/plugin/data-harbor/index.min.js' + `?${BUILD_DATE}`,
'https://page-spy.mycht.cn/plugin/rrweb/index.min.js' + `?${BUILD_DATE}`,
]
Promise.all(PageSpySrc.map((src) => loadScript(src))).then(() => {
// 注册插件
window.$harbor = new DataHarborPlugin()
window.$rrweb = new RRWebPlugin()
;[window.$harbor, window.$rrweb].forEach((p) => {
PageSpy.registerPlugin(p)
})
window.$pageSpy = new PageSpy(PageSpyConfig)
// PageSpy.registerPlugin(new DataHarborPlugin());
// PageSpy.registerPlugin(new RRWebPlugin());
// 实例化 PageSpy
// window.$pageSpy = new PageSpy({ api: 'page-spy.mycht.cn', project: 'Sales CRM', title: title + '(v' + BUILD_VERSION + ')', autoRender: false, offline: true, });
console.log('[PageSpy]', window.$pageSpy.version)
// window.addEventListener('beforeunload', (e) => {
// e.preventDefault() // If you prevent default behavior in Mozilla Firefox
// e.returnValue = '' // Chrome requires returnValue to be set
// window.$harbor.upload({ clearCache: false, remark: '自动上传' }) // 上传日志 { clearCache: true, remark: '' }
// })
window.onerror = async function (msg, url, lineNo, columnNo, error) {
// 上传最近 3 分钟的日志
const now = Date.now()
await window.$harbor.uploadPeriods({
startTime: now - 3 * 60000,
endTime: now,
remark: '`onerror`自动上传',
})
/**
* @deprecated
*/
// export const loadPageSpy = (title) => {
// if (import.meta.env.DEV || window.$pageSpy) return
// const PageSpyConfig = { api: 'page-spy.mycht.cn', project: 'Sales CRM', title: title + '(v' + BUILD_VERSION + ')', autoRender: false, offline: true }
// const PageSpySrc = [
// 'https://page-spy.mycht.cn/page-spy/index.min.js' + `?${BUILD_DATE}`,
// 'https://page-spy.mycht.cn/plugin/data-harbor/index.min.js' + `?${BUILD_DATE}`,
// 'https://page-spy.mycht.cn/plugin/rrweb/index.min.js' + `?${BUILD_DATE}`,
// ]
// Promise.all(PageSpySrc.map((src) => loadScript(src))).then(() => {
// // 注册插件
// window.$harbor = new DataHarborPlugin()
// window.$rrweb = new RRWebPlugin()
// ;[window.$harbor, window.$rrweb].forEach((p) => {
// PageSpy.registerPlugin(p)
// })
// window.$pageSpy = new PageSpy(PageSpyConfig)
// // PageSpy.registerPlugin(new DataHarborPlugin());
// // PageSpy.registerPlugin(new RRWebPlugin());
// // 实例化 PageSpy
// // window.$pageSpy = new PageSpy({ api: 'page-spy.mycht.cn', project: 'Sales CRM', title: title + '(v' + BUILD_VERSION + ')', autoRender: false, offline: true, });
// console.log('[PageSpy]', window.$pageSpy.version)
// // window.addEventListener('beforeunload', (e) => {
// // e.preventDefault() // If you prevent default behavior in Mozilla Firefox
// // e.returnValue = '' // Chrome requires returnValue to be set
// // window.$harbor.upload({ clearCache: false, remark: '自动上传' }) // 上传日志 { clearCache: true, remark: '' }
// // })
// window.onerror = async function (msg, url, lineNo, columnNo, error) {
// await readWebsocketLog()
// // 上传最近 3 分钟的日志
// const now = Date.now()
// await window.$harbor.uploadPeriods({
// startTime: now - 3 * 60000,
// endTime: now,
// remark: `\`onerror\`自动上传. ${msg}`,
// })
// }
// })
// }
/**
* @deprecated
*/
// export const uploadPageSpyLog = async () => {
// // window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
// // if (window.$pageSpy) {
// // await window.$harbor.upload() // 上传日志 { clearCache: true, remark: '' }
// // }
// if (import.meta.env.DEV) return true;
// if (window.$pageSpy) {
// try {
// await readWebsocketLog()
// // await window.$harbor.upload() // 上传日志 { clearCache: true, remark: '' }
// // 上传最近 1 小时的日志, 直接upload 所有日志: 413 Payload Too Large
// const now = Date.now();
// await window.$harbor.uploadPeriods({
// startTime: now - 60 * 60000,
// endTime: now,
// });
// return true;
// } catch (error) {
// return false;
// }
// } else {
// return false;
// }
// }
export const sendNotify = async (message) => {
const notifyUrl = 'https://p9axztuwd7x8a7.mycht.cn/dingtalk/dingtalkwork/SendMDMsgByDingRobotToGroup';
const params = {
groupid: 'cidFtzcIzNwNoiaGU9Q795CIg==',
msgTitle: '有人求助',
msgText: `${message}\\n\\nSales CRM (${BUILD_VERSION})`,
};
return fetchJSON(notifyUrl, params).then((json) => {
if (json.errcode === 0) {
console.info('发送通知成功');
} else {
throw new Error(json?.errmsg + ': ' + json.errcode);
}
})
}
export const uploadPageSpyLog = async () => {
// window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
if (window.$pageSpy) {
await window.$harbor.upload() // 上传日志 { clearCache: true, remark: '' }
}
}
});
};
/**
* @deprecated
*/
// const uploadLog = async () => {
// await readWebsocketLog()
// if (window.$pageSpy) {
// // window.$pageSpy.triggerPlugins('onOfflineLog', 'upload')
// try {
// // await window.$harbor.upload() // 上传日志 { clearCache: true, remark: '' }
// // 上传最近 1 小时的日志, 直接upload 所有日志: 413 Payload Too Large
// const now = Date.now()
// await window.$harbor.uploadPeriods({
// startTime: now - 60 * 60000,
// endTime: now,
// })
// messageApi.info('Success')
// // clearWebsocketLog()
// sendNotify()
// } catch (error) {
// messageApi.error('Failure')
// }
// } else {
// messageApi.error('Failure')
// }
// }

@ -1,6 +1,10 @@
import { BUILD_VERSION } from '@/config'
console.warn('Warning: `request.js` is deprecated and will be removed in next version.');
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
const customHeaders = []
// 添加 HTTP Reuqest 自定义头部

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

@ -1,45 +1,42 @@
import ErrorBoundary from '@/components/ErrorBoundary'
import useAuthStore from '@/stores/AuthStore'
import { useThemeContext } from '@/stores/ThemeContext'
import { Conditional } from '@/components/Conditional'
import useConversationStore from '@/stores/ConversationStore'
import { PERM_USE_EMAL } from '@/stores/AuthStore'
import {
App as AntApp,
ConfigProvider,
Empty,
Modal,
message,
FloatButton,
theme,
Modal, FloatButton,
theme
} from 'antd'
import { BugOutlined, MailOutlined } from '@ant-design/icons'
import { CustomerServiceOutlined } from '@ant-design/icons'
import zhLocale from 'antd/locale/zh_CN'
import 'dayjs/locale/zh-cn'
import { useEffect } from 'react'
import { Outlet, useHref, useNavigate } from 'react-router-dom'
import { appendRequestHeader } from '@/utils/request'
import { loadPageSpy } from '@/utils/pagespy'
import { appendRequestHeader } from '@haina/utils-request'
import { loadPageSpy } from '@haina/utils-pagespy'
import AppLogo from '@/assets/highlights_travel_300_300.png'
import '@/assets/App.css'
import 'react-chat-elements/dist/main.css'
import EmailFetch from './Conversations/Online/Components/EmailFetch'
import FetchEmailWorker from './../workers/fetchEmailWorker?worker&url'
import { clearWebsocketLog, readWebsocketLog } from '@/utils/commons'
import { useGlobalNotify } from '@/hooks/useGlobalNotify'
import GeneratePaymentDrawer from './Conversations/Online/Components/GeneratePaymentDrawer'
import GenerateAutoDocDrawer from './Conversations/Online/Components/GenerateAutoDocDrawer'
import GenerateShorturlDrawer from './Conversations/Online/Components/GenerateShorturlDrawer'
import LogUploader from '@/components/LogUploader'
import { BUILD_VERSION } from '@/config'
// const fetchEmailWorkerURL = new URL('/src/workers/fetchEmailWorker.js', import.meta.url);
const fetchEmailWorker = new Worker(FetchEmailWorker, { type: 'module' });
function AuthApp() {
const navigate = useNavigate()
const [messageApi, contextHolder] = message.useMessage()
const { colorPrimary, borderRadius } = useThemeContext()
const [loginUser, sendNotify, isPermitted] = useAuthStore((state) => [
state.loginUser, state.sendNotify, state.isPermitted
const [loginUser] = useAuthStore((state) => [
state.loginUser
])
const href = useHref()
@ -60,14 +57,11 @@ function AuthApp() {
let _fetchEmailWorker;
if (loginUser.userId > 0) {
appendRequestHeader('X-User-Id', loginUser.userId)
loadPageSpy(loginUser.username)
loadPageSpy(loginUser.username + '(v' + BUILD_VERSION + ')', 'Sales CRM', true)
connectWebsocket(loginUser.userId)
fetchInitialData(loginUser)
let _fetchEmailWorker;
if (isPermitted(PERM_USE_EMAL)) {
_fetchEmailWorker = startEmailInterval(loginUser.userId)
}
_fetchEmailWorker = startEmailInterval(loginUser.userId)
}
return () => {
disconnectWebsocket()
@ -78,6 +72,8 @@ function AuthApp() {
}
}, [])
useGlobalNotify();
const startEmailInterval = (userId) => {
// const fetchEmailWorker = new Worker(fetchEmailWorkerURL, { type: 'module' });
fetchEmailWorker.onerror = function(error) {
@ -90,23 +86,18 @@ function AuthApp() {
return fetchEmailWorker;
}
const uploadLog = async () => {
await readWebsocketLog()
if (window.$pageSpy) {
// window.$pageSpy.triggerPlugins('onOfflineLog', 'upload')
await window.$harbor.upload() // { clearCache: true, remark: '' }
messageApi.info('Success')
clearWebsocketLog()
sendNotify()
} else {
messageApi.error('Failure')
}
clearWebsocketLog()
}
// /p...
const needToLogin = loginUser.userId === -1 && href.indexOf('/p/') === -1
const isMobileApp =
navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i,
) !== null
const floatButtonLineEnd = isMobileApp ? 0 : 24
const floatTrigger = isMobileApp ? 'click' : null
useEffect(() => {
if (needToLogin) {
navigate('/p/dingding/login?origin_url=' + href)
@ -122,7 +113,7 @@ function AuthApp() {
colorPrimary: colorPrimary,
borderRadius: borderRadius,
fontFamily:
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Noto Color Emoji','Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol'",
"-apple-system,BlinkMacSystemFont,Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Noto Color Emoji','Apple Color Emoji'",
},
algorithm: theme.defaultAlgorithm,
}}
@ -132,19 +123,20 @@ function AuthApp() {
<AntApp>
<ErrorBoundary>
<FloatButton.Group
shape="square"
shape='square'
placement={'left'}
trigger={floatTrigger}
style={{
insetInlineEnd: 94,
insetInlineEnd: floatButtonLineEnd,
insetBlockEnd: floatButtonLineEnd,
flexDirection: 'row',
}}
icon={<CustomerServiceOutlined />}
>
<Conditional
condition={isPermitted(PERM_USE_EMAL)}
whenTrue={<EmailFetch />}
/>
<FloatButton icon={<BugOutlined />} tooltip={<div>上传日志给研发部</div>} onClick={() => uploadLog()} />
<EmailFetch />
<LogUploader />
<FloatButton.BackTop />
</FloatButton.Group>
{contextHolder}
{needToLogin ? <>login...</> : <Outlet />}
<dialog id="about-dialog" className="border-0">
<img className="logo" src={AppLogo} alt="logo" />
@ -169,6 +161,9 @@ function AuthApp() {
</button>
</form>
</dialog>
<GeneratePaymentDrawer />
<GenerateAutoDocDrawer />
<GenerateShorturlDrawer />
</ErrorBoundary>
</AntApp>
</ConfigProvider>

@ -1,8 +1,8 @@
import { useCallback, useState, useEffect } from "react";
import { Grid, Divider, Layout, Flex, Spin, Input, Col, Row, List, Typography } from "antd";
import { Grid, Divider, Layout, Flex, Spin, Input, Col, Row, List, Typography, Alert } from "antd";
import { PhoneOutlined, CustomerServiceOutlined, AudioOutlined } from "@ant-design/icons";
import { useParams, useHref, useNavigate } from "react-router-dom";
import { isEmpty } from "@/utils/commons";
import { isEmpty } from "@haina/utils-commons";
import callCenterStore from "@/stores/CallCenterStore";
import useAuthStore from "@/stores/AuthStore";
@ -50,9 +50,15 @@ const CallCenter = props => {
setPhone_number(e.target.value);
}}></Input.Search>
</Col>
<Col md={24} lg={8} xxl={9}></Col>
<Col md={24} lg={8} xxl={9}>
<Alert message={'不支持拨打中国大陆(+86)号码'} type="info" showIcon />
</Col>
</Row>
<Row gutter={16}>
<Col md={24} lg={8} xxl={9}></Col>
<Col md={24} lg={8} xxl={6}>
</Col>
</Row>
<Divider plain orientation="left" className="mb-0"></Divider>
<List header={<Typography.Text strong>Console Logs</Typography.Text>} bordered dataSource={logs} renderItem={item => <List.Item>{item}</List.Item>} />
</>

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

@ -6,7 +6,7 @@ import ConversationsList from './Conversations/History/ConversationsList';
import MessagesMatchList from './Conversations/History/MessagesMatchList';
import MessagesList from './Conversations/History/MessagesList';
import ImageAlbumPreview from './Conversations/History/ImageAlumPreview';
import { flush, pick } from '@/utils/commons';
import { flush, pick } from '@haina/utils-commons';
import { fetchConversationsSearch, fetchConversationsUnassigned } from '@/actions/ConversationActions';
const { Sider, Content } = Layout;

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

@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { App } from 'antd';
import { calcCacheSizes, clearAllCaches } from '@/utils/commons';
import { calcCacheSizes, clearAllCaches } from '@haina/utils-commons';
const ClearCache = (props) => {
const { message } = App.useApp();

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

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

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

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

@ -1,15 +1,19 @@
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 { MessageBox } from 'react-chat-elements';
import { MESSAGE_PAGE_SIZE, fetchMessagesHistory } from '@/actions/ConversationActions';
import useFormStore from '@/stores/FormStore';
import { isEmpty, stringToColour, groupBy, isNotEmpty } from '@/utils/commons';
import { isEmpty, stringToColour, groupBy, isNotEmpty, TagColorStyle } from '@haina/utils-commons';
import { useShallow } from 'zustand/react/shallow';
import MergeConversationTo from './MergeConversationTo';
import BubbleIM from '../Online/Components/BubbleIM';
import BubbleEmail from '../Online/Components/BubbleEmail';
import { ERROR_IMG, POPUP_FEATURES } from '@/config';
import { parseSimpleMarkdown } from '@/channel/bubbleMsgUtils';
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 100;
const MessagesList = ({ ...props }) => {
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 20;
const MessagesList = ({ ...listProps }) => {
const { message: appMessage } = App.useApp();
const [formValues] = useFormStore((state) => [state.chatHistoryForm]);
@ -17,6 +21,7 @@ const MessagesList = ({ ...props }) => {
const [paramsForMsgList, setParamsForMsgList] = useFormStore((state) => [state.msgListParams, state.setMsgListParams]);
const [selectMatch, setSelectedMatch] = useFormStore((state) => [state.msgHistorySelectMatch, state.setMsgHistorySelectMatch]);
const [setImageAlbumList, setImagePreviewSrc] = useFormStore(useShallow((state) => [state.setImageAlbum, state.setImagePreviewSrc]));
const [ setEmailList] = useFormStore(useShallow((state) => [ state.setEmailList]));
const [chatItemMessages, setChatItemMessages] = useState([]);
const [messageListPreLoading, setMessageListPreLoading] = useState(false);
@ -31,7 +36,7 @@ const MessagesList = ({ ...props }) => {
setChatItemMessages((prevValue) => [].concat(data, prevValue));
const loadPrePage = !(data.length === 0 || data.length < BIG_PAGE_SIZE);
if (data.length > 0) {
setParamsForMsgList({ loadPrePage, pretime: data[0].orgmsgtime });
setParamsForMsgList({ loadPrePage, pretime: data[0].msgtime });
}
};
const getMessagesNext = async (chatItem) => {
@ -43,7 +48,7 @@ const MessagesList = ({ ...props }) => {
setChatItemMessages((prevValue) => [].concat(prevValue, data));
const loadNextPage = !(data.length === 0 || data.length < BIG_PAGE_SIZE);
if (data.length > 0) {
setParamsForMsgList({ loadNextPage, lasttime: data[data.length - 1].orgmsgtime });
setParamsForMsgList({ loadNextPage, lasttime: data[data.length - 1].msgtime });
}
};
@ -64,15 +69,16 @@ const MessagesList = ({ ...props }) => {
if (isEmpty(selectedConversation.conversationid)) {
return () => {};
}
const firstActionPageParams = { opisn: (selectedConversation.opi_sn || 0), whatsappid: selectedConversation.whatsapp_phone_number, loadNextPage: true };
// opisn: (selectedConversation.opi_sn || 0), whatsappid: selectedConversation.whatsapp_phone_number,
const firstActionPageParams = { conversationid: selectedConversation.conversationid , loadNextPage: true };
// if (isEmpty(selectedConversation.matchMsgList)) {
if (!isEmpty(formValues?.from_date)) {
firstActionPageParams.lasttime = formValues.from_date;
firstActionPageParams.loadPrePage = true;
}
if (!isEmpty(formValues?.search) && !isEmpty(selectedConversation.matchMsgList)) {
firstActionPageParams.pretime = selectedConversation.matchMsgList[0].orgmsgtime;
firstActionPageParams.lasttime = selectedConversation.matchMsgList[0].orgmsgtime;
firstActionPageParams.pretime = selectedConversation.matchMsgList[0].msgtime;
firstActionPageParams.lasttime = selectedConversation.matchMsgList[0].msgtime;
firstActionPageParams.loadPrePage = true;
}
setParamsForMsgList(firstActionPageParams);
@ -103,9 +109,11 @@ const MessagesList = ({ ...props }) => {
// ,
useEffect(() => {
if (chatItemMessages.length > 0) {
// setParamsForMsgList({ pretime: chatItemMessages[0].orgmsgtime, lasttime: chatItemMessages[chatItemMessages.length - 1].orgmsgtime });
// 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);
setImageAlbumList(album);
const emailList = chatItemMessages.filter((ele) => ele.msg_source === 'email').map(ele => ({...ele, MAI_SN: ele.msgtext?.email?.mai_sn, MAI_Subject: ele.msgtext?.email?.subject, SenderReceiver: ele.from, MAI_SendDate: ele.msgtime, Direction: ele.msg_direction === 'inbound' ? '收' : '发' })).reverse();
setEmailList(emailList);
}
return () => {};
}, [chatItemMessages]);
@ -141,6 +149,27 @@ const MessagesList = ({ ...props }) => {
setFocusMsg(id);
}
};
// Render parsed tokens to React elements
const renderMDTokens = (tokens) => {
return tokens.map((token, index) => {
switch (token.type) {
case 'text':
return <span key={index}>{token.content}</span>;
case 'bold':
return <b key={index}>{renderMDTokens(token.content)}</b>;
case 'italic':
return <i key={index}>{renderMDTokens(token.content)}</i>;
case 'url':
return (
<a key={index} href={token.content} target='_blank' rel='noopener noreferrer' className=' underline text-sm'>
{token.content}
</a>
)
default:
return <span key={index}>{token.content}</span>;
}
});
};
const RenderText = memo(function renderText({ str, className, template }) {
let headerObj, footerObj, buttonsArr;
@ -150,27 +179,12 @@ const MessagesList = ({ ...props }) => {
footerObj = componentsObj?.footer?.[0];
buttonsArr = componentsObj?.buttons?.reduce((r, c) => r.concat(c.buttons), []);
}
const parts = str.split(/(https?:\/\/[^\s()]+|\p{Emoji_Presentation})/gmu).filter((s) => s !== '');
const links = str.match(/https?:\/\/[^\s()]+/gi) || [];
const emojis = str.match(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g) || [];
const extraClass = isEmpty(emojis) ? '' : '';
const objArr = parts.reduce((prev, curr, index) => {
if (links.includes(curr)) {
prev.push({ type: 'link', key: curr });
} else if (emojis.includes(curr)) {
prev.push({ type: 'emoji', key: curr });
} else {
prev.push({ type: 'text', key: curr });
}
return prev;
}, []);
return (
<span className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className} ${extraClass} `} key={'msg-text'}>
<span className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className} `} key={'msg-text'}>
{headerObj ? (
<div className='text-neutral-500 text-center'>
{'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>}
{'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <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()) && (
<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;]
@ -178,17 +192,7 @@ const MessagesList = ({ ...props }) => {
)}
</div>
) : null}
{(objArr || []).map((part, index) => {
if (part.type === 'link') {
return (
<a href={part.key} target='_blank' key={`${part.key}${index}`} rel='noreferrer' className='text-sm'>
{part.key}
</a>
);
} else {
return part.key;
}
})}
{renderMDTokens(parseSimpleMarkdown(str))}
</span>
);
});
@ -196,8 +200,9 @@ const MessagesList = ({ ...props }) => {
// eslint-disable-next-line react/display-name
const MessageBoxWithRef = forwardRef((props, ref) => (
<li ref={ref}>
<MessageBox {...props} />
</li>
{['waba', 'wai'].includes((props.msg_source || '').toLowerCase()) && <MessageBox {...props} />}
{props.msg_source === 'email' && <BubbleEmail {...props} reposition='left' onOpenEmail={listProps.onOpenEmail} />}
</li>
));
const handlePreview = (msg) => {
@ -207,7 +212,7 @@ const MessagesList = ({ ...props }) => {
return false;
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;
default:
@ -252,16 +257,17 @@ const MessagesList = ({ ...props }) => {
title={message.whatsapp_msg_type === 'text' ? '' : message.title}
text={<RenderText str={message?.text || ''} className={message.status === 'failed' ? 'line-through text-neutral-400' : ''} template={message.template} />}
copiableDate={true}
dateString={message.dateString || message.localDate}
dateString={`${message.wabaName} - ${message.dateString || message.localDate}`}
className={[
'whitespace-pre-wrap mb-2',
message.whatsapp_msg_type === 'sticker' ? 'bg-transparent' : '',
message.sender === 'me' ? 'whatsappme-container' : '',
focusMsg === message.id ? 'message-box-focus' : '',
message.status === 'failed' ? 'failed-msg' : '',
message.sender === 'me' ? (message.msg_source.toLowerCase() === 'waba' ? `[&_.rce-mbox]:bg-waba-me` : `[&_.rce-mbox]:bg-whatsapp-me`) : '',
].join(' ')}
style={{
backgroundColor: message.sender === 'me' ? '#ccd4ae' : '#fff',
// backgroundColor: message.sender === 'me' ? '#ccd4ae' : '#fff',
}}
{...(message.type === 'meetingLink'
? {
@ -277,10 +283,10 @@ const MessagesList = ({ ...props }) => {
}
: {})}
renderAddCmp={
<div key={'msg-prefix'} className='border-dashed border-0 border-t border-slate-300 text-slate-600 space-x-2 emoji'>
<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
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}
</span>
<span>{message.dateString || message.localDate}</span>

@ -1,6 +1,6 @@
import { ChatItem } from 'react-chat-elements';
import useFormStore from '@/stores/FormStore';
import { isNotEmpty } from '@/utils/commons';
import { isNotEmpty } from '@haina/utils-commons';
const MessagesMatchList = ({ ...props }) => {
const [formValues] = useFormStore((state) => [state.chatHistoryForm]);

@ -2,7 +2,7 @@
import { memo } from 'react';
import { Form, Flex, Input, Button, DatePicker, Select } from 'antd';
import SearchInput from '@/components/SearchInput';
import { isNotEmpty } from '@/utils/commons';
import { isNotEmpty } from '@haina/utils-commons';
import { fetchSalesAgentWithDD as fetchSalesAgent, fetchCustomerList } from '@/actions/CommonActions';
const { RangePicker } = DatePicker;
@ -40,13 +40,13 @@ const SearchForm = memo(function ({ initialValues, onSubmit, onReset }) {
<Input placeholder='关键词' allowClear />
</Form.Item>
<Form.Item label='消息渠道' name='mchannel' className='w-52'>
<Select mode='multiple' maxTagCount={0} allowClear options={[
{ key: 'waba', label: 'WA商业号', value: 'waba' },
<Select maxTagCount={0} allowClear options={[
{ key: 'WABA', label: 'WA商业号', value: 'WABA' },
{ key: 'email', label: '邮件', value: 'email' },
{ key: 'whatsapp', label: 'WhatsApp', value: 'whatsapp' },
{ key: 'wai', label: 'WhatsApp', value: 'wai' },
]} className='w-8' />
</Form.Item>
<Form.Item label='消息类型' name='mtype' className='w-44'>
{/* <Form.Item label='' name='mtype' className='w-44'>
<Select mode='multiple' maxTagCount={0} allowClear options={[
{ key: 'image', label: '图片', value: 'image' },
{ key: 'video', label: '视频', value: 'video' },
@ -54,9 +54,10 @@ const SearchForm = memo(function ({ initialValues, onSubmit, onReset }) {
{ key: 'file', label: '文件', value: 'file' },
{ key: 'email', label: '邮件', value: 'email' },
]} className='w-8' />
</Form.Item>
</Form.Item> */}
<Form.Item label='日期' name='msgDateRange'>
<RangePicker format={'YYYY-MM-DD'} />
</Form.Item>
</Flex>
<div style={{ flex: '0 1 64px' }} className='flex justify-between'>

@ -2,7 +2,7 @@ import { memo } from 'react';
import { App } from 'antd';
import { MailOutlined } from '@ant-design/icons';
import { MessageBox } from 'react-chat-elements';
import { isEmpty, } from '@/utils/commons';
import { isEmpty, } from '@haina/utils-commons';
import { useEmailDetail, } from '@/hooks/useEmail';
const BubbleEmail = ({ onOpenEditor, onOpenEmail, ...message }) => {
@ -56,17 +56,17 @@ const BubbleEmail = ({ onOpenEditor, onOpenEmail, ...message }) => {
<>
<b>From: </b>
<span>
{/* {message?.emailOrigin?.fromName}&nbsp;&lt;{message?.emailOrigin.fromEmail}&gt; */}
{message.msgOrigin?.from}
</span>
</> : <><b>To: </b>{message.msgOrigin?.to}</>
}
{/* <b>Subject: </b>{message.msgOrigin.email.subject} */}
</span>
</>
}
// titleColor={message.sender !== 'me' ? '#4f46e5' : ''} // 600
notch={false}
position={message.sender === 'me' ? 'right' : 'left'}
position={message.reposition || (message.sender === 'me' ? 'right' : 'left')}
onReplyClick={() => onOpenEditor(message.msgOrigin)}
onForwardClick={ () => { handleResend(message.msgOrigin); }}
onOpen={() => handlePreview(message)}

@ -1,12 +1,20 @@
import { createContext, useEffect, useState, memo } from 'react';
import { App, Button } from 'antd';
import { MailFilled, MailOutlined, WhatsAppOutlined } from '@ant-design/icons';
import { memo } from 'react';
import { App, Button, Image } from 'antd';
import { ExportOutlined, CopyOutlined, PhoneOutlined } from '@ant-design/icons';
import { MessageBox } from 'react-chat-elements';
import { groupBy, isEmpty } from '@/utils/commons';
import { groupBy, isEmpty, TagColorStyle } from '@haina/utils-commons';
import { parseSimpleMarkdown } from '@/channel/bubbleMsgUtils';
import useConversationStore from '@/stores/ConversationStore';
import { useShallow } from 'zustand/react/shallow';
import { WABIcon } from '@/components/Icons';
import { ReplyIcon } from '@/components/Icons';
import ChannelLogo from './ChannelLogo';
import { ERROR_IMG } from '@/config';
const outboundStyle = {
'waba': { color: '#ccd4ae' },
'whatsapp': { color: '#d9fdd3' },
'wai': { color: '#d9fdd3' },
}
const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, setNewChatFormValues, scrollToMessage, focusMsg, ...message }) => {
const { message: appMessage } = App.useApp();
@ -16,39 +24,49 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
setNewChatModalVisible(true);
setNewChatFormValues((prev) => ({ ...prev, phone_number: wa_id, name: wa_name }));
};
// Render parsed tokens to React elements
const renderMDTokens = (tokens) => {
return tokens.map((token, index) => {
switch (token.type) {
case 'text':
return <span key={index}>{token.content}</span>
case 'bold':
return <b key={index}>{renderMDTokens(token.content)}</b>
case 'italic':
return <i key={index}>{renderMDTokens(token.content)}</i>
case 'url':
return (
<a key={index} href={token.content} target='_blank' rel='noopener noreferrer' className=' underline text-sm'>
{token.content}
</a>
)
case 'number':
return (
<a key={`${index}`} className='text-sm ' onClick={() => openNewChatModal({ wa_id: token.content, wa_name: token.content })}>
{token.content}
</a>
)
default:
return <span key={index}>{token.content}</span>
}
})
};
const RenderText = memo(function renderText({ str, className, template, message }) {
let headerObj, footerObj, buttonsArr;
if (!isEmpty(template) && !isEmpty(template.components)) {
const componentsObj = groupBy(template.components, (item) => item.type);
const componentsObj = groupBy(template.components.concat(template?.components_omit || []), (item) => item.type);
headerObj = componentsObj?.header?.[0];
footerObj = componentsObj?.footer?.[0];
buttonsArr = componentsObj?.buttons?.reduce((r, c) => r.concat(c.buttons), []);
buttonsArr = componentsObj?.button; // ?.reduce((r, c) => r.concat(c.buttons), []);
}
const parts = str.split(/(https?:\/\/[^\s()]+|\p{Emoji_Presentation}|\d{4,})/gmu).filter((s) => s !== '');
const links = str.match(/https?:\/\/[^\s()]+/gi) || [];
const numbers = str.match(/\d{4,}/g) || [];
const emojis = str.match(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g) || [];
const extraClass = isEmpty(emojis) ? '' : '';
const objArr = parts.reduce((prev, curr, index) => {
if (links.includes(curr)) {
prev.push({ type: 'link', key: curr });
} else if (numbers.includes(curr)) {
prev.push({ type: 'number', key: curr });
} else if (emojis.includes(curr)) {
prev.push({ type: 'emoji', key: curr });
} else {
prev.push({ type: 'text', key: curr });
}
return prev;
}, []);
return (
<span className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className} ${extraClass}`} key={'msg-text'}>
<div className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className}`} key={'msg-text'}>
{headerObj ? (
<div className='text-neutral-500 text-center'>
{'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>}
{'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <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()) && (
<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;]
@ -56,50 +74,33 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
)}
</div>
) : null}
{(objArr || []).map((part, index) => {
if (part.type === 'link') {
return (
<a href={part.key} target='_blank' key={`${part.key}${index}`} rel='noreferrer' className='text-sm'>
{part.key}
</a>
)
} else if (part.type === 'number') {
return (
<a key={`${part.key}${index}`} className='text-sm' onClick={() => openNewChatModal({ wa_id: part.key, wa_name: part.key })}>
{part.key}
</a>
)
} else {
// if (part.type === 'emoji')
return part.key
}
})}
{renderMDTokens(parseSimpleMarkdown(str))}
{footerObj ? <div className=' text-neutral-500'>{footerObj.text}</div> : null}
{buttonsArr && buttonsArr.length > 0 ? (
<div className='flex flex-row gap-1'>
{buttonsArr.map((btn, index) =>
btn.type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={btn.url} rel='noreferrer'>
btn.sub_type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={`${btn.sub_type}_${btn.index}`} rel='noreferrer' icon={<ExportOutlined />}>
{btn.text}
</Button>
) : btn.type.toLowerCase() === 'phone_number' ? (
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer'>
) : btn.sub_type.toLowerCase() === 'phone_number' ? (
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer' icon={<PhoneOutlined />}>
{btn.text} ({btn.phone_number})
</Button>
) : (
<Button className='text-blue-500' size={'small'} key={btn.type}>
{btn.text}
<Button className='text-blue-500' size={'small'} key={`${btn.type}_${btn.sub_type}_${btn.index}`} icon={btn.sub_type.toLowerCase() === 'copy_code' ? <CopyOutlined /> : btn.sub_type.toLowerCase() === 'quick_reply' ? <ReplyIcon /> : null}>
{btn.text || btn.sub_type.toUpperCase()}
</Button>
),
)}
</div>
) : null}
</span>
</div>
)
});
return (
<MessageBox
{...message}
{...message} titleColor={TagColorStyle(message.title).color}
key={`IM.${message.id}`}
position={message.sender === 'me' ? 'right' : 'left'}
onReplyClick={() => setReferenceMsg(message)}
@ -112,21 +113,22 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
{...(message.sender === 'me'
? {
// styles: { backgroundColor: '#ccd4ae' },
notchStyle: { fill: '#ccd4ae' }, // todo: channel[WhatsApp] color '#d9fdd3'
title: <><ChannelLogo channel={message.msg_source} />&nbsp;{message.wabaName}&nbsp;-&nbsp;{message.title}</>,
notchStyle: { fill: outboundStyle[message.msg_source.toLowerCase()].color },
title: <><ChannelLogo channel={message.msg_source} />{message.wabaName ? ` ${message.wabaName} - ${message.title || ''}` : ` ${message.title || message.from || ''}`}</>,
}
: {
// title: <>&nbsp;<ChannelLogo channel={message.msg_source} />&nbsp;{message.title}</>,
dateString: `${message.wabaName} - ${message.dateString}`,
})}
className={[
'whitespace-pre-wrap',
'whitespace-pre-wrap', '[&_.rce-mbox-reply-message]:line-clamp-3',
message.whatsapp_msg_type === 'sticker' ? 'bg-transparent' : '',
// message.sender === 'me' ? 'whatsappme-container' : '',
focusMsg === message.id ? 'message-box-focus' : '',
message.status === 'failed' ? 'failed-msg' : '',
// '*:bg-waba-me'
message.sender === 'me' ? '*:!bg-waba-me' : '', // todo: channel color
message.sender === 'me' ? (message.msg_source.toLowerCase() === 'waba' ? `[&_.rce-mbox]:bg-waba-me` : `[&_.rce-mbox]:bg-whatsapp-me`) : '',
message.sender !== 'me' ? (message.msg_source.toLowerCase() === 'waba' ? `[&_.rce-mbox-time:before]:text-waba-600 [&_.rce-mbox-time:before]:font-semibold` : `[&_.rce-mbox-time:before]:text-whatsapp`) : '',
].join(' ')}
{...(message.type === 'meetingLink'
? {

@ -12,6 +12,8 @@ const ChannelLogo = ({channel, className, ...props}) => {
case 'waba':
return <WABIcon key={channel} className={`text-whatsapp ${className} `} />;
case 'wa':
case 'wai':
case 'whatsapp':
return <WhatsAppOutlined key={channel} className={`text-whatsapp ${className} `} />;
case 'email':
return <MailOutlined key={channel} className={`text-indigo-500 ${className} `} />

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

@ -1,11 +1,11 @@
import React, { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Dropdown, Input, Button, Tag, Popover, Form, Tooltip } from 'antd';
import { Dropdown, Input, Button, Tag, Popover, Form, Tooltip, Spin } from 'antd';
import { CloseCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import { fetchConversationItemClose, fetchConversationsSearch, fetchConversationItemUnread, fetchConversationItemTop, postConversationTags, deleteConversationTags } from '@/actions/ConversationActions';
import { fetchConversationItemClose, fetchConversationsSearch, fetchConversationItemUnread, fetchConversationItemTop, postConversationTags, deleteConversationTags, fetchCleanUnreadMsgCount } from '@/actions/ConversationActions';
import { ChatItem } from 'react-chat-elements';
// import ConversationsNewItem from './ConversationsNewItem';
import { flush, isEmpty, isNotEmpty, stringToColour, TagColorStyle } from '@/utils/commons';
import { flush, isEmpty, isNotEmpty, stringToColour, TagColorStyle } from '@haina/utils-commons';
import useConversationStore from '@/stores/ConversationStore';
import useAuthStore from '@/stores/AuthStore';
import ChannelLogo from './ChannelLogo';
@ -75,9 +75,13 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
const setClosedConversationList = useConversationStore((state) => state.setClosedConversationList);
const [currentHandleChat, setCurrentHandleChat] = useState({});
const [handleLoading, setHandleLoading] = useState(false);
const itemTagsKeys = (item.tags || []).map(t => t.key);
const [tags, addTag] = useConversationStore(state => [state.tags, state.addTag]);
const handleConversationItemClose = async (item) => {
setHandleLoading(true);
await fetchConversationItemClose({ conversationid: item.sn, opisn: item.opi_sn });
delConversationitem(item);
if (String(order_sn) === String(item.coli_sn)) {
@ -85,22 +89,44 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
}
const _clist = await fetchConversationsSearch({ opisn: userId, session_enable: 0 });
setClosedConversationList(_clist);
setCurrentHandleChat({});
setHandleLoading(false);
};
const handleConversationItemUnread = async (item) => {
await fetchConversationItemUnread({ conversationid: item.sn });
setHandleLoading(true);
if (item.unread_msg_count < 999) {
await fetchConversationItemUnread({ conversationid: item.sn });
} else {
await fetchCleanUnreadMsgCount({ opisn: item.opi_sn, conversationid: item.sn });
}
await refreshConversationList(item.lasttime);
setListUpdateFlag(Math.random());
setCurrentHandleChat({});
setHandleLoading(false);
}
const handleConversationItemTop = async (item) => {
setHandleLoading(true);
await fetchConversationItemTop({ conversationid: item.sn, top_state: item.top_state === 0 ? 1 : 0 });
await refreshConversationList(item.lasttime);
setListUpdateFlag(Math.random());
setCurrentHandleChat({});
setHandleLoading(false);
}
const handleConversationItemMuted = async (item) => {
setHandleLoading(true);
await fetchConversationItemTop({ conversationid: item.sn, top_state: item.top_state === -1 ? 0 : -1 });
await refreshConversationList(item.lasttime);
setListUpdateFlag(Math.random());
setCurrentHandleChat({});
setHandleLoading(false);
}
const handleConversationItemTags = async (item, tagKey, tagLabel) => {
const _tags = (item.tags || []).map(t => t.key);
setHandleLoading(true);
if (isNotEmpty(tagKey) && _tags.includes(Number(tagKey))) {
await deleteConversationTags({ conversationid: item.sn, tag_id: tagKey, opisn: userId })
} else {
@ -117,12 +143,17 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
await refreshConversationList(item.lasttime);
setListUpdateFlag(Math.random());
setContextMenuOpen(false);
setCurrentHandleChat({});
setHandleLoading(false);
}
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const handleContextMenuOpenChange = (nextOpen, info) => {
const handleContextMenuOpenChange = (nextOpen, info, item) => {
if (info.source === 'trigger' || nextOpen) {
setContextMenuOpen(nextOpen);
setCurrentHandleChat(nextOpen ? item : {})
} else {
// setCurrentHandleChat({});
}
};
@ -138,7 +169,7 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
const RenderLastMsg = (msg) => {
const readFromMsg = msg?.originText || msg?.text || '';
// const _text = isEmpty(msg) ? '' : msg.type === 'text' ? msg.text.body : `[${(msg?.type || '').toUpperCase()}]`;
const _text = isEmpty(msg) ? '' : ((whatsappMsgTypeMapped?.[msg.type]?.renderForReply(msg) || {})?.message || readFromMsg)
const _text = isEmpty(msg?.type) ? '' : ((whatsappMsgTypeMapped?.[msg.type]?.renderForReply(msg) || {})?.message || readFromMsg)
return (
<>{_text}</>
);
@ -152,11 +183,12 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
trigger={['contextMenu']}
overlayClassName='z-[998]'
open={contextMenuOpen}
onOpenChange={handleContextMenuOpenChange}
onOpenChange={(nextOpen, info) => handleContextMenuOpenChange(nextOpen, info, item)}
menu={{
items: [
item.top_state === 1 ? { label: '取消置顶', key: 'top' } : { label: '置顶会话', key: 'top' },
{ label: '标记为未读', key: 'unread' },
{ label: item.top_state === 1 ? '取消置顶' : '置顶会话', key: 'top' },
{ label: item.unread_msg_count > 998 ? '标为已读' : '标记为未读', key: 'unread' },
// { label: item.top_state === -1 ? '' : '', key: 'mute' },
{
label: '设置标签',
key: 'tags',
@ -196,8 +228,9 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
},
},
{ label: '编辑联系人', key: 'edit0' },
{ type: 'divider' },
{ label: '隐藏会话', key: 'close', danger: true },
{ label: '移到🗂隐藏列表', key: 'close', danger: true },
],
triggerSubMenuAction: 'click',
openKeys: openTags,
@ -214,14 +247,17 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
}
switch (key) {
case 'top':
setContextMenuOpen(false)
return handleConversationItemTop(item)
setContextMenuOpen(false);
return handleConversationItemTop(item);
case 'mute':
setContextMenuOpen(false);
return handleConversationItemMuted(item);
case 'unread':
setContextMenuOpen(false)
return handleConversationItemUnread(item)
setContextMenuOpen(false);
return handleConversationItemUnread(item);
case 'close':
setContextMenuOpen(false)
return handleConversationItemClose(item)
setContextMenuOpen(false);
return handleConversationItemClose(item);
case 'edit0':
setOpenTags([])
setEditingChat({ ...item, is_new: false })
@ -245,16 +281,21 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
: item.top_state === 1
? 'bg-stone-100'
: '',
'hover:bg-slate-50',
(item.sn) === (currentHandleChat?.sn) ? ' bg-slate-50 text-slate-500' : '',
// String(item.sn) === String(tabSelectedConversation?.sn) ? ' bg-neutral-200' : '',
].join(' ')}>
{/* <div className='pl-4 pt-1 text-xs text-right'>
{tags.map((tag) => <Tag color={tag.color} key={tag.value}>{tag.label}</Tag>)}
</div> */}
<Spin spinning={(item.sn) === (currentHandleChat?.sn) && (props.conversationsListLoading || handleLoading)} size='small'>
<ChatItem
{...item}
key={item.sn}
id={item.sn}
letterItem={{ id: item.show_default, letter: (item?.show_default || '').split("@")[0].slice(0, 5) }}
letterItem={ item.session_type === 1 ? void 0 : { id: item.show_default, letter: (item?.show_default || '').split("@")[0].slice(0, 5) }}
avatar={ item.session_type === 1 ? 'https://hiana-crm.oss-accelerate.aliyuncs.com/WAMedia/02ab0228-4c3c-4834-ac73-a6dfcdf81938.png' : void 0}
avatarSize={'small'}
alt={item.whatsapp_name}
title={
<span>
@ -266,13 +307,13 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
}
// subtitle={item.coli_id}
subtitle={
<div>
<>
{/* <ReadIcon /> */}
{/* <DeliverIcon /> */}
{/* <SentIcon /> */}
{/* <span>{item.coli_id}</span> */}
{/* <span><ReadIcon />最后一条消息</span> */}
<span className='text-sm'>
<span className='text-xs'>
<RenderLastMsg {...item?.last_message} />
</span>
<div className='text-sm'>
@ -284,11 +325,13 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
))}
{/* <span title={'附加备注'}>附加备注</span> */}
</div>
</div>
</>
}
date={item.lasttime || item.last_received_time || item.last_send_time}
dateString='' // : , , dataString
unread={item.unread_msg_count > 99 ? 0 : item.unread_msg_count}
muted={item.top_state === -1}
showMute={item.top_state === -1}
// className={[
// String(item.sn) === String(currentConversation.sn) ? '__active text-primary bg-whatsapp-bg' : '',
// String(item.sn) === String(tabSelectedConversation?.sn) ? ' bg-neutral-200' : '',
@ -297,10 +340,10 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
statusText={
<ChannelLogo
channel={flush([
item?.channels?.phone_number ? 'phone' : null,
item?.channels?.phone_number ? 'waba' : null,
item?.channels?.email ? 'email' : null,
item?.channels?.whatsapp_phone_number ? 'waba' : null,
item?.last_message?.type === 'email' ? 'email' : null,
item?.last_message?.source || null, // wai, WABA, email
])}
/>
}
@ -311,6 +354,7 @@ const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitch
// () => <span key={'tag'} className='self-end>💎💴👑💼🤝💤💔💨🕳🚫🎈🎊🎁📜</span>,
]}
/>
</Spin>
</div>
</Dropdown>
</>

@ -1,20 +1,25 @@
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 { isEmpty, cloneDeep } from '@/utils/commons'
import { fetchJSON } from '@/utils/request'
import { isEmpty, cloneDeep } from '@haina/utils-commons'
import { fetchJSON } from '@haina/utils-request'
import AdvanceSearchForm from '../../../orders/AdvanceSearchForm'
import { API_HOST } from '@/config'
import dayjs from 'dayjs'
import useAuthStore from '@/stores/AuthStore'
import { fetchEmailBindOrderAction } from '@/actions/EmailActions'
const fetchOrderList = async (params) => {
const fetchMyOrderList = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getdvancedwlorder`, params)
return errcode !== 0 ? [] : result
}
const fetchHTOrderList = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/query_order`, params)
return errcode !== 0 ? [] : result
}
export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSuccess, ...props }) => {
export const EmailBindFormModal = ({ mai_sn, conversationid, userId, coliID, onBoundSuccess, ...props }) => {
const [form] = Form.useForm()
const [open, setOpen] = useState(false)
const { userId: loginUserId } = useAuthStore((state) => state.loginUser)
@ -28,7 +33,7 @@ export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSucc
const copyObject = cloneDeep(values)
delete copyObject.type
const allEmpty = Object.values(copyObject).every((val) => {
return val === null || val === '' || val === undefined
return val === null || String(val).trim() === '' || val === undefined
})
if (allEmpty) {
notification.warning({
@ -39,12 +44,14 @@ export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSucc
})
return false
}
values.opisn = loginUserId
// values.opisn = loginUserId
values.sourcetype = values.is_biz ? '227002' : '227001'
delete values.is_biz
setLoading(false)
setSearchLoading(true)
setSearchResult([])
const result = await fetchOrderList(values)
setSearchResult(result)
const result = await fetchHTOrderList(values)
setSearchResult(result.map(ele => ({...ele, sourcetype: values.sourcetype})))
setSearchLoading(false)
}
@ -76,40 +83,58 @@ export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSucc
width: 222,
},
{
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: 'OperatorName',
dataIndex: 'OperatorName',
width: 100,
},
{
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: '分配时间',
key: 'COLI_AssignDate',
dataIndex: 'COLI_AssignDate',
width: 100,
},
{
title: '附加信息',
ellipsis: true,
key: 'COLI_Introduction',
dataIndex: 'COLI_Introduction',
title: '天数',
key: 'COLI_Days',
dataIndex: 'COLI_Days',
width: 100,
},
// {
// title: '',
// key: 'coli_guest',
// dataIndex: 'coli_guest',
// render: (text, record) => {
// let regularText = ''
// if (record.buytime > 0) regularText = '(R' + record.buytime + ')'
// return text + regularText
// },
// },
// {
// title: '',
// key: 'COLI_OrderStartDate',
// dataIndex: 'COLI_OrderStartDate',
// width: 120,
// hidden: false,
// sortDirections: ['ascend', 'descend'],
// sorter: (a, b) => {
// const datejsA = isEmpty(a.COLI_OrderStartDate) ? 0 : new dayjs(a.COLI_OrderStartDate).valueOf()
// const datejsB = isEmpty(b.COLI_OrderStartDate) ? 0 : new dayjs(b.COLI_OrderStartDate).valueOf()
// return datejsA - datejsB
// },
// },
// {
// title: '',
// ellipsis: true,
// key: 'COLI_Introduction',
// dataIndex: 'COLI_Introduction',
// },
{
title: '',
key: 'action',
width: 150,
render: (_, record) => (
<Button type={'text'} className='text-primary' onClick={() => handleBindOrder({ coli_sn: record.COLI_SN, coli_id: record.COLI_ID })}>
<Button type={'text'} className='text-primary' onClick={() => handleBindOrder({ coli_sn: record.COLI_SN, coli_id: record.COLI_ID, sourcetype: record.sourcetype })}>
关联此订单
</Button>
),
@ -131,9 +156,31 @@ export const EmailBindFormModal = ({ mai_sn, conversationid, userId, onBoundSucc
onCancel={() => {
setOpen(false)
}}
destroyOnClose>
<AdvanceSearchForm onSubmit={onSearchOrder} loading={searchLoading} />
<Table key={'advanceOrderTable'} loading={loading} dataSource={searchResult} columns={searchResultColumns} pagination={searchResult.length <= 10 ? false : paginationProps} />
destroyOnHidden>
{/* <AdvanceSearchForm onSubmit={onSearchOrder} loading={searchLoading} /> */}
<Form
layout={'inline'}
form={form}
initialValues={{
orderLabel: '',
orderStatus: '',
remindState: '',
// ...initialValues,
}}
onFinish={onSearchOrder}>
<Form.Item label='订单号' name='coli_id' initialValue={coliID} rules={[{ required: true, message: '请输入订单号' }]}>
<Input placeholder='订单号' allowClear />
</Form.Item>
<Form.Item name='is_biz' className='' valuePropName='checked'>
<Checkbox>商务订单</Checkbox>
</Form.Item>
<div style={{ flex: '0 1 64px' }} className='flex justify-between'>
<Button type='primary' htmlType='submit' loading={searchLoading}>
搜索
</Button>
</div>
</Form>
<Table key={'advanceOrderTable'} loading={loading} dataSource={searchResult} columns={searchResultColumns} pagination={searchResult.length <= 10 ? false : paginationProps} rowKey={'COLI_SN'} />
</Modal>
</>
)

@ -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 allow-popups-to-escape-sandbox allow-forms allow-top-navigation'
/>
</div>
</div>
)
}
export default EmailContent

@ -2,18 +2,21 @@ import { useState, useEffect } from 'react'
import { App, Button, Divider, Avatar } from 'antd'
import { LoadingOutlined, ApiOutlined } from '@ant-design/icons';
import { EditIcon, ReplyIcon, ResendIcon, ShareForwardIcon } from '@/components/Icons'
import { isEmpty, TagColorStyle } from '@/utils/commons'
import { isEmpty, TagColorStyle } from '@haina/utils-commons'
import EmailEditorPopup from '../Input/EmailEditorPopup'
import DnDModal from '@/components/DndModal'
import DnDModal from '@/components/DnDModal'
import useStyleStore from '@/stores/StyleStore'
import { useEmailDetail, } from '@/hooks/useEmail';
import { EMAIL_ATTA_HOST } from '@/config';
import EmailBindFormModal from './EmailBind';
import EmailDetailInline from './EmailDetailInline';
import EmailContent from './EmailContent';
/**
* @property {*} emailMsg - 邮件数据. { conversationid, actionId, order_opi, coli_sn, msgOrigin: { from, to, id, email: { subject, mai_sn, } } }
* @property {*} disabled - 是否禁用操作: 回复, 转发
*/
const EmailDetail = ({ open, setOpen, emailMsg={}, ...props }) => {
const EmailDetail = ({ open, setOpen, emailMsg={}, disabled=false, ...props }) => {
// console.log('emailDetail', emailMsg);
@ -22,16 +25,16 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, ...props }) => {
const { conversationid, actionId, order_opi, coli_sn } = emailMsg
const { mai_sn, id } = emailMsg.msgOrigin?.email || emailMsg.msgOrigin || {}
const mailID = mai_sn || id
const [initialPosition, setInitialPosition] = useState({})
const [initialSize, setInitialSize] = useState({})
// const [initialPosition, setInitialPosition] = useState({})
// const [initialSize, setInitialSize] = useState({})
function onHandleMove(e) {
const { top, left, width, height } = e
setInitialPosition({ top, left })
props?.setInitialPosition({ top, left })
}
function onHandleResize(e) {
const { top, left, width, height } = e
setInitialPosition({ top, left })
setInitialSize({ width, height })
props?.setInitialPosition({ top, left })
props?.setInitialSize({ width, height })
}
const [action, setAction] = useState('')
@ -48,7 +51,7 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, ...props }) => {
const [mobile] = useStyleStore((state) => [state.mobile])
const { loading, mailData, postEmailResend } = useEmailDetail(mailID)
const { loading, mailData, orderDetail, postEmailResend } = useEmailDetail(mailID)
const [showBindBtn, setShowBindBtn] = useState(false);
useEffect(() => {
setShowBindBtn(isEmpty(mailData.info?.MAI_COLI_SN))
@ -77,6 +80,7 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, ...props }) => {
* * 已保存: []
* * 已发送: 回复, 转发
* * 失败: 重发
* todo: disabled 不显示
*/
const ActionBtns = ({className, ...props}) => {
const { status } = mailData.info
@ -139,76 +143,28 @@ const EmailDetail = ({ open, setOpen, emailMsg={}, ...props }) => {
{mailData.info?.MAI_Subject || emailMsg?.msgOrigin?.email?.subject}
</>
}
initial={{ top: 74, left: 324 }}
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}
onResize={onHandleResize}
footer={<ActionBtns className='w-full !justify-start' />}>
<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'>收件人:</span>
{mailData.info?.MAI_To}
</div>
{mailData.info?.cc && (
<div className='text-sm'>
<span className='text-neutral-500 pr-2'>抄送:</span>
{mailData.info.cc}
</div>
)}
{mailData.info?.bcc && (
<div className='text-sm'>
<span className='text-neutral-500 pr-2'>密送:</span>
{mailData.info.bcc}
</div>
)}
{mailData.attachments.length > 0 && (
<div className='mt-2 *:ml-2'>
<span>{mailData.attachments.length}个附件</span>
<div className='flex flex-wrap gap-2'>
{mailData.attachments.map((atta) => (
<a href={`${EMAIL_ATTA_HOST}${atta.ATI_ServerFile}`} key={atta.ATI_SN} target='_blank' rel='noreferrer'>
{atta.ATI_Name}
</a>
))}
</div>
</div>
)}
<Divider className='my-2' />
<div className='mt-2 whitespace-pre-wrap' dangerouslySetInnerHTML={{ __html: mailData.content }}></div>
</div>
</div>
<EmailDetailInline { ...{ mailData, emailMsg, loading, mailID } } />
</DnDModal>
<EmailEditorPopup
{/* <EmailEditorPopup
open={openEmailEditor}
setOpen={setOpenEmailEditor}
fromEmail={fromEmail}
fromUser={mailData.info?.MAI_OPI_SN || order_opi}
fromOrder={mailData.info?.MAI_COLI_SN || coli_sn}
conversationid={conversationid}
oid={orderDetail.order_no}
customerDetail={orderDetail.customerDetail}
// emailMsg={ReferEmailMsg}
quoteid={mailID}
initial={{ ...initialPosition, ...initialSize }}
initial={{ ...props.initialPosition, ...props.initialSize }}
mailData={mailData}
action={action}
key={`email-detail-inner-${action}-popup_${mailID}`}
/>
key={`email-detail-inner-${action}_${mailID}`}
/> */}
</>
)
}

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

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

@ -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 '@haina/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

@ -5,7 +5,7 @@ import { useOrderStore } from '@/stores/OrderStore'
import useAuthStore from '@/stores/AuthStore'
import useConversationStore from '@/stores/ConversationStore'
import { getEmailQuotationDraftAction } from '@/actions/EmailActions'
import { isEmpty } from '@/utils/commons'
import { isEmpty } from '@haina/utils-commons'
const EmailQuotation = ({ sfi_sn, ...props }) => {
const {notification} = App.useApp()
@ -69,7 +69,7 @@ const EmailQuotation = ({ sfi_sn, ...props }) => {
邮件
</Button>
<EmailEditorPopup
{/* <EmailEditorPopup
open={editorOpen}
setOpen={setEditorOpen}
fromEmail={pickEmail.key}
@ -83,9 +83,10 @@ const EmailQuotation = ({ sfi_sn, ...props }) => {
// initial={{ ...initialPosition, ...initialSize }}
// mailData={mailData}
draft={draft}
// customerDetail={customerDetail}
action={'new'}
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

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

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

Loading…
Cancel
Save