feat: add search_image and product_list content types with image upload
Some checks failed
Lock Threads / action (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled

## 新增功能

### 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 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-27 19:03:46 +08:00
parent 092fb2e083
commit 4bd11c0ecc
43 changed files with 2979 additions and 124 deletions

View File

@@ -1,5 +1,6 @@
json.id @message.id
json.content @message.content
json.content_type @message.content_type
json.inbox_id @message.inbox_id
json.conversation_id @message.conversation.display_id
json.message_type @message.message_type_before_type_cast

View File

@@ -1,18 +1,222 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.test-panel {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.test-panel h1 {
margin-top: 0;
color: #333;
font-size: 24px;
}
.status-card {
background: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.status-card.visitor {
border-left-color: #ffc107;
}
.status-card h3 {
margin-top: 0;
font-size: 16px;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #e9ecef;
}
.status-item:last-child {
border-bottom: none;
}
.status-label {
font-weight: 600;
color: #495057;
}
.status-value {
color: #6c757d;
font-family: monospace;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #28a745;
color: white;
}
.btn-primary:hover {
background: #218838;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
}
.log-section {
margin-top: 30px;
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 300px;
overflow-y: auto;
}
.log-section h3 {
color: #4ec9b0;
margin-top: 0;
font-size: 14px;
}
.hidden {
display: none;
}
</style>
<body>
<div class="test-panel">
<h1>🧪 Chatwoot Widget 测试页面</h1>
<p style="color: #666; margin-bottom: 20px;">
<strong>测试流程:</strong><br>
1. 页面加载时以游客模式启动(不做任何处理)<br>
2. 点击"模拟用户登录":使用 reset() 清除聊天 + setUser() 设置用户(不刷新页面)<br>
3. 点击"用户退出":使用 reset() 清除内容并刷新页面
</p>
<div id="statusCard" class="status-card visitor">
<h3 id="statusTitle">👤 当前状态:游客模式</h3>
<div class="status-item">
<span class="status-label">User ID:</span>
<span class="status-value" id="displayUserId">无</span>
</div>
<div class="status-item">
<span class="status-label">Token:</span>
<span class="status-value" id="displayToken">不存在</span>
</div>
<div class="status-item">
<span class="status-label">登录状态:</span>
<span class="status-value" id="displayLoginStatus">未登录</span>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" id="loginBtn" onclick="simulateLogin()">🔐 模拟用户登录</button>
<button class="btn btn-danger hidden" id="logoutBtn" onclick="simulateLogout()">🚪 用户退出</button>
<button class="btn btn-secondary" onclick="refreshPage()">🔄 刷新页面</button>
<button class="btn btn-secondary" onclick="openWidget()">💬 打开聊天</button>
<button class="btn btn-secondary" onclick="clearAllData()">🗑️ 清除所有数据</button>
<button class="btn btn-warning hidden" id="cancelRefreshBtn" onclick="cancelRefresh()">❌ 取消刷新</button>
</div>
<div id="refreshNotice" class="hidden" style="margin-top: 15px; padding: 10px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<strong>⏳ 页面将在 <span id="countdown">2</span> 秒后自动刷新...</strong>
</div>
<div class="log-section">
<h3>📋 控制台日志</h3>
<div id="logOutput">等待操作...</div>
</div>
</div>
</body>
<%
# 使用动态的 user identifier生成对应的 hash
user_id = '123'
user_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
user_id.to_s
)
# 测试用的用户信息
# 检查 cookie 中是否有 token
token_present = cookies[:token].present?
if token_present
test_user_id = '211845'
test_user_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
test_user_id.to_s
)
else
test_user_id = nil
test_user_hash = nil
end
%>
<script>
// 测试用的 JWT Token
const TEST_TOKEN = 'eyJ0eXAiOiJqd3QifQ.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cDpcL1wvOiIsImV4cCI6MTc3MjAwNTk1MSwiaWF0IjoxNzY5NDEzOTUxLCJuYmYiOjE3Njk0MTM5NTEsInVzZXJJZCI6MjExODQ1LCJ0eXBlIjoyLCJ0ZW5hbnRJZCI6MiwidWlkIjoyMTE4NDUsInMiOiI4VG5wd2QiLCJqdGkiOiI5MjFlYzhhMDE2Yjk3MTA5OTI1MjgxMjUzZWQ1MzBkYSJ9.ql7ianUz3Scn_y0JbMXjeq56BVhjpQqt2_vrdq0kUL4';
// 测试用户信息
const TEST_USER = {
userId: '<%= test_user_id %>',
userHash: '<%= test_user_hash %>',
email: '211845@example.com',
name: '测试用户 211845'
};
// 当前状态
let isLoggedIn = false;
let currentUser = null;
let refreshTimer = null;
let countdownTimer = null;
let hadUserIdentifier = false; // 记录之前是否有过用户标识
// Helper function to get cookie value by name
function getCookie(name) {
const value = `; ${document.cookie}`;
@@ -21,22 +225,64 @@ function getCookie(name) {
return null;
}
// Helper function to set cookie
function setCookie(name, value, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = name + '=' + value + '; expires=' + expires + '; path=/';
}
// Helper function to delete cookie
function deleteCookie(name) {
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/';
}
// 日志输出
function addLog(message) {
const logOutput = document.getElementById('logOutput');
const timestamp = new Date().toLocaleTimeString();
logOutput.innerHTML += `<div>[${timestamp}] ${message}</div>`;
logOutput.scrollTop = logOutput.scrollHeight;
console.log(message);
}
// 更新状态显示
function updateStatusDisplay() {
const token = getCookie('token');
const userId = currentUser ? currentUser.userId : null;
document.getElementById('displayUserId').textContent = userId || '无';
document.getElementById('displayToken').textContent = token ? '存在' : '不存在';
document.getElementById('displayLoginStatus').textContent = isLoggedIn ? '已登录' : '未登录';
const statusCard = document.getElementById('statusCard');
const statusTitle = document.getElementById('statusTitle');
const loginBtn = document.getElementById('loginBtn');
const logoutBtn = document.getElementById('logoutBtn');
if (isLoggedIn) {
statusCard.className = 'status-card';
statusTitle.textContent = '✅ 当前状态:已登录';
loginBtn.classList.add('hidden');
logoutBtn.classList.remove('hidden');
} else {
statusCard.className = 'status-card visitor';
statusTitle.textContent = '👤 当前状态:游客模式';
loginBtn.classList.remove('hidden');
logoutBtn.classList.add('hidden');
}
}
// 初始化 Chatwoot Widget
window.chatwootSettings = {
hideMessageBubble: false,
// showUnreadMessagesDialog: false,
// baseDomain: '.loca.lt',
position: '<%= @widget_position %>',
locale: 'zh_CN',
useBrowserLanguage: false,
type: '<%= @widget_type %>',
// showPopoutButton: true,
widgetStyle: '<%= @widget_style %>',
darkMode: '<%= @dark_mode %>',
};
// User ID for identification (simple string, not JWT token)
const userId = '<%= user_id %>';
(function(d,t) {
var BASE_URL = '';
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
@@ -44,10 +290,20 @@ const userId = '<%= user_id %>';
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
// Get token from cookie (for custom attributes only)
const token = getCookie('token');
addLog('✅ Chatwoot SDK 加载完成');
// Initialize config with simple userId as userIdentifier
// 检查当前登录状态(仅用于显示)
const token = getCookie('token');
addLog('🔍 检查登录状态...');
addLog(' - Token 存在: ' + (token ? '是' : '否'));
addLog(' - userId (服务器): ' + ('<%= test_user_id %>' || '无'));
// 无论是否有 token都以游客模式启动
// 用户信息通过点击登录按钮来设置
addLog('👤 游客模式启动(不做任何处理)');
hadUserIdentifier = false;
// 游客模式:不清除任何数据,保留现有会话
const widgetConfig = {
websiteToken: '<%= @web_widget.website_token %>',
baseUrl: BASE_URL,
@@ -55,68 +311,220 @@ const userId = '<%= user_id %>';
useBrowserLanguage: false
};
// Add userIdentifier (use simple userId, not JWT token)
if (userId) {
widgetConfig.userIdentifier = userId;
}
addLog('📝 初始化 widgetConfig (游客模式):');
addLog(' - websiteToken: ' + widgetConfig.websiteToken);
addLog(' - userIdentifier: (未设置)');
window.chatwootSDK.run(widgetConfig);
console.log('✅ Chatwoot Widget 已加载');
console.log('User ID:', userId);
console.log('Token from cookie:', token || 'Not set');
// Wait for widget to load, then set user attributes
setTimeout(function() {
if (userId && window.$chatwoot && window.$chatwoot.setUser) {
window.$chatwoot.setUser(userId, {
identifier_hash: '<%= user_hash %>',
email: 'user@example.com',
name: 'Token User',
phone_number: '',
custom_attributes: token ? {
jwt_token: token,
mall_token: token
} : {}
});
console.log('✅ 已通过 setUser 设置用户属性userId:', userId);
} else if (token && window.$chatwoot && window.$chatwoot.setCustomAttributes) {
// Fallback: use setCustomAttributes
window.$chatwoot.setCustomAttributes({
jwt_token: token,
mall_token: token
});
console.log('✅ 已通过 setCustomAttributes 设置用户属性');
}
}, 1000);
}
addLog('✅ 游客模式已启动');
updateStatusDisplay();
};
})(document,"script");
window.addEventListener('chatwoot:ready', function() {
console.log('chatwoot:ready', window.$chatwoot);
})
// 模拟用户登录
function simulateLogin() {
addLog('🔐 开始模拟用户登录...');
window.addEventListener('chatwoot:error', function(e) {
console.log('chatwoot:error', e.detail)
// 设置 token cookie
setCookie('token', TEST_TOKEN);
// 验证 token 是否设置成功
const tokenCheck = getCookie('token');
if (tokenCheck) {
addLog('✅ Token 已设置到 cookie');
// 设置当前用户状态
isLoggedIn = true;
currentUser = TEST_USER;
addLog('🔄 使用 reset() 清除之前的聊天内容...');
if (window.$chatwoot && window.$chatwoot.reset) {
window.$chatwoot.reset();
addLog('✅ 已清除聊天内容和会话数据');
} else {
addLog('⚠️ Chatwoot reset() 方法不可用');
}
// 等待 reset 完成后设置用户信息
setTimeout(function() {
addLog('👤 设置用户信息...');
if (window.$chatwoot && window.$chatwoot.setUser) {
window.$chatwoot.setUser(currentUser.userId, {
identifier_hash: currentUser.userHash,
email: currentUser.email,
name: currentUser.name,
phone_number: '',
custom_attributes: {
jwt_token: tokenCheck,
mall_token: tokenCheck,
login_status: 'logged_in'
}
});
addLog('✅ 用户信息已设置: ' + currentUser.name);
addLog('✅ 登录成功(无需刷新页面)');
} else {
addLog('⚠️ window.$chatwoot 未就绪');
}
updateStatusDisplay();
}, 500);
} else {
addLog('❌ Token 设置失败');
}
}
// 模拟用户退出
function simulateLogout() {
addLog('🚪 开始模拟用户退出...');
// 删除 token cookie
deleteCookie('token');
isLoggedIn = false;
currentUser = null;
addLog('✅ Token 已从 cookie 删除');
// 使用 Chatwoot 官方的 reset() 方法清除用户和会话数据
if (window.$chatwoot && window.$chatwoot.reset) {
window.$chatwoot.reset();
addLog('✅ 已调用 Chatwoot reset() - 清除用户和会话数据');
} else {
addLog('⚠️ Chatwoot reset() 方法不可用');
}
// 清除 localStorage 缓存
try {
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('chatwoot') || key.includes('cw_') || key.includes('widget'))) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
addLog('🧹 已清除 ' + keysToRemove.length + ' 个 localStorage 缓存项');
} catch (e) {
addLog('⚠️ 清除缓存时出错: ' + e.message);
}
addLog('⏳ 即将刷新页面以完成退出...');
// 显示刷新倒计时
document.getElementById('refreshNotice').classList.remove('hidden');
document.getElementById('cancelRefreshBtn').classList.remove('hidden');
document.getElementById('countdown').textContent = '2';
let countdown = 2;
countdownTimer = setInterval(() => {
countdown--;
document.getElementById('countdown').textContent = countdown;
if (countdown <= 0) {
clearInterval(countdownTimer);
addLog('🔄 正在刷新页面...');
location.reload();
}
}, 1000);
refreshTimer = setTimeout(() => {
location.reload();
}, 2000);
}
// 取消刷新
function cancelRefresh() {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
document.getElementById('refreshNotice').classList.add('hidden');
document.getElementById('cancelRefreshBtn').classList.add('hidden');
addLog('✅ 已取消自动刷新');
addLog('⚠️ 注意Chatwoot Widget 仍保持登录状态,需要手动刷新页面才能完全恢复游客模式');
}
// 清除所有数据(包括会话)
function clearAllData() {
addLog('🗑️ 开始清除所有数据...');
// 使用 Chatwoot reset() 方法清除会话
if (window.$chatwoot && window.$chatwoot.reset) {
window.$chatwoot.reset();
addLog('✅ 已调用 Chatwoot reset() - 清除会话和用户数据');
}
// 删除 token cookie
deleteCookie('token');
addLog('✅ Token 已删除');
// 清除 localStorage
try {
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('chatwoot') || key.includes('cw_') || key.includes('widget'))) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
addLog('🧹 已清除 ' + keysToRemove.length + ' 个 localStorage 项');
} catch (e) {
addLog('⚠️ 清除 localStorage 时出错: ' + e.message);
}
isLoggedIn = false;
currentUser = null;
hadUserIdentifier = false;
updateStatusDisplay();
addLog('✅ 所有数据已清除');
// 刷新页面
addLog('⏳ 即将刷新页面...');
setTimeout(() => {
location.reload();
}, 500);
}
// 刷新页面
function refreshPage() {
addLog('🔄 正在刷新页面...');
setTimeout(() => {
location.reload();
}, 500);
}
// 打开聊天窗口
function openWidget() {
if (window.$chatwoot && window.$chatwoot.toggle) {
window.$chatwoot.toggle('open');
addLog('💬 聊天窗口已打开');
} else {
addLog('⚠️ Chatwoot 未就绪');
}
}
// 事件监听
window.addEventListener('chatwoot:ready', function() {
addLog('✅ Chatwoot Widget 已就绪');
})
window.addEventListener('chatwoot:on-message', function(e) {
console.log('chatwoot:on-message', e.detail)
})
window.addEventListener('chatwoot:postback', function(e) {
console.log('chatwoot:postback', e.detail)
addLog('📨 收到消息');
})
window.addEventListener('chatwoot:opened', function() {
console.log('chatwoot:opened')
addLog('🔓 Widget 已打开');
})
window.addEventListener('chatwoot:closed', function() {
console.log('chatwoot:closed')
addLog('🔒 Widget 已关闭');
})
window.addEventListener('chatwoot:on-start-conversation', function(e) {
console.log('chatwoot:on-start-conversation', e.detail)
})
// 初始化
addLog('🚀 测试页面已加载');
</script>

View File

@@ -28,6 +28,7 @@
timezone: '<%= @web_widget.inbox.timezone %>',
allowMessagesAfterResolved: <%= @web_widget.inbox.allow_messages_after_resolved %>,
disableBranding: <%= @web_widget.inbox.account.feature_enabled?('disable_branding') %>,
mallUrl: '<%= ENV.fetch('MALL_URL', 'https://apicn.qa1.gaia888.com') %>',
}
window.chatwootPubsubToken = '<%= @contact_inbox.pubsub_token %>'
window.authToken = '<%= @token %>'