feat(ai-house): 重构找房聊天交互

This commit is contained in:
2026-08-08 03:12:57 +08:00
parent c93aac0aa9
commit d6861ab8c4
3 changed files with 522 additions and 79 deletions
+349 -79
View File
@@ -1,5 +1,16 @@
<template>
<view class="page">
<view class="page-toolbar">
<view class="page-toolbar-title">AI找房助手</view>
<view
class="clear-session-btn"
:class="{ 'clear-session-btn-disabled': loading }"
@click="confirmClearSession"
>
<u-icon name="trash" size="18" color="#667085"></u-icon>
<text class="clear-session-text">清空</text>
</view>
</view>
<view class="message-scroll">
<view class="message-content">
<view class="intro-card">
@@ -30,22 +41,19 @@
></image>
<view :class="['message-bubble', message.role === 'user' ? 'bubble-user' : 'bubble-ai']">
<view v-if="message.type === 'form'" class="form-message">
<view class="form-title">您想要找什么样的房型</view>
<view class="form-title">{{ message.content || '您好,请填写您的找房需求' }}</view>
<view class="form-desc">填写楼盘面积和预算我来帮您筛选合适的房源</view>
<view class="form-item">
<text class="form-label">多少平方</text>
<input v-model="form.extent" class="form-input" type="text" placeholder="请输入面积" :disabled="loading" />
<text class="form-label">楼盘</text>
<input v-model="form.building" class="form-input" type="text" placeholder="请输入楼盘名称" :disabled="loading" />
</view>
<view class="form-item">
<text class="form-label">多少楼层</text>
<input v-model="form.floor" class="form-input" type="text" placeholder="请输入楼层" :disabled="loading" />
<text class="form-label">面积</text>
<input v-model="form.area" class="form-input" type="text" placeholder="请输入面积" :disabled="loading" />
</view>
<view class="form-item">
<text class="form-label">多少租金</text>
<input v-model="form.rent" class="form-input" type="text" placeholder="请输入租金" :disabled="loading" />
</view>
<view class="form-item">
<text class="form-label">什么地段</text>
<input v-model="form.location" class="form-input" type="text" placeholder="请输入地段" :disabled="loading" />
<text class="form-label">预算</text>
<input v-model="form.budget" class="form-input" type="text" placeholder="请输入预算" :disabled="loading" />
</view>
<view
class="finder-submit"
@@ -124,6 +132,25 @@
<!-- <text class="copy-text">复制</text>-->
<!-- </view>-->
</view>
<view v-else-if="message.type === 'location'" class="location-message">
<view class="result-title">{{ message.content }}</view>
<view v-for="item in message.locationCards" :key="item.locationId" class="location-card">
<view class="location-card-title">{{ item.locationName }}</view>
<view v-if="item.parentLocationName || item.city" class="location-card-path">
{{ [item.city, item.parentLocationName].filter(Boolean).join(' / ') }}
</view>
<view v-if="item.tags && item.tags.length" class="location-tag-row">
<text v-for="tag in item.tags" :key="tag" class="location-tag">{{ tag }}</text>
</view>
<view v-for="knowledge in item.knowledgeItems" :key="knowledge.topic" class="location-knowledge-item">
<view class="location-knowledge-title">{{ knowledge.title }}</view>
<view v-if="locationKnowledgeFacts(knowledge)" class="location-knowledge-facts">
{{ locationKnowledgeFacts(knowledge) }}
</view>
<view v-if="knowledge.content" class="location-knowledge-content">{{ knowledge.content }}</view>
</view>
</view>
</view>
<view v-else-if="message.type === 'contact'" class="contact-message">
<button class="contact-btn" open-type="contact">{{ message.content || '联系客服' }}</button>
</view>
@@ -182,7 +209,6 @@
<view class="input-panel">
<view class="quick-question-row">
<view class="finder-tag" :class="{ active: showFinderForm }" @click="toggleFinderForm">找房</view>
<view
v-for="item in quickQuestions"
:key="item.label"
@@ -249,12 +275,10 @@
loading: false,
progressText: '',
messages: [],
showFinderForm: false,
form: {
extent: '',
floor: '',
rent: '',
location: ''
building: '',
area: '',
budget: ''
},
messageFormOpened: false,
messageSubmitting: false,
@@ -274,7 +298,8 @@
socketConnected: false,
heartbeatTimer: null,
loginUserId: null,
nextMessageId: 0
nextMessageId: 0,
sessionRestored: false
}
},
computed: {
@@ -282,7 +307,7 @@
if (!this.aiConfigLoaded) {
return '正在加载AI助手配置...'
}
return this.aiConfig.welcomeMessage || '告诉我面积、楼层、预算和地段,我来帮您筛选更合适的房源。'
return this.aiConfig.welcomeMessage || '填写楼盘、面积和预算,我来帮您筛选更合适的房源。'
},
resolvedAiAvatar() {
return this.aiConfig.aiAvatar || 'https://oss.wsdns.cn/20260601/0d53634419a448919c90c99ab9779d8a.png?x-oss-process=image/resize,w_750/quality,Q_90'
@@ -302,44 +327,97 @@
}, 300)
return
}
this.conversationId = `house-ai-${this.loginUserId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
this.conversationId = this.createConversationId()
this.sessionRestored = this.restoreSession()
this.connectWebSocket()
this.loadAiConfig()
},
onUnload() {
if (this.conversationId) {
HouseAiChatApi.clearHouseAiSession({
conversationId: this.conversationId
}).catch(() => {})
}
this.persistSession()
this.closeWebSocket()
},
methods: {
getSessionStorageKey(userId = this.loginUserId) {
return userId ? `house-ai-session-${userId}` : ''
},
createConversationId() {
return `house-ai-${this.loginUserId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
},
restoreSession() {
const storageKey = this.getSessionStorageKey()
if (!storageKey || !storage || typeof storage.get !== 'function') {
return false
}
const savedSession = storage.get(storageKey)
if (!savedSession || typeof savedSession !== 'object' || !savedSession.conversationId) {
return false
}
this.conversationId = savedSession.conversationId
this.messages = Array.isArray(savedSession.messages) ? savedSession.messages : []
this.messageSubmitted = Boolean(savedSession.messageSubmitted)
this.nextMessageId = this.messages.reduce((nextId, message) => {
const match = String(message?.id || '').match(/^msg-(\d+)$/)
return match ? Math.max(nextId, Number(match[1]) + 1) : nextId
}, 0)
return true
},
persistSession() {
const storageKey = this.getSessionStorageKey()
if (!storageKey || !this.conversationId || !storage || typeof storage.set !== 'function') {
return
}
storage.set(storageKey, {
conversationId: this.conversationId,
messages: JSON.parse(JSON.stringify(this.messages)),
messageSubmitted: this.messageSubmitted
})
},
confirmClearSession() {
if (this.loading) {
return
}
uni.showModal({
title: '清空会话',
content: '确定清空当前聊天记录吗?',
confirmText: '清空',
cancelText: '取消',
success: (result) => {
if (result.confirm) {
this.clearSession()
}
}
})
},
async clearSession() {
if (this.loading) {
return
}
const previousConversationId = this.conversationId
this.startNewChat()
this.conversationId = this.createConversationId()
this.appendWelcomeMessage()
try {
await HouseAiChatApi.clearHouseAiSession({
conversationId: previousConversationId
})
uni.showToast({
title: '会话已清空',
icon: 'none'
})
} catch (error) {
uni.showToast({
title: '本地已清空,服务端同步失败',
icon: 'none'
})
}
},
askQuickQuestion(question) {
if (this.loading) {
return
}
this.showFinderForm = false
this.inputText = question
this.sendMessage()
},
toggleFinderForm() {
if (this.loading) {
return
}
this.showFinderForm = !this.showFinderForm
if (this.showFinderForm) {
this.messages = [
this.createMessage('ai', 'text', {
content: this.aiConfig.welcomeMessage || '您好,我是AI找房助手,请告诉我您的找房需求。'
}),
this.createMessage('ai', 'form')
]
} else {
this.appendWelcomeMessage()
}
this.scrollToBottom()
},
submitFinderForm() {
if (this.loading) {
return
@@ -367,32 +445,30 @@
startNewChat() {
this.loading = false
this.progressText = ''
this.showFinderForm = false
this.messageFormOpened = false
this.messageSubmitting = false
this.messageSubmitted = false
this.inputText = ''
this.form = {
extent: '',
floor: '',
rent: '',
location: ''
building: '',
area: '',
budget: ''
}
this.messageForm = {
realName: '',
phone: '',
wechat: ''
}
this.nextMessageId = 0
this.messages = []
this.scrollToBottom()
},
appendWelcomeMessage() {
const welcomeMessage = this.aiConfig.welcomeMessage || '您好,我是AI找房助手,请告诉我面积、楼层、预算和地段,我来帮您筛选更合适的房源。'
this.messages = [
this.createMessage('ai', 'text', {
content: welcomeMessage
})
]
const welcomeMessage = this.aiConfig.welcomeMessage || '您好,我是AI找房助手,请填写楼盘、面积和预算,我来帮您筛选更合适的房源。'
this.messages = [this.createMessage('ai', 'form', {
content: welcomeMessage
})]
this.persistSession()
},
async loadAiConfig() {
this.aiConfigLoaded = false
@@ -410,8 +486,12 @@
}
}
this.aiConfigLoaded = true
this.startNewChat()
this.appendWelcomeMessage()
if (!this.sessionRestored) {
this.startNewChat()
this.appendWelcomeMessage()
} else if (!this.messages.length) {
this.appendWelcomeMessage()
}
this.scrollToBottom()
},
openMessageForm() {
@@ -419,6 +499,7 @@
return
}
this.messageFormOpened = true
this.persistSession()
this.scrollToBottom()
},
validateMessageForm() {
@@ -466,6 +547,7 @@
wechat: (this.messageForm.wechat || '').trim()
})
this.messageSubmitted = true
this.persistSession()
uni.showToast({
title: '提交成功',
icon: 'none'
@@ -500,6 +582,7 @@
content: question
})
)
this.persistSession()
this.inputText = ''
this.loading = true
this.progressText = '正在分析您的需求'
@@ -523,6 +606,7 @@
content: error.message || 'AI服务暂时不可用,请稍后再试。'
})
)
this.persistSession()
this.scrollToBottom()
}
},
@@ -530,10 +614,10 @@
return (this.inputText || '').trim()
},
buildFormQuestion() {
if (!this.form.extent || !this.form.floor || !this.form.rent || !this.form.location) {
if (!this.form.building || !this.form.area || !this.form.budget) {
return ''
}
return `帮我找房,想要${this.form.location}的房源,面积在${this.form.extent}左右,楼层要求${this.form.floor},预算租金控制在${this.form.rent}以内`
return `帮我找房,想要${this.form.building}的房源,面积在${this.form.area}左右,预算控制在${this.form.budget}以内`
},
copyText(text) {
if (!text) {
@@ -570,6 +654,84 @@
}
return []
},
normalizeLocationCards(value) {
return this.normalizeStructuredArray(value)
.map((item, index) => {
if (!item || typeof item !== 'object') {
return null
}
return {
...item,
locationId: item.locationId ?? item.location_id ?? `location-${index}`,
locationName: item.locationName ?? item.location_name ?? item.name ?? '地点资料',
parentLocationName: item.parentLocationName ?? item.parent_location_name ?? '',
tags: this.normalizeLocationTags(item.tags),
knowledgeItems: this.normalizeLocationKnowledgeItems(
item.knowledgeItems !== undefined ? item.knowledgeItems : item.knowledge_items
)
}
})
.filter(Boolean)
},
normalizeStructuredArray(value) {
if (Array.isArray(value)) {
return value
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : (parsed && typeof parsed === 'object' ? [parsed] : [])
} catch (error) {
return []
}
}
return value && typeof value === 'object' ? [value] : []
},
normalizeLocationTags(value) {
if (Array.isArray(value)) {
return value.filter(Boolean).map((item) => String(item))
}
if (typeof value === 'string') {
const tags = this.normalizeStructuredArray(value)
if (tags.length) {
return tags.filter(Boolean).map((item) => String(item))
}
return value.split(/[,]/).map((item) => item.trim()).filter(Boolean)
}
return []
},
normalizeLocationKnowledgeItems(value) {
return this.normalizeStructuredArray(value)
.map((item, index) => {
if (!item || typeof item !== 'object') {
return null
}
return {
...item,
topic: item.topic || `knowledge-${index}`,
name: item.name || '',
title: item.title || '补充信息',
propertyCompany: item.propertyCompany ?? item.property_company,
propertyFees: item.propertyFees ?? item.property_fees,
waterBillingType: item.waterBillingType ?? item.water_billing_type,
waterUnitPrice: item.waterUnitPrice ?? item.water_unit_price,
electricityBillingType: item.electricityBillingType ?? item.electricity_billing_type,
electricityUnitPrice: item.electricityUnitPrice ?? item.electricity_unit_price,
parkingAvailable: this.normalizeBoolean(item.parkingAvailable ?? item.parking_available),
parkingFee: item.parkingFee ?? item.parking_fee
}
})
.filter(Boolean)
},
normalizeBoolean(value) {
if (value === 'true' || value === 1 || value === '1') {
return true
}
if (value === 'false' || value === 0 || value === '0') {
return false
}
return value
},
connectWebSocket() {
if (!this.loginUserId) {
return
@@ -641,6 +803,7 @@
content: raw
})
)
this.persistSession()
this.scrollToBottom()
return
}
@@ -684,9 +847,21 @@
})
)
}
const locationCards = this.normalizeLocationCards(
data.locationCards !== undefined ? data.locationCards : data.location_cards
)
if (locationCards.length) {
this.messages.push(
this.createMessage('ai', 'location', {
content: '以下地点资料来自已维护的知识库:',
locationCards
})
)
}
if (data.showContactForm) {
this.messages.push(this.createMessage('ai', 'messageForm'))
}
this.persistSession()
this.scrollToBottom()
},
goHouseDetail(item) {
@@ -697,6 +872,19 @@
url: `/sub_pages/house/detail?houseId=${item.houseId}`
})
},
locationKnowledgeFacts(item) {
const facts = []
if (item.propertyCompany) facts.push(`物业公司:${item.propertyCompany}`)
if (item.propertyFees !== null && item.propertyFees !== undefined) facts.push(`物业费:${item.propertyFees}`)
if (item.waterBillingType) facts.push(`水费:${item.waterBillingType}`)
if (item.waterUnitPrice !== null && item.waterUnitPrice !== undefined) facts.push(`水费单价:${item.waterUnitPrice}`)
if (item.electricityBillingType) facts.push(`电费:${item.electricityBillingType}`)
if (item.electricityUnitPrice !== null && item.electricityUnitPrice !== undefined) facts.push(`电费单价:${item.electricityUnitPrice}`)
if (item.parkingAvailable === true) facts.push('停车:可用')
if (item.parkingAvailable === false) facts.push('停车:不可用')
if (item.parkingFee) facts.push(`停车费用:${item.parkingFee}`)
return facts.join('')
},
scrollToBottom() {
this.$nextTick(() => {
setTimeout(() => {
@@ -719,6 +907,38 @@
box-sizing: border-box;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx 0;
}
.page-toolbar-title {
font-size: 28rpx;
font-weight: 600;
color: #344054;
}
.clear-session-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 14rpx;
border: 1rpx solid #d0d5dd;
border-radius: 8rpx;
background: #ffffff;
}
.clear-session-text {
font-size: 24rpx;
color: #667085;
}
.clear-session-btn-disabled {
opacity: 0.45;
}
.message-scroll {
padding: 24rpx 24rpx 0;
box-sizing: border-box;
@@ -835,6 +1055,69 @@
justify-content: flex-start;
}
.location-message {
padding: 20rpx;
border-radius: 24rpx;
background: #f8fbff;
border: 1rpx solid #e6eefc;
}
.location-card {
margin-top: 16rpx;
padding: 20rpx;
border-radius: 16rpx;
background: #ffffff;
border: 1rpx solid #e9edf5;
}
.location-card-title {
font-size: 30rpx;
font-weight: 600;
color: #1f2937;
}
.location-card-path {
margin-top: 8rpx;
font-size: 23rpx;
color: #667085;
}
.location-tag-row {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 14rpx;
}
.location-tag {
padding: 4rpx 12rpx;
border-radius: 8rpx;
background: #eef6ff;
color: #2f5f9f;
font-size: 21rpx;
}
.location-knowledge-item {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #eef1f6;
}
.location-knowledge-title {
font-size: 26rpx;
font-weight: 600;
color: #344054;
}
.location-knowledge-facts,
.location-knowledge-content {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.55;
color: #667085;
word-break: break-all;
}
.faq-message {
padding: 8rpx 0 0;
}
@@ -917,6 +1200,14 @@
margin-bottom: 20rpx;
}
.form-desc {
margin-top: -8rpx;
margin-bottom: 10rpx;
font-size: 25rpx;
line-height: 1.6;
color: #667085;
}
.form-item {
display: flex;
align-items: center;
@@ -1162,27 +1453,6 @@
opacity: 0.55;
}
.tag-row {
margin-bottom: 16rpx;
}
.finder-tag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12rpx 26rpx;
border-radius: 999rpx;
background: rgba(79, 124, 255, 0.1);
color: #3557d6;
font-size: 24rpx;
font-weight: 600;
}
.finder-tag.active {
background: rgba(79, 124, 255, 0.16);
color: #274ed8;
}
.input-row {
display: flex;
align-items: center;