From 4bd11c0ecca09038b3db37aabcb400e41b7273c5 Mon Sep 17 00:00:00 2001 From: Liang XJ Date: Tue, 27 Jan 2026 19:03:46 +0800 Subject: [PATCH] feat: add search_image and product_list content types with image upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 ### 1. 新增消息类型 - 添加 search_image (17) 和 product_list (18) content_type - 支持图片搜索和商品列表消息展示 ### 2. 图片上传功能 - 添加 ImageUploadButton 组件,支持图片上传到 Mall API - 上传后发送 search_image 类型消息,图片 URL 存储在 content_attributes - 支持图片文件验证(类型、大小) ### 3. Webhook 推送优化 - 修改 webhook_listener.rb,允许 search_image 类型即使 content 为空也推送 webhook - 解决 search_image 消息不触发 webhook 的问题 ### 4. 前端组件 - 新增 SearchImage.vue 组件(widget 和 dashboard) - 新增 ProductList.vue、Logistics.vue、OrderDetail.vue、OrderList.vue 组件 - 更新 Message.vue 路由逻辑支持新的 content_type - 更新 UserMessage.vue 支持 search_image 消息显示 ### 5. API 层修改 - widget/messages_controller.rb: 允许 content_attributes 参数 - widget/base_controller.rb: 使用前端传入的 content_attributes - widget/conversation API: 支持 contentAttributes 参数传递 - conversation actions 和 helpers: 完整的 content_attributes 数据流 ### 6. Widget 测试页面优化 - 重写 /widget_tests 页面,支持游客/用户模式切换 - 登录流程:使用 reset() + setUser(),不刷新页面 - 退出流程:使用 reset() + 刷新页面 - 添加详细的日志输出和状态显示 ## 技术细节 ### Message Model ```ruby enum content_type: { # ...existing types... search_image: 17, product_list: 18 } ``` ### Webhook Listener ```ruby # Allow search_image webhook even if content is blank return if message.content.blank? && message.content_type != 'search_image' ``` ### Widget Upload Flow ``` 用户选择图片 → 上传到 Mall API → 获取图片 URL → 发送消息: { content: '', content_type: 'search_image', content_attributes: { url: '...' } } ``` Co-Authored-By: Claude Sonnet 4.5 --- CHATWOOT_INITIALIZATION.md | 249 ++++++++ TEST_SEARCH_IMAGE.md | 118 ++++ TEST_WEBHOOK_SEARCH_IMAGE.md | 104 ++++ TROUBLESHOOTING_WEBHOOK.md | 234 ++++++++ WEBHOOK_TROUBLESHOOTING.md | 291 ++++++++++ WIDGET_TESTS_GUIDE.md | 200 +++++++ .../api/v1/widget/base_controller.rb | 5 +- .../api/v1/widget/conversations_controller.rb | 7 +- .../api/v1/widget/messages_controller.rb | 2 +- app/controllers/widget_tests_controller.rb | 2 +- .../components-next/message/Message.vue | 16 +- .../message/bubbles/ProductList.vue | 156 +++++ .../message/bubbles/SearchImage.vue | 37 ++ .../components-next/message/constants.js | 2 + .../shared/components/Logistics.vue | 52 +- .../shared/components/OrderDetail.vue | 29 +- .../shared/components/OrderList.vue | 8 +- .../shared/components/ProductList.vue | 141 +++++ .../shared/components/SearchImage.vue | 47 ++ app/javascript/widget/App.vue | 13 +- app/javascript/widget/api/conversation.js | 7 +- app/javascript/widget/api/endPoints.js | 24 +- .../assets/scss/views/_conversation.scss | 2 +- .../widget/components/AgentMessage.vue | 6 +- .../widget/components/AgentMessageBubble.vue | 13 +- .../widget/components/AgentTypingBubble.vue | 2 +- .../widget/components/ChatInputWrap.vue | 31 +- .../widget/components/ImageUploadButton.vue | 185 ++++++ .../widget/components/MessageReplyButton.vue | 1 + .../widget/components/UserMessage.vue | 14 + .../store/modules/conversation/actions.js | 15 +- .../store/modules/conversation/helpers.js | 4 +- app/listeners/webhook_listener.rb | 6 + app/models/message.rb | 4 +- .../v1/widget/messages/create.json.jbuilder | 1 + app/views/widget_tests/index.html.erb | 536 +++++++++++++++--- app/views/widgets/show.html.erb | 1 + check_webhook.rb | 79 +++ debug_webhook.rb | 128 +++++ test_search_image_console.rb | 69 +++ test_search_image_message.rb | 108 ++++ test_search_image_webhook.sh | 42 ++ test_webhook_manual.rb | 112 ++++ 43 files changed, 2979 insertions(+), 124 deletions(-) create mode 100644 CHATWOOT_INITIALIZATION.md create mode 100644 TEST_SEARCH_IMAGE.md create mode 100644 TEST_WEBHOOK_SEARCH_IMAGE.md create mode 100644 TROUBLESHOOTING_WEBHOOK.md create mode 100644 WEBHOOK_TROUBLESHOOTING.md create mode 100644 WIDGET_TESTS_GUIDE.md create mode 100644 app/javascript/dashboard/components-next/message/bubbles/ProductList.vue create mode 100644 app/javascript/dashboard/components-next/message/bubbles/SearchImage.vue create mode 100644 app/javascript/shared/components/ProductList.vue create mode 100644 app/javascript/shared/components/SearchImage.vue create mode 100644 app/javascript/widget/components/ImageUploadButton.vue create mode 100644 check_webhook.rb create mode 100644 debug_webhook.rb create mode 100644 test_search_image_console.rb create mode 100755 test_search_image_message.rb create mode 100755 test_search_image_webhook.sh create mode 100644 test_webhook_manual.rb diff --git a/CHATWOOT_INITIALIZATION.md b/CHATWOOT_INITIALIZATION.md new file mode 100644 index 0000000..0736861 --- /dev/null +++ b/CHATWOOT_INITIALIZATION.md @@ -0,0 +1,249 @@ +# Chatwoot Widget 初始化流程说明 + +## 🔍 当前的初始化流程 + +### 1. 页面首次加载(游客模式) + +**服务器端 (Ruby ERB):** +```erb +<% + token_present = cookies[:token].present? # false + test_user_id = nil # 没有用户 ID + test_user_hash = nil +%> +``` + +**前端 JavaScript:** +```javascript +// 服务器端渲染后 +const TEST_USER = { + userId: '', // 空字符串 + userHash: '', // 空字符串 + ... +}; + +const token = getCookie('token'); // null +if (token) { + // 不执行 +} + +const widgetConfig = { + websiteToken: 'xxx', + baseUrl: '', + locale: 'zh_CN', + useBrowserLanguage: false + // 没有 userIdentifier +}; + +window.chatwootSDK.run(widgetConfig); +// ✅ Widget 初始化成功(游客模式) +``` + +### 2. 点击登录(不刷新) + +```javascript +function simulateLogin() { + setCookie('token', TEST_TOKEN); // 设置 cookie + isLoggedIn = true; + currentUser = TEST_USER; + + // 调用 setUser 设置用户信息 + window.$chatwoot.setUser(currentUser.userId, { + identifier_hash: currentUser.userHash, + email: currentUser.email, + name: currentUser.name, + ... + }); + // ✅ 用户信息已设置 + // ⚠️ 但 widget 仍然是游客模式初始化的! +} +``` + +### 3. 刷新页面(登录后) + +**服务器端 (Ruby ERB):** +```erb +<% + token_present = cookies[:token].present? # ✅ true + test_user_id = '211845' # ✅ 有用户 ID + test_user_hash = OpenSSL::HMAC.hexdigest(...) +%> +``` + +**前端 JavaScript:** +```javascript +// 服务器端渲染后 +const TEST_USER = { + userId: '211845', # ✅ 正确的用户 ID + userHash: 'xxx...', # ✅ 正确的 hash + ... +}; + +const token = getCookie('token'); // ✅ 存在 +if (token) { + const widgetConfig = { + websiteToken: 'xxx', + baseUrl: '', + locale: 'zh_CN', + useBrowserLanguage: false, + userIdentifier: '211845' # ✅ 设置用户标识 + }; + + window.chatwootSDK.run(widgetConfig); + // ❌ 问题:window.$chatwoot 已经存在! + // ❌ SDK 内部会 return,不会重新初始化! +} +``` + +## ⚠️ 核心问题 + +**Chatwoot SDK 只能初始化一次!** + +```javascript +// app/javascript/entrypoints/sdk.js:21-24 +runSDK({ baseUrl, websiteToken }) { + if (window.$chatwoot) { + return; // ❌ 已经初始化过,直接返回 + } + // ... 初始化代码 +} +``` + +## 💡 解决方案 + +### 方案 1:登录时强制刷新(推荐) + +```javascript +function simulateLogin() { + setCookie('token', TEST_TOKEN); + isLoggedIn = true; + currentUser = TEST_USER; + + // 立即刷新页面,让 SDK 正确初始化 + addLog('💡 即将刷新页面以应用登录状态...'); + setTimeout(() => { + location.reload(); + }, 500); +} +``` + +**优点:** +- 简单可靠 +- SDK 会正确初始化 +- userIdentifier 在初始化时就设置 + +**缺点:** +- 需要刷新页面 +- 用户体验略差 + +### 方案 2:在登录前先 reset + +```javascript +function simulateLogin() { + // 先清除旧的 SDK 实例 + if (window.$chatwoot && window.$chatwoot.reset) { + window.$chatwoot.reset(); + } + + // 等待 reset 完成 + setTimeout(() => { + setCookie('token', TEST_TOKEN); + isLoggedIn = true; + currentUser = TEST_USER; + + // 重新初始化 + const widgetConfig = { + websiteToken: '<%= @web_widget.website_token %>', + baseUrl: '', + locale: 'zh_CN', + useBrowserLanguage: false, + userIdentifier: currentUser.userId + }; + + window.chatwootSDK.run(widgetConfig); + // ... + }, 100); +} +``` + +**问题:** +- SDK 已经初始化检查在 `run()` 函数内部 +- 无法绕过这个检查 + +### 方案 3:使用 iframe 的方式(不推荐) + +每次都创建新的 iframe,但这样会失去状态同步。 + +## ✅ 推荐的实现 + +**登录时自动刷新页面:** + +```javascript +function simulateLogin() { + addLog('🔐 开始模拟用户登录...'); + + // 设置 token cookie + setCookie('token', TEST_TOKEN); + + // 验证设置成功 + const tokenCheck = getCookie('token'); + if (tokenCheck) { + addLog('✅ Token 已设置'); + addLog('⏳ 即将刷新页面以完成登录...'); + + // 显示倒计时 + let countdown = 2; + const countdownInterval = setInterval(() => { + countdown--; + if (countdown <= 0) { + clearInterval(countdownInterval); + location.reload(); + } + }, 1000); + } else { + addLog('❌ Token 设置失败'); + } +} +``` + +## 📊 完整流程 + +### 游客 → 登录: +``` +1. 游客模式访问 +2. 点击登录 +3. 设置 token +4. 自动刷新(2秒倒计时) +5. Ruby 检测到 token,设置 user_id = '211845' +6. SDK 初始化(带 userIdentifier) +7. ✅ 登录成功 +``` + +### 登录 → 退出: +``` +1. 点击退出 +2. 删除 token +3. 调用 reset() 清除所有数据 +4. ✅ 立即切换到游客模式 +``` + +### 退出 → 登录: +``` +1. 游客模式 +2. 点击登录 +3. 设置 token +4. 自动刷新 +5. ✅ 恢复登录状态(新会话) +``` + +## 🎯 总结 + +**Chatwoot SDK 的设计限制:** +- SDK 只能初始化一次 +- 初始化后无法动态更改 userIdentifier +- 必须刷新页面才能重新初始化 + +**最佳实践:** +- **登录/退出时都刷新页面** +- 服务器端检查 token 来决定初始状态 +- 确保每次刷新都能正确初始化 diff --git a/TEST_SEARCH_IMAGE.md b/TEST_SEARCH_IMAGE.md new file mode 100644 index 0000000..8c21819 --- /dev/null +++ b/TEST_SEARCH_IMAGE.md @@ -0,0 +1,118 @@ +# 测试 search_image 消息发送 + +## 方法 1: 使用 Rails Console(推荐) + +在你的终端中执行: + +```bash +cd /home/lxj/project/chatwoot-develop + +# 确保已初始化 rbenv +eval "$(rbenv init -)" + +# 启动 Rails console +bundle exec rails c +``` + +然后在 Rails console 中复制粘贴以下代码: + +```ruby +# 配置参数 +ACCOUNT_ID = 2 +INBOX_ID = 1 +USER_IDENTIFIER = '211845' + +# 1. 查找联系人 +contact = Contact.find_by(identifier: USER_IDENTIFIER) + +if contact.nil? + puts "❌ 联系人不存在,请先通过 widget 创建会话" +else + puts "✅ 联系人: #{contact.name} (ID: #{contact.id})" + + # 2. 获取会话 + conversation = Conversation.where( + contact_id: contact.id, + inbox_id: INBOX_ID + ).order(created_at: :desc).first + + if conversation.nil? + puts "❌ 会话不存在" + else + puts "✅ 会话: #{conversation.display_id}" + + # 3. 创建 search_image 消息 + test_image_url = "https://img.gaia888.com/image/www/test_#{SecureRandom.uuid}.jpg" + + message = Message.create!( + account_id: ACCOUNT_ID, + conversation_id: conversation.id, + inbox_id: INBOX_ID, + sender: contact, + sender_type: 'Contact', + sender_id: contact.id, + message_type: :incoming, + content_type: :search_image, + content: '', + content_attributes: { + url: test_image_url + }, + status: :sent + ) + + puts "\n✅ 消息创建成功!" + puts " Message ID: #{message.id}" + puts " content_type: #{message.content_type}" + puts " URL: #{test_image_url}" + end +end +``` + +## 方法 2: 使用 Rails Runner + +```bash +cd /home/lxj/project/chatwoot-develop +eval "$(rbenv init -)" +bundle exec rails runner test_search_image_console.rb +``` + +## 预期结果 + +成功后会看到: +``` +✅ 联系人: 测试用户 211845 (ID: 24) +✅ 会话: 16 + +✅ 消息创建成功! + Message ID: 427 + content_type: search_image + URL: https://img.gaia888.com/image/www/test/... +``` + +## 验证消息 + +1. **Dashboard 查看**: + - 访问: http://localhost:3000/app/accounts/2/inbox/1/conversations/16 + - 应该看到新发送的图片消息 + +2. **Webhook 日志**: + - 查看 webhook 接收到的数据 + - 应该看到 content_type: "search_image" + +3. **Widget 查看**: + - 刷新 /widget_tests 页面 + - 应该看到图片显示在聊天界面 + +## 错误排查 + +### 错误: 联系人不存在 +**原因**: 还没有通过 widget 创建过会话 +**解决**: 先访问 /widget_tests 页面并发送一条消息 + +### 错误: 会话不存在 +**原因**: 联系人没有在此 inbox 中的会话 +**解决**: 先通过 widget 发送消息创建会话 + +### 错误: content_type 未知 +**原因**: 数据库没有 search_image 枚举值 +**解决**: 运行迁移添加 search_image 到 content_type 枚举 diff --git a/TEST_WEBHOOK_SEARCH_IMAGE.md b/TEST_WEBHOOK_SEARCH_IMAGE.md new file mode 100644 index 0000000..28a392c --- /dev/null +++ b/TEST_WEBHOOK_SEARCH_IMAGE.md @@ -0,0 +1,104 @@ +# 测试 search_image 消息发送到 Webhook + +## 方法 1: 使用测试脚本(推荐) + +```bash +cd /home/lxj/project/chatwoot-develop +./test_search_image_webhook.sh +``` + +## 方法 2: 直接执行 curl 命令 + +复制以下命令到终端执行: + +```bash +curl -X POST "http://localhost:3000/api/v1/widget/messages?website_token=9n9D3JFHBorFTZLD7cQ49TMg&cw_conversation=eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M&locale=zh_CN" \ + -H "Content-Type: application/json" \ + -H "X-Auth-Token: eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M" \ + -d '{ + "message": { + "content": "", + "content_type": "search_image", + "content_attributes": { + "url": "https://img.gaia888.com/image/www/test.jpg" + }, + "timestamp": "Tue Jan 27 2026 17:41:08 GMT+0800 (中国标准时间)", + "referer_url": "http://localhost:3000/widget_tests" + } + }' +``` + +## 方法 3: 使用你提供的完整 curl 命令 + +```bash +curl "http://localhost:3000/api/v1/widget/messages?website_token=9n9D3JFHBorFTZLD7cQ49TMg&cw_conversation=eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M&locale=zh_CN" \ + -H "Accept: application/json, text/plain, */*" \ + -H "Content-Type: application/json" \ + -H "X-Auth-Token: eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M" \ + --data-raw '{"message":{"content":"","reply_to":null,"timestamp":"Tue Jan 27 2026 17:41:08 GMT+0800 (中国标准时间)","referer_url":"http://localhost:3000/widget_tests","content_type":"search_image","content_attributes":{"url":"https://img.gaia888.com/image/www/auto_202601/test.jpg"}}}' +``` + +## 预期响应 + +成功时会返回类似: + +```json +{ + "id": 428, + "content": "", + "content_type": "search_image", + "inbox_id": 1, + "conversation_id": 16, + "message_type": 0, + "created_at": 1769504268, + "private": false, + "content_attributes": { + "url": "https://img.gaia888.com/image/www/test.jpg" + }, + "sender": { + "id": 24, + "name": "测试用户 211845", + "email": "211845@example.com", + "thumbnail": "", + "type": "contact" + } +} +``` + +## 验证步骤 + +1. **检查 Dashboard**: + - 访问: http://localhost:3000/app/accounts/2/inbox/1 + - 查看会话列表中是否有新消息 + +2. **检查 Webhook 日志**: + - 查看你的 webhook 接收服务日志 + - 应该看到 `content_type: "search_image"` + +3. **检查 Widget**: + - 刷新 /widget_tests 页面 + - 应该看到图片显示在聊天界面 + +## 错误排查 + +### 401 Unauthorized +**原因**: cw_conversation token 过期或无效 +**解决**: 从 /widget_tests 页面重新获取 cookie 中的 cw_conversation 值 + +### 404 Not Found +**原因**: website_token 不正确 +**解决**: 确认 website_token 与配置一致 + +### 422 Unprocessable Entity +**原因**: 请求参数格式错误 +**解决**: 检查 JSON 格式和必需字段 + +## 参数说明 + +| 参数 | 值 | 说明 | +|------|-----|------| +| `content_type` | `"search_image"` | 消息类型 | +| `content` | `""` | 空字符串 | +| `content_attributes.url` | 图片 URL | 图片地址 | +| `cw_conversation` | JWT token | 会话标识 | +| `website_token` | Widget token | 网站标识 | diff --git a/TROUBLESHOOTING_WEBHOOK.md b/TROUBLESHOOTING_WEBHOOK.md new file mode 100644 index 0000000..9bc52c0 --- /dev/null +++ b/TROUBLESHOOTING_WEBHOOK.md @@ -0,0 +1,234 @@ +# Webhook 不触发的排查步骤 + +## 问题原因 + +Webhook 不触发通常有以下几种原因: + +1. ❌ 没有配置 Webhook +2. ❌ Webhook 未启用(active: false) +3. ❌ 没有订阅 `message_created` 事件 +4. ❌ Webhook URL 不正确或无法访问 + +--- + +## 检查步骤 + +### 方法 1: 运行检查脚本 + +```bash +cd /home/lxj/project/chatwoot-develop + +# 启动 Rails console +bundle exec rails c + +# 加载检查脚本 +load 'check_webhook.rb' +``` + +### 方法 2: 在 Rails Console 中手动检查 + +```bash +bundle exec rails c +``` + +然后执行: + +```ruby +# 1. 检查 Account +account = Account.find(2) +puts account.name + +# 2. 检查 Webhooks +webhooks = account.webhooks +puts "Webhooks 数量: #{webhooks.count}" + +# 3. 查看每个 Webhook 的配置 +webhooks.each do |webhook| + puts "ID: #{webhook.id}" + puts "URL: #{webhook.webhook_url}" + puts "启用: #{webhook.active}" + puts "订阅: #{webhook.subscriptions.inspect}" +end + +# 4. 检查最近的调用日志 +logs = account.webhook_logs.order(created_at: :desc).limit(5) +logs.each do |log| + puts "#{log.event_type} - #{log.status} - #{log.response_code}" +end +``` + +--- + +## 修复方法 + +### 场景 1: 没有配置 Webhook + +```ruby +account = Account.find(2) + +# 创建 Webhook +webhook = account.webhooks.create!( + webhook_url: 'https://your-domain.com/webhook', # 替换为你的 URL + subscriptions: ['conversation_created', 'message_created', 'message_updated'], + active: true +) + +puts "✅ Webhook 已创建 (ID: #{webhook.id})" +``` + +### 场景 2: Webhook 未启用 + +```ruby +webhook = Webhook.find(1) # 替换为实际的 ID +webhook.update!(active: true) +puts "✅ Webhook 已启用" +``` + +### 场景 3: 未订阅 message_created 事件 + +```ruby +webhook = Webhook.find(1) # 替换为实际的 ID + +# 添加 message_created 订阅 +current_subs = webhook.subscriptions +webhook.update!(subscriptions: current_subs + ['message_created']) + +puts "✅ 已添加 message_created 订阅" +``` + +### 场景 4: Webhook URL 无法访问 + +```ruby +# 更新为正确的 URL +webhook = Webhook.find(1) +webhook.update!(webhook_url: 'https://correct-url.com/webhook') +puts "✅ URL 已更新" +``` + +--- + +## 完整配置示例 + +```ruby +# Account 2 的完整 Webhook 配置 +account = Account.find(2) + +# 删除旧的 webhooks(可选) +# account.webhooks.destroy_all + +# 创建新的 Webhook +webhook = account.webhooks.create!( + webhook_url: 'https://your-domain.com/chatwoot/webhook', + subscriptions: [ + 'conversation_created', + 'conversation_status_changed', + 'message_created', + 'message_updated', + 'message_deleted' + ], + active: true, + retry_frequency: 3 # 失败重试次数 +) + +puts "✅ Webhook 配置完成" +puts " ID: #{webhook.id}" +puts " URL: #{webhook.webhook_url}" +puts " 订阅事件: #{webhook.subscriptions}" +``` + +--- + +## 验证 Webhook 是否工作 + +### 方法 1: 检查日志 + +```ruby +# 查看最近的 Webhook 日志 +logs = WebhookLog.order(created_at: :desc).limit(10) + +logs.each do |log| + puts "事件: #{log.event_type}" + puts "状态: #{log.status}" + puts "响应码: #{log.response_code}" + puts "URL: #{log.webhook.webhook_url}" + puts "错误: #{log.error_message}" + puts "时间: #{log.created_at}" + puts "---" +end +``` + +### 方法 2: 重新发送 Webhook + +```ruby +# 找到之前发送的消息 +message = Message.find(455) + +# 手动触发 webhook +WebhookService.new(message.account, message.conversation).trigger_webhook(message, 'message_created') + +puts "✅ Webhook 已重新发送" +``` + +### 方法 3: 使用 curl 发送测试消息 + +```bash +curl -X POST "http://localhost:3000/api/v1/widget/messages?website_token=9n9D3JFHBorFTZLD7cQ49TMg&cw_conversation=xxx&locale=zh_CN" \ + -H "Content-Type: application/json" \ + -d '{"message":{"content":"","content_type":"search_image","content_attributes":{"url":"https://test.jpg"}}}' +``` + +然后立即检查: + +```ruby +# 查看最新日志 +WebhookLog.order(created_at: :desc).first +``` + +--- + +## 常见问题 + +### Q: Dashboard 能看到消息,但 Webhook 不触发? + +**A**: 检查以下几点: +1. Webhook 是否启用(`active: true`) +2. 是否订阅了 `message_created` 事件 +3. Webhook URL 是否可访问 +4. 查看是否有错误日志:`WebhookLog.where(status: 'failed')` + +### Q: 如何调试 Webhook 请求? + +**A**: 使用工具接收 Webhook 请求,例如: +- https://webhook.site(免费的 Webhook 测试工具) +- ngrok(将本地服务器暴露到公网) + +### Q: Webhook 调用失败会重试吗? + +**A**: 会。Chatwoot 会根据 `retry_frequency` 配置自动重试失败的 Webhook。 + +--- + +## 快速解决方案 + +如果你只想快速测试 Webhook,使用 Webhook.site: + +1. 访问 https://webhook.site 获取一个临时 URL +2. 在 Rails console 中配置: + +```ruby +account = Account.find(2) +webhook = account.webhooks.create!( + webhook_url: 'https://webhook.site/your-uuid', # 替换为你的 UUID + subscriptions: ['message_created'], + active: true +) +``` + +3. 发送测试消息 +4. 在 webhook.site 查看是否收到请求 + +--- + +## 下一步 + +运行检查脚本后,告诉我结果,我可以帮你修复具体的问题! diff --git a/WEBHOOK_TROUBLESHOOTING.md b/WEBHOOK_TROUBLESHOOTING.md new file mode 100644 index 0000000..af30821 --- /dev/null +++ b/WEBHOOK_TROUBLESHOOTING.md @@ -0,0 +1,291 @@ +# Webhook 不触发问题排查 + +## 快速检查步骤 + +### 1. 确认服务器已重启 ⚠️ + +**重要**: 修改代码后必须重启服务器! + +```bash +# 如果使用 overmind +overmind restart web + +# 或直接重启 Rails server +# Ctrl+C 然后重新启动 +bundle exec rails server +``` + +### 2. 运行调试脚本 + +```bash +cd /home/lxj/project/chatwoot-develop +bundle exec rails c +# 然后在 console 中执行: +load 'debug_webhook.rb' +``` + +### 3. 检查 Webhook 配置 + +在 Rails console 中执行: + +```ruby +account = Account.find(2) +webhooks = account.webhooks + +# 检查数量 +puts "Webhooks 数量: #{webhooks.count}" + +# 查看配置 +webhooks.each do |w| + puts "ID: #{w.id}" + puts "URL: #{w.webhook_url}" + puts "启用: #{w.active}" + puts "订阅: #{w.subscriptions.inspect}" +end +``` + +**预期结果**: +- `active: true` (必须启用) +- `subscriptions` 包含 `'message_created'` (必须订阅) + +### 4. 手动触发 Webhook 测试 + +```bash +bundle exec rails c +# 然后执行: +load 'test_webhook_manual.rb' +``` + +这会: +- 查找最近的 search_image 消息 +- 手动构建 webhook payload +- 直接触发 webhook 发送 +- 显示结果 + +### 5. 查看 Webhook 日志 + +```ruby +# 最近的日志 +logs = Account.find(2).webhook_logs.order(created_at: :desc).limit(10) + +logs.each do |log| + puts "#{log.created_at} - #{log.event_type} - #{log.status}" + puts "响应码: #{log.response_code}" + puts "错误: #{log.error_message}" if log.error_message + puts "---" +end +``` + +### 6. 检查最近的消息 + +```ruby +# 查找 search_image 消息 +messages = Message.where(content_type: 'search_image').order(created_at: :desc).limit(5) + +messages.each do |msg| + puts "ID: #{msg.id}" + puts "content: '#{msg.content}'" + puts "content_type: #{msg.content_type}" + puts "创建时间: #{msg.created_at}" + puts "---" +end +``` + +--- + +## 常见问题和解决方案 + +### 问题 1: 没有配置 Webhook + +**症状**: `webhooks.count == 0` + +**解决**: + +```ruby +# 创建 Webhook (使用 webhook.site 测试) +account = Account.find(2) + +# 1. 访问 https://webhook.site 获取一个临时 URL +# 2. 创建 Webhook 指向该 URL +account.webhooks.create!( + webhook_url: 'https://webhook.site/your-uuid-here', # 替换为实际 URL + subscriptions: ['conversation_created', 'message_created', 'message_updated'], + active: true +) +``` + +### 问题 2: Webhook 未启用 + +**症状**: `webhook.active == false` + +**解决**: + +```ruby +webhook = Webhook.find(1) +webhook.update!(active: true) +``` + +### 问题 3: 未订阅 message_created 事件 + +**症状**: `webhook.subscriptions` 不包含 `'message_created'` + +**解决**: + +```ruby +webhook = Webhook.find(1) +webhook.update!(subscriptions: ['conversation_created', 'message_created', 'message_updated']) +``` + +### 问题 4: 代码修改未生效 + +**症状**: 代码已修改但行为未改变 + +**解决**: 重启服务器! + +```bash +# 方法 1: overmind +overmind restart web + +# 方法 2: 直接重启 +# Ctrl+C 停止服务器 +bundle exec rails server +``` + +### 问题 5: Webhook URL 无法访问 + +**症状**: 日志显示连接错误 + +**解决**: +1. 检查 URL 是否正确 +2. 确认服务器可以从外网访问 +3. 使用 webhook.site 测试 + +### 问题 6: content 过滤问题 + +**症状**: search_image 消息不触发 webhook + +**验证代码已正确修改**: + +```ruby +# 检查 WebhookListener +File.read('app/listeners/webhook_listener.rb') =~ /search_image/ +# 应该返回匹配结果 + +# 查看具体代码 +puts File.read('app/listeners/webhook_listener.rb')[/def message_created.*?end/m] +``` + +应该看到: + +```ruby +def message_created(event) + message = extract_message_and_account(event)[0] + inbox = message.inbox + + return unless message.webhook_sendable? + + # For search_image type, allow webhook even if content is blank + return if message.content.blank? && message.content_type != 'search_image' + + payload = message.webhook_data.merge(event: __method__.to_s) + deliver_webhook_payloads(payload, inbox) +end +``` + +--- + +## 完整测试流程 + +### 步骤 1: 重启服务器 + +```bash +overmind restart web +# 或 +bundle exec rails server +``` + +### 步骤 2: 确认 Webhook 配置 + +```ruby +# Rails console +account = Account.find(2) +webhooks = account.webhooks + +# 如果没有,创建一个 +if webhooks.count == 0 + account.webhooks.create!( + webhook_url: 'https://webhook.site/your-uuid', + subscriptions: ['message_created'], + active: true + ) +end +``` + +### 步骤 3: 发送测试消息 + +使用 curl: + +```bash +curl -X POST "http://localhost:3000/api/v1/widget/messages?website_token=9n9D3JFHBorFTZLD7cQ49TMg&cw_conversation=xxx&locale=zh_CN" \ + -H "Content-Type: application/json" \ + -d '{"message":{"content":"","content_type":"search_image","content_attributes":{"url":"https://test.jpg"}}}' +``` + +或在 /widget_tests 页面上传图片。 + +### 步骤 4: 立即检查日志 + +```ruby +# 查看最新消息 +message = Message.last +puts "content_type: #{message.content_type}" +puts "content: '#{message.content}'" + +# 查看 webhook 日志 +logs = Account.find(2).webhook_logs.order(created_at: :desc).limit(5) +logs.each { |log| puts "#{log.event_type} - #{log.status}" } +``` + +### 步骤 5: 检查 Webhook 接收 + +- 如果使用 webhook.site,刷新页面查看是否收到请求 +- 查看请求内容是否包含 search_image 数据 + +--- + +## 调试命令汇总 + +```bash +# 1. 重启服务器 +overmind restart web + +# 2. 启动 Rails console +bundle exec rails c + +# 3. 在 console 中依次执行: +load 'debug_webhook.rb' # 全面诊断 +load 'test_webhook_manual.rb' # 手动触发测试 + +# 4. 发送测试消息 +# (另开一个终端) +curl -X POST "http://localhost:3000/api/v1/widget/messages?..." \ + -H "Content-Type: application/json" \ + -d '{"message":{"content":"","content_type":"search_image","content_attributes":{"url":"https://test.jpg"}}}' + +# 5. 检查结果 (在 Rails console 中) +Message.last +Account.find(2).webhook_logs.order(created_at: :desc).first +``` + +--- + +## 预期结果 + +成功的情况下,你应该看到: + +1. ✅ 消息创建成功 +2. ✅ Dashboard 显示消息 +3. ✅ Webhook 日志有新记录 (`status: 'success'` 或 `'failed'`) +4. ✅ Webhook 接收端收到请求 + +如果仍然不工作,请运行 `debug_webhook.rb` 并把输出发给我! diff --git a/WIDGET_TESTS_GUIDE.md b/WIDGET_TESTS_GUIDE.md new file mode 100644 index 0000000..870426b --- /dev/null +++ b/WIDGET_TESTS_GUIDE.md @@ -0,0 +1,200 @@ +# /widget_tests 页面使用指南 + +## 📋 功能说明 + +测试页面用于模拟 Chatwoot Widget 的用户登录/退出流程。 + +## 🔄 新的行为逻辑 + +### 1. 页面初始加载(游客模式) + +**无论 cookie 中是否有 token,都以游客模式启动:** +- ✅ 不清除任何数据 +- ✅ 不设置 userIdentifier +- ✅ 保留现有会话 + +**原因:** 让用户通过手动点击登录按钮来测试登录流程。 + +### 2. 点击"模拟用户登录"(不刷新页面) + +**执行步骤:** +1. 设置 token cookie +2. 调用 `window.$chatwoot.reset()` 清除之前的聊天内容 +3. 调用 `window.$chatwoot.setUser()` 设置用户信息 +4. ✅ **不刷新页面** + +**优点:** +- 用户体验更好,无页面闪烁 +- 切换快速 + +**注意:** +- SDK 仍然以游客模式初始化(没有 userIdentifier) +- 但通过 `setUser()` 设置了用户身份 + +### 3. 点击"用户退出"(刷新页面) + +**执行步骤:** +1. 删除 token cookie +2. 调用 `window.$chatwoot.reset()` 清除所有数据 +3. 清除 localStorage +4. ✅ **刷新页面(2秒倒计时)** + +**原因:** +- 确保完全清除用户状态 +- 让 SDK 重新以游客模式初始化 + +## 🎯 完整流程示例 + +### 场景 1:游客 → 登录 → 退出 + +``` +1. 访问页面 + → 游客模式启动 + → 可以发送游客消息 + +2. 点击"模拟用户登录" + → reset() 清除聊天 + → setUser() 设置用户 211845 + → 不刷新页面 + → 可以发送已登录用户消息 + +3. 点击"用户退出" + → reset() 清除数据 + → 刷新页面 + → 回到游客模式 +``` + +### 场景 2:刷新页面(有 token) + +``` +1. 已登录状态(token 存在) +2. 刷新页面 +3. → 仍然以游客模式启动 +4. → 需要重新点击登录按钮 +``` + +**重要:** 页面加载时不会自动检查 token 并设置用户,必须手动点击登录按钮。 + +## 🔍 关键代码 + +### 登录函数(不刷新) + +```javascript +function simulateLogin() { + // 1. 设置 token + setCookie('token', TEST_TOKEN); + + // 2. 清除之前的聊天 + window.$chatwoot.reset(); + + // 3. 设置用户信息 + setTimeout(() => { + window.$chatwoot.setUser('211845', { + identifier_hash: userHash, + email: '211845@example.com', + name: '测试用户 211845', + custom_attributes: { + jwt_token: token, + login_status: 'logged_in' + } + }); + }, 500); +} +``` + +### 退出函数(刷新页面) + +```javascript +function simulateLogout() { + // 1. 删除 token + deleteCookie('token'); + + // 2. 清除数据 + window.$chatwoot.reset(); + + // 3. 清除 localStorage + + // 4. 刷新页面(2秒倒计时) + location.reload(); +} +``` + +## 🎨 界面说明 + +### 状态卡片 + +- **游客模式**:黄色边框 +- **已登录**:绿色边框 + +显示内容: +- User ID +- Token 状态 +- 登录状态 + +### 操作按钮 + +- **🔐 模拟用户登录**:设置用户并清除聊天 +- **🚪 用户退出**:清除数据并刷新页面 +- **🔄 刷新页面**:手动刷新 +- **💬 打开聊天**:打开 Widget 聊天窗口 +- **🗑️ 清除所有数据**:完全重置并刷新页面 + +### 日志输出 + +实时显示所有操作日志,包括: +- SDK 加载状态 +- Token 设置/删除 +- 用户信息设置 +- 会话清除操作 + +## ⚠️ 注意事项 + +1. **页面加载不自动登录**:即使有 token,也需要手动点击登录按钮 +2. **登录不刷新页面**:使用 `reset()` + `setUser()` 实现 +3. **退出会刷新页面**:确保完全清除状态 +4. **会话数据**:`reset()` 会清除 `cw_conversation` cookie,退出后会创建新会话 + +## 🧪 测试建议 + +### 测试 1:游客模式 + +1. 访问页面 +2. 发送一条消息 +3. 验证:消息以游客身份发送 + +### 测试 2:登录流程 + +1. 点击"模拟用户登录" +2. 观察日志:reset() → setUser() +3. 发送一条消息 +4. 验证:消息以用户 211845 身份发送 + +### 测试 3:退出流程 + +1. 在已登录状态 +2. 点击"用户退出" +3. 观察倒计时和刷新 +4. 验证:回到游客模式 + +### 测试 4:会话隔离 + +1. 游客模式发送消息 +2. 登录(reset 清除) +3. 发送消息 +4. 退出(刷新) +5. 验证:之前的消息已被清除 + +## 📊 与之前实现的对比 + +| 操作 | 之前的行为 | 现在的行为 | +|------|----------|----------| +| 页面加载(有 token) | 自动设置 userIdentifier | 游客模式,不做处理 | +| 点击登录 | 刷新页面(2秒倒计时) | reset() + setUser()(不刷新) | +| 点击退出 | reset()(不刷新) | reset() + 刷新页面(2秒倒计时) | + +## 🎯 设计理念 + +**新实现的目标:** +- 更好的用户体验(登录不刷新) +- 更清晰的状态管理(退出刷新确保清理) +- 手动控制登录流程(便于测试) diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 5b87e2d..41423e6 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -79,9 +79,8 @@ class Api::V1::Widget::BaseController < ApplicationController sender: @contact, content: permitted_params[:message][:content], inbox_id: conversation.inbox_id, - content_attributes: { - in_reply_to: permitted_params[:message][:reply_to] - }, + content_type: permitted_params[:message][:content_type], + content_attributes: permitted_params[:message][:content_attributes] || { in_reply_to: permitted_params[:message][:reply_to] }, echo_id: permitted_params[:message][:echo_id], message_type: :incoming } diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index fe5facc..ed1385d 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -10,7 +10,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController ActiveRecord::Base.transaction do process_update_contact @conversation = create_conversation - conversation.messages.create!(message_params) + Rails.logger.info "Widget API - message_params: #{message_params.inspect}" + Rails.logger.info "Widget API - content_type: #{message_params[:message][:content_type]}" + message = conversation.messages.create!(message_params) + Rails.logger.info "Widget API - Created message content_type: #{message.content_type}" # TODO: Temporary fix for message type cast issue, since message_type is returning as string instead of integer conversation.reload end @@ -87,7 +90,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController def permitted_params params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], - message: [:content, :referer_url, :timestamp, :echo_id], + message: [:content, :content_type, :referer_url, :timestamp, :echo_id], custom_attributes: {}) end end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a51b4c2..843f47c 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -64,7 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def permitted_params # timestamp parameter is used in create conversation method - params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) + params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :content_type, :referer_url, :timestamp, :echo_id, :reply_to, content_attributes: {}]) end def set_message diff --git a/app/controllers/widget_tests_controller.rb b/app/controllers/widget_tests_controller.rb index caa2c69..96bd6ed 100644 --- a/app/controllers/widget_tests_controller.rb +++ b/app/controllers/widget_tests_controller.rb @@ -28,7 +28,7 @@ class WidgetTestsController < ActionController::Base end def inbox_id - @inbox_id ||= params[:inbox_id] || Channel::WebWidget.find(2).inbox.id + @inbox_id ||= params[:inbox_id] || Channel::WebWidget.first&.inbox&.id end def ensure_web_widget diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 44f6229..b347fb6 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -42,6 +42,8 @@ import TableBubble from './bubbles/Table.vue'; import OrderListBubble from './bubbles/OrderList.vue'; import OrderDetailBubble from './bubbles/OrderDetail.vue'; import LogisticsBubble from './bubbles/Logistics.vue'; +import ProductListBubble from './bubbles/ProductList.vue'; +import SearchImageBubble from './bubbles/SearchImage.vue'; import MessageError from './MessageError.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; @@ -308,6 +310,14 @@ const componentToRender = computed(() => { return LogisticsBubble; } + if (props.contentType === CONTENT_TYPES.PRODUCT_LIST) { + return ProductListBubble; + } + + if (props.contentType === CONTENT_TYPES.SEARCH_IMAGE) { + return SearchImageBubble; + } + if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) { return EmailBubble; } @@ -404,6 +414,8 @@ const shouldRenderMessage = computed(() => { const isOrderList = props.contentType === CONTENT_TYPES.ORDER_LIST; const isOrderDetail = props.contentType === CONTENT_TYPES.ORDER_DETAIL; const isLogistics = props.contentType === CONTENT_TYPES.LOGISTICS; + const isProductList = props.contentType === CONTENT_TYPES.PRODUCT_LIST; + const isSearchImage = props.contentType === CONTENT_TYPES.SEARCH_IMAGE; return ( hasAttachments || @@ -414,7 +426,9 @@ const shouldRenderMessage = computed(() => { isDataTable || isOrderList || isOrderDetail || - isLogistics + isLogistics || + isProductList || + isSearchImage ); }); diff --git a/app/javascript/dashboard/components-next/message/bubbles/ProductList.vue b/app/javascript/dashboard/components-next/message/bubbles/ProductList.vue new file mode 100644 index 0000000..181c9f6 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/bubbles/ProductList.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/message/bubbles/SearchImage.vue b/app/javascript/dashboard/components-next/message/bubbles/SearchImage.vue new file mode 100644 index 0000000..26578f3 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/bubbles/SearchImage.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js index b6f4ba8..73dd529 100644 --- a/app/javascript/dashboard/components-next/message/constants.js +++ b/app/javascript/dashboard/components-next/message/constants.js @@ -72,6 +72,8 @@ export const CONTENT_TYPES = { ORDER_LIST: 'order_list', ORDER_DETAIL: 'order_detail', LOGISTICS: 'logistics', + PRODUCT_LIST: 'product_list', + SEARCH_IMAGE: 'search_image', }; export const MEDIA_TYPES = [ diff --git a/app/javascript/shared/components/Logistics.vue b/app/javascript/shared/components/Logistics.vue index b1928d2..f6dddc4 100644 --- a/app/javascript/shared/components/Logistics.vue +++ b/app/javascript/shared/components/Logistics.vue @@ -20,12 +20,9 @@
-
-
@@ -70,10 +67,12 @@ :key="index" @click="onActionClick(action)" class="flex-1 py-3 text-sm transition-colors" - :class="{ - 'text-n-slate-11 hover:bg-n-slate-2 dark:hover:bg-n-solid-2 border-r border-n-weak': action.style === 'default', - 'text-n-blue-10 font-semibold hover:bg-n-blue-1 dark:hover:bg-n-solid-blue': action.style === 'primary' - }" + :class="[ + action.style === 'primary' + ? 'text-n-blue-10 font-semibold hover:bg-n-blue-1 dark:hover:bg-n-solid-blue' + : 'text-n-slate-11 hover:bg-n-slate-2 dark:hover:bg-n-solid-2', + index < actions.length - 1 ? 'border-r border-n-weak' : '' + ]" > {{ action.text }} @@ -112,8 +111,10 @@ const currentStatusText = computed(() => { }); const progressWidth = computed(() => { + // 限制 currentStep 在有效范围内 + const validStep = Math.min(props.currentStep, props.steps.length - 1); // 计算进度条长度:(当前步骤 / 总步数间隔) * 100% - return `${(props.currentStep / (props.steps.length - 1)) * 100}%`; + return `${(validStep / (props.steps.length - 1)) * 100}%`; }); const statusBadgeClass = computed(() => { @@ -128,14 +129,23 @@ const copyTrackingNumber = () => { }; const onActionClick = (action) => { - // 如果有 URL,在父窗口中打开 + // 如果有 URL,根据 target 属性决定打开方式 if (action && action.url) { + const target = action.target || '_self'; // 默认在当前窗口打开 if (window.parent !== window) { - // 在 iframe 中,直接设置父窗口 location - window.parent.location.href = action.url; + // 在 iframe 中 + if (target === '_blank') { + window.parent.open(action.url, '_blank'); + } else { + window.parent.location.href = action.url; + } } else { - // 不在 iframe 中,直接使用当前窗口 - window.location.href = action.url; + // 不在 iframe 中 + if (target === '_blank') { + window.open(action.url, '_blank'); + } else { + window.location.href = action.url; + } } return; } @@ -151,5 +161,15 @@ const onActionClick = (action) => { diff --git a/app/javascript/shared/components/OrderDetail.vue b/app/javascript/shared/components/OrderDetail.vue index 883ecf1..3bedfc5 100644 --- a/app/javascript/shared/components/OrderDetail.vue +++ b/app/javascript/shared/components/OrderDetail.vue @@ -61,14 +61,23 @@ export default { return color; }, onActionClick(action) { - // 如果有 URL,在父窗口中打开 + // 如果有 URL,根据 target 属性决定打开方式 if (action && action.url) { + const target = action.target || '_self'; // 默认在当前窗口打开 if (window.parent !== window) { - // 在 iframe 中,直接设置父窗口 location - window.parent.location.href = action.url; + // 在 iframe 中 + if (target === '_blank') { + window.parent.open(action.url, '_blank'); + } else { + window.parent.location.href = action.url; + } } else { - // 不在 iframe 中,直接使用当前窗口 - window.location.href = action.url; + // 不在 iframe 中 + if (target === '_blank') { + window.open(action.url, '_blank'); + } else { + window.location.href = action.url; + } } return; } @@ -141,10 +150,12 @@ export default { :key="index" @click="onActionClick(action)" class="flex-1 py-3 text-sm transition-colors" - :class="{ - 'text-n-slate-11 hover:bg-n-slate-2 dark:hover:bg-n-solid-2 border-r border-n-weak': action.style === 'default' || !action.style, - 'text-n-blue-10 font-semibold hover:bg-n-blue-1 dark:hover:bg-n-solid-blue': action.style === 'primary' - }" + :class="[ + action.style === 'primary' + ? 'text-n-blue-10 font-semibold hover:bg-n-blue-1 dark:hover:bg-n-solid-blue' + : 'text-n-slate-11 hover:bg-n-slate-2 dark:hover:bg-n-solid-2', + index < actions.length - 1 ? 'border-r border-n-weak' : '' + ]" > {{ action.text }} diff --git a/app/javascript/shared/components/OrderList.vue b/app/javascript/shared/components/OrderList.vue index 9c6f74b..eb4cd39 100644 --- a/app/javascript/shared/components/OrderList.vue +++ b/app/javascript/shared/components/OrderList.vue @@ -114,8 +114,8 @@ export default { .status-badge { display: inline-block; - background-color: #f0f0f0; - color: #888; + background-color: #000000; + color: #ffffff; padding: 4px 8px; border-radius: 4px; font-size: 11px; @@ -124,8 +124,8 @@ export default { } .dark .status-badge { - background-color: #374151; - color: #d1d5db; + background-color: #000000; + color: #ffffff; } .product-list { diff --git a/app/javascript/shared/components/ProductList.vue b/app/javascript/shared/components/ProductList.vue new file mode 100644 index 0000000..00ebe14 --- /dev/null +++ b/app/javascript/shared/components/ProductList.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/app/javascript/shared/components/SearchImage.vue b/app/javascript/shared/components/SearchImage.vue new file mode 100644 index 0000000..a43d6fe --- /dev/null +++ b/app/javascript/shared/components/SearchImage.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index 6b9d605..f1c8757 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -375,9 +375,20 @@ export default { if (token) { console.log('[Widget] Found token in cookie:', token.substring(0, 20) + '...'); + // 从 JWT token 中提取用户 ID + let userId = '123'; // 默认值 + try { + const payload = JSON.parse(atob(token.split('.')[1])); + userId = payload.user_id || payload.sub || '123'; + } catch (e) { + console.warn('[Widget] Failed to decode token, using default userId:', e.message); + } + + console.log('[Widget] Extracted userId:', userId); + // 调用 setUser API 保存到 contact this.$store.dispatch('contacts/setUser', { - identifier: token, + identifier: userId, user: { custom_attributes: { jwt_token: token, diff --git a/app/javascript/widget/api/conversation.js b/app/javascript/widget/api/conversation.js index 1060b28..0490d1d 100755 --- a/app/javascript/widget/api/conversation.js +++ b/app/javascript/widget/api/conversation.js @@ -6,8 +6,11 @@ const createConversationAPI = async content => { return API.post(urlData.url, urlData.params); }; -const sendMessageAPI = async (content, replyTo = null) => { - const urlData = endPoints.sendMessage(content, replyTo); +const sendMessageAPI = async (content, replyTo = null, contentType = null, contentAttributes = null) => { + const urlData = endPoints.sendMessage(content, replyTo, contentType, contentAttributes); + console.log('[sendMessageAPI] Sending to:', urlData.url, 'with params:', urlData.params); + console.log('[sendMessageAPI] contentType:', contentType); + console.log('[sendMessageAPI] contentAttributes:', contentAttributes); return API.post(urlData.url, urlData.params); }; diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js index b595fdf..855507f 100755 --- a/app/javascript/widget/api/endPoints.js +++ b/app/javascript/widget/api/endPoints.js @@ -22,18 +22,28 @@ const createConversation = params => { }; }; -const sendMessage = (content, replyTo) => { +const sendMessage = (content, replyTo, contentType = null, contentAttributes = null) => { const referrerURL = window.referrerURL || ''; const search = buildSearchParamsWithLocale(window.location.search); + const messageParams = { + content, + reply_to: replyTo, + timestamp: new Date().toString(), + referer_url: referrerURL, + }; + + if (contentType) { + messageParams.content_type = contentType; + } + + if (contentAttributes) { + messageParams.content_attributes = contentAttributes; + } + return { url: `/api/v1/widget/messages${search}`, params: { - message: { - content, - reply_to: replyTo, - timestamp: new Date().toString(), - referer_url: referrerURL, - }, + message: messageParams, }, }; }; diff --git a/app/javascript/widget/assets/scss/views/_conversation.scss b/app/javascript/widget/assets/scss/views/_conversation.scss index 76fb1c1..84a25fe 100644 --- a/app/javascript/widget/assets/scss/views/_conversation.scss +++ b/app/javascript/widget/assets/scss/views/_conversation.scss @@ -4,7 +4,7 @@ .conversation-wrap { .agent-message { - @apply items-end flex flex-row justify-start mt-0 ltr:mr-0 rtl:mr-2 mb-0.5 ltr:ml-2 rtl:ml-0 max-w-[88%]; + @apply items-end flex flex-row justify-start mt-0 ltr:mr-0 rtl:mr-2 mb-0.5 ltr:ml-2 rtl:ml-0 max-w-[98%]; .avatar-wrap { @apply flex-shrink-0 h-6 w-6; diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue index d3d2181..3a3cc47 100755 --- a/app/javascript/widget/components/AgentMessage.vue +++ b/app/javascript/widget/components/AgentMessage.vue @@ -53,8 +53,8 @@ export default { ) { return false; } - // Table, order_list, order_detail and logistics messages can have empty content, so check content_type as well - if (this.contentType === 'data_table' || this.contentType === 'order_list' || this.contentType === 'order_detail' || this.contentType === 'logistics') { + // Table, order_list, order_detail, logistics and product_list messages can have empty content, so check content_type as well + if (this.contentType === 'data_table' || this.contentType === 'order_list' || this.contentType === 'order_detail' || this.contentType === 'logistics' || this.contentType === 'product_list') { return true; } return this.message.content; @@ -168,7 +168,7 @@ export default { }" >
-
+