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

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

View File

@@ -0,0 +1,156 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
const { contentAttributes } = useMessageContext();
const title = computed(() => contentAttributes.value?.title || '');
const products = computed(() => contentAttributes.value?.products || []);
const actions = computed(() => contentAttributes.value?.actions || []);
const productCount = computed(() => products.value.length);
</script>
<template>
<div class="product-list-message">
<div v-if="title" class="product-list-title">
<h4>{{ title }}</h4>
</div>
<div class="product-grid">
<div
v-for="(product, index) in products"
:key="index"
class="product-item"
>
<div class="product-image">
<img
:src="product.image"
:alt="product.name"
class="w-full h-full object-cover"
/>
</div>
<div class="product-info">
<h5 class="product-name">{{ product.name }}</h5>
<p class="product-price">{{ product.price }}</p>
<p v-if="product.url" class="product-url text-xs text-n-blue-9">
<span class="text-xs">🔗 </span>{{ product.url }}
</p>
</div>
</div>
</div>
<div v-if="actions && actions.length > 0" class="product-actions">
<span
v-for="(action, index) in actions"
:key="index"
class="product-action"
>
{{ action.text }}{{ index < actions.length - 1 ? ' · ' : '' }}
</span>
</div>
</div>
</template>
<style scoped>
.product-list-message {
max-width: 400px;
}
.product-list-title {
padding: 12px 16px 4px;
}
.product-list-title h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.dark .product-list-title h4 {
color: #f1f5f9;
}
.product-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 0 16px 12px 16px;
}
.product-item {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
background: white;
}
.dark .product-item {
border-color: #374151;
background: #1e293b;
}
.product-image {
aspect-ratio: 4/5;
width: 100%;
overflow: hidden;
background: #f9fafb;
}
.dark .product-image {
background: #374151;
}
.product-info {
padding: 8px;
}
.product-name {
margin: 0 0 4px 0;
font-size: 12px;
font-weight: 500;
color: #374151;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.dark .product-name {
color: #e5e7eb;
}
.product-price {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #dc2626;
}
.product-url {
margin: 4px 0 0 0;
word-break: break-all;
opacity: 0.8;
}
.product-actions {
padding: 8px 16px 12px;
font-size: 12px;
color: #64748b;
border-top: 1px solid #e5e7eb;
}
.dark .product-actions {
color: #94a3b8;
border-top-color: #374151;
}
.product-action {
cursor: pointer;
}
.product-action:hover {
color: #2563eb;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="search-image-bubble">
<div v-if="!imageUrl" class="p-4 text-n-slate-11 text-sm">
No image URL
</div>
<div v-else class="inline-block">
<img
:src="imageUrl"
alt="Search Image"
class="max-w-md rounded-lg shadow-sm"
@error="handleImageError"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
const { contentAttributes, content } = useMessageContext();
const imageUrl = computed(() => {
return contentAttributes.value?.url || content.value || '';
});
const handleImageError = (event) => {
console.error('[SearchImageBubble] Image failed to load:', imageUrl.value);
};
</script>
<style scoped>
.search-image-bubble img {
max-height: 400px;
width: auto;
}
</style>

View File

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