Commit e52639b8 by zhangxingmin

p

parent 76387c42
...@@ -14,9 +14,6 @@ export function randList(data) { ...@@ -14,9 +14,6 @@ export function randList(data) {
/** /**
* 查询输出流信息(流式响应) * 查询输出流信息(流式响应)
* @param {string} question 用户问题
* @param {number} timeout 超时时间(毫秒),默认 300000
* @returns {Promise<Response>} fetch 响应对象
*/ */
export function getStream(question, timeout = 300000) { export function getStream(question, timeout = 300000) {
const userStore = useUserStore() const userStore = useUserStore()
...@@ -31,12 +28,9 @@ export function getStream(question, timeout = 300000) { ...@@ -31,12 +28,9 @@ export function getStream(question, timeout = 300000) {
headers['X-Tenant-ID'] = tenantId headers['X-Tenant-ID'] = tenantId
} }
// 从环境变量获取基础 URL
const baseUrl = import.meta.env.VITE_APP_BASE_API const baseUrl = import.meta.env.VITE_APP_BASE_API
// 拼接完整 URL(注意:后端实际路径为 /ai/api/ai/stream)
const url = `${baseUrl}/ai/api/api/ai/stream?question=${encodeURIComponent(question)}` const url = `${baseUrl}/ai/api/api/ai/stream?question=${encodeURIComponent(question)}`
// 使用 AbortController 实现超时控制
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout) const timeoutId = setTimeout(() => controller.abort(), timeout)
...@@ -47,4 +41,49 @@ export function getStream(question, timeout = 300000) { ...@@ -47,4 +41,49 @@ export function getStream(question, timeout = 300000) {
}).finally(() => { }).finally(() => {
clearTimeout(timeoutId) clearTimeout(timeoutId)
}) })
}
/**
* 获取服务卡片列表(产品列表)
*/
export async function getServiceCardList() {
const userStore = useUserStore()
const token = getToken()
// 根据实际 store 获取 userId,如果不存在则使用默认值(示例中为 '30')
const userId = userStore.userInfo?.userId || userStore.userId || '30'
// 处理 token 格式(去掉 "Bearer " 前缀)
let cleanToken = token
if (token && token.startsWith('Bearer ')) {
cleanToken = token.slice(7)
}
const headers = {
'Content-Type': 'application/json',
'x-authorization': `sfpfamilyfinancialplanning ${cleanToken}`
}
const url = 'https://hoservice.ydhomeoffice.cn/hoserviceApi/ydServiceCard/serviceCardList'
const body = {
userId: String(userId),
cardType: '11'
}
try {
const response = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('获取服务卡片列表失败:', error)
throw error
}
} }
\ No newline at end of file
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
</div> </div>
</div> </div>
<div class="chat-messages" ref="messagesContainer"> <div class="chat-messages" ref="messagesContainer" @click="handleProductClick">
<div v-for="(msg, idx) in messages" :key="idx" :class="['message', msg.role]"> <div v-for="(msg, idx) in messages" :key="idx" :class="['message', msg.role]">
<div class="message-avatar"> <div class="message-avatar">
<div v-if="msg.role === 'user'" class="user-avatar">U</div> <div v-if="msg.role === 'user'" class="user-avatar">U</div>
...@@ -30,7 +30,9 @@ ...@@ -30,7 +30,9 @@
</svg> </svg>
</div> </div>
</div> </div>
<div class="message-content" v-html="formatMessage(msg.content)"></div> <!-- 产品列表消息直接渲染HTML,其他消息使用markdown -->
<div class="message-content" v-if="msg.isProductList" v-html="msg.content"></div>
<div class="message-content" v-else v-html="formatMessage(msg.content)"></div>
</div> </div>
<div v-if="isLoading" class="message assistant"> <div v-if="isLoading" class="message assistant">
<div class="message-avatar"> <div class="message-avatar">
...@@ -106,11 +108,14 @@ ...@@ -106,11 +108,14 @@
</template> </template>
<script> <script>
import { randList, getStream } from '@/api/ai/ai' import { randList, getStream, getServiceCardList } from '@/api/ai/ai'
import { marked } from 'marked' import { marked } from 'marked'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css' import 'highlight.js/styles/github-dark.css'
// 购买关键词正则(可根据需要调整)
const PURCHASE_KEYWORDS = /购买|产品|商品|套餐|服务|有哪些产品|商品列表|购买服务|推荐产品|产品列表|选购|下单/i
// 配置 marked,增强安全性 // 配置 marked,增强安全性
marked.setOptions({ marked.setOptions({
highlight: function(code, lang) { highlight: function(code, lang) {
...@@ -121,8 +126,8 @@ marked.setOptions({ ...@@ -121,8 +126,8 @@ marked.setOptions({
}, },
breaks: true, breaks: true,
gfm: true, gfm: true,
mangle: false, // 禁用 HTML 实体编码,防止 XSS mangle: false,
headerIds: false, // 不自动生成标题 id headerIds: false,
}) })
export default { export default {
...@@ -144,7 +149,6 @@ export default { ...@@ -144,7 +149,6 @@ export default {
this.loadSuggestions() this.loadSuggestions()
}, },
methods: { methods: {
// 滚动到底部
scrollToBottom() { scrollToBottom() {
this.$nextTick(() => { this.$nextTick(() => {
const container = this.$refs.messagesContainer const container = this.$refs.messagesContainer
...@@ -154,7 +158,6 @@ export default { ...@@ -154,7 +158,6 @@ export default {
}) })
}, },
// 加载随机词条
async loadSuggestions(randNum = 4) { async loadSuggestions(randNum = 4) {
this.suggestionsLoading = true this.suggestionsLoading = true
try { try {
...@@ -176,14 +179,12 @@ export default { ...@@ -176,14 +179,12 @@ export default {
} }
}, },
// 换一批
refreshSuggestions() { refreshSuggestions() {
if (!this.suggestionsLoading) { if (!this.suggestionsLoading) {
this.loadSuggestions(4) this.loadSuggestions(4)
} }
}, },
// 点击词条
handleSuggestionClick(content) { handleSuggestionClick(content) {
if (this.isLoading) return if (this.isLoading) return
if (content && content.trim()) { if (content && content.trim()) {
...@@ -192,25 +193,130 @@ export default { ...@@ -192,25 +193,130 @@ export default {
} }
}, },
// 格式化消息(Markdown 转 HTML)
formatMessage(content) { formatMessage(content) {
if (!content) return '' if (!content) return ''
// 对用户消息内容进行额外转义(防止恶意 HTML)
// 但 marked 已做安全处理,此步可省略,保留原样
return marked.parse(content) return marked.parse(content)
}, },
// 生成产品列表HTML
buildProductListHtml(productData) {
if (!productData || !productData.list || !productData.list.length) {
return '<div class="product-list-empty">暂无产品信息</div>'
}
let html = '<div class="product-list">'
productData.list.forEach(card => {
// 获取卡片主图(取第一个子项的图片)
const mainImage = card.list && card.list[0]?.itemImg || ''
// 获取卡片标识(用于购买跳转)
const cardCode = card.list && card.list[0]?.cardCode || ''
const cardName = card.cardName || ''
const price = card.price || '0'
html += `
<div class="product-card" data-product-code="${cardCode}" data-product-name="${cardName}">
${mainImage ? `<div class="product-image"><img src="${mainImage}" alt="${cardName}" loading="lazy"></div>` : ''}
<div class="product-info">
<div class="product-title">${escapeHtml(cardName)}</div>
<div class="product-price">¥${parseFloat(price).toFixed(2)}</div>
<button class="product-buy-btn" data-product-code="${cardCode}" data-product-name="${cardName}">立即购买</button>
</div>
</div>
`
})
html += '</div>'
return html
},
// 检查是否包含购买关键词
isPurchaseIntent(text) {
return PURCHASE_KEYWORDS.test(text)
},
// 获取并展示产品列表
async fetchAndShowProducts(userQuestion) {
this.isLoading = true
try {
const response = await getServiceCardList()
if (response.success && response.data && response.data.list) {
const productHtml = this.buildProductListHtml(response.data)
const messageContent = `
<div class="product-intro">根据您的问题“${escapeHtml(userQuestion)}”,为您推荐以下产品:</div>
${productHtml}
`
this.messages.push({
role: 'assistant',
content: messageContent,
isProductList: true
})
} else {
this.messages.push({
role: 'assistant',
content: '抱歉,获取产品列表失败,请稍后重试。',
isProductList: false
})
}
this.scrollToBottom()
} catch (error) {
console.error('获取产品列表失败:', error)
this.messages.push({
role: 'assistant',
content: '获取产品列表失败,请检查网络后重试。',
isProductList: false
})
this.scrollToBottom()
} finally {
this.isLoading = false
}
},
// 处理产品卡片点击(事件委托)
handleProductClick(event) {
const target = event.target
// 查找实际触发的购买按钮或卡片本身
let productElement = target.closest('.product-buy-btn') || target.closest('.product-card')
if (!productElement) return
// 获取产品标识
let productCode = productElement.dataset.productCode
let productName = productElement.dataset.productName
// 如果是按钮,从按钮上获取;如果是卡片,从卡片上获取
if (!productCode) {
const card = productElement.closest('.product-card')
if (card) {
productCode = card.dataset.productCode
productName = card.dataset.productName
}
}
if (productCode) {
// 跳转到购买页面(根据实际商城地址调整)
const buyUrl = `https://hoservice.ydhomeoffice.cn/ydMall/productDetail?cardCode=${productCode}`
window.open(buyUrl, '_blank')
} else {
console.warn('缺少产品标识,无法跳转')
}
},
async sendMessage() { async sendMessage() {
if (!this.question.trim() || this.isLoading) return if (!this.question.trim() || this.isLoading) return
const userQuestion = this.question.trim() const userQuestion = this.question.trim()
this.messages.push({ role: 'user', content: userQuestion }) this.messages.push({ role: 'user', content: userQuestion })
this.question = '' this.question = ''
this.isLoading = true this.scrollToBottom()
this.scrollToBottom() // 立即滚动显示用户消息
// 检查是否包含购买关键词
if (this.isPurchaseIntent(userQuestion)) {
await this.fetchAndShowProducts(userQuestion)
return
}
// 正常流式回答
this.isLoading = true
const assistantMessageIndex = this.messages.length const assistantMessageIndex = this.messages.length
this.messages.push({ role: 'assistant', content: '' }) this.messages.push({ role: 'assistant', content: '', isProductList: false })
try { try {
const response = await getStream(userQuestion) const response = await getStream(userQuestion)
...@@ -242,7 +348,6 @@ export default { ...@@ -242,7 +348,6 @@ export default {
} else if (line.startsWith('message:')) { } else if (line.startsWith('message:')) {
content = line.slice(8).trim() content = line.slice(8).trim()
} else if (line.startsWith('event:')) { } else if (line.startsWith('event:')) {
// 忽略事件类型行
continue continue
} else { } else {
content = line content = line
...@@ -251,12 +356,11 @@ export default { ...@@ -251,12 +356,11 @@ export default {
if (content) { if (content) {
accumulatedContent += content accumulatedContent += content
this.messages[assistantMessageIndex].content = accumulatedContent this.messages[assistantMessageIndex].content = accumulatedContent
this.scrollToBottom() // 每次更新内容后滚动 this.scrollToBottom()
} }
} }
} }
// 处理剩余 buffer
if (buffer.trim()) { if (buffer.trim()) {
let content = buffer let content = buffer
if (buffer.startsWith('data:')) content = buffer.slice(5).trim() if (buffer.startsWith('data:')) content = buffer.slice(5).trim()
...@@ -269,7 +373,6 @@ export default { ...@@ -269,7 +373,6 @@ export default {
} }
} catch (error) { } catch (error) {
console.error('流式请求失败:', error) console.error('流式请求失败:', error)
// 处理超时或中断错误
const errorMsg = error.name === 'AbortError' ? '请求超时,请稍后重试' : `错误:${error.message}` const errorMsg = error.name === 'AbortError' ? '请求超时,请稍后重试' : `错误:${error.message}`
this.messages[assistantMessageIndex].content = `❌ ${errorMsg}` this.messages[assistantMessageIndex].content = `❌ ${errorMsg}`
this.scrollToBottom() this.scrollToBottom()
...@@ -288,6 +391,17 @@ export default { ...@@ -288,6 +391,17 @@ export default {
} }
} }
} }
// HTML转义函数,防止XSS
function escapeHtml(str) {
if (!str) return ''
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
</script> </script>
<style scoped> <style scoped>
...@@ -426,6 +540,102 @@ export default { ...@@ -426,6 +540,102 @@ export default {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
} }
/* 产品列表样式 */
.message-content :deep(.product-list) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
margin-top: 12px;
}
.message-content :deep(.product-card) {
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
border: 1px solid #f0f0f0;
}
.message-content :deep(.product-card:hover) {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(108, 92, 231, 0.15);
}
.message-content :deep(.product-image) {
width: 100%;
height: 160px;
overflow: hidden;
background: #f5f5f5;
}
.message-content :deep(.product-image img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.message-content :deep(.product-card:hover .product-image img) {
transform: scale(1.05);
}
.message-content :deep(.product-info) {
padding: 12px;
}
.message-content :deep(.product-title) {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 8px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-content :deep(.product-price) {
font-size: 18px;
font-weight: 700;
color: #6c5ce7;
margin-bottom: 12px;
}
.message-content :deep(.product-buy-btn) {
width: 100%;
padding: 8px 0;
background: linear-gradient(135deg, #6c5ce7, #a855f7);
border: none;
border-radius: 24px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.message-content :deep(.product-buy-btn:hover) {
opacity: 0.9;
}
.message-content :deep(.product-intro) {
font-size: 14px;
color: #6c757d;
margin-bottom: 12px;
padding: 8px 0;
border-bottom: 1px solid #e9ecef;
}
.message-content :deep(.product-list-empty) {
text-align: center;
padding: 32px;
color: #94a3b8;
font-size: 14px;
}
.chat-input-area { .chat-input-area {
background: white; background: white;
border-top: 1px solid rgba(0, 0, 0, 0.05); border-top: 1px solid rgba(0, 0, 0, 0.05);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment