feat(ai): 新增AI找房功能模块及配套组件

添加AI找房功能页面、路由配置和API接口
实现聊天界面组件、历史记录加载和滚动定位功能
包含房源推荐展示、联系人组件和样式优化
This commit is contained in:
DESKTOP-RQ919RC\Pc
2025-08-22 18:56:17 +08:00
parent 005441895b
commit 397cdd2a53
8 changed files with 820 additions and 6 deletions

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="17px" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1 0 0 1 -3899 -1199 )">
<path d="M 15.5978260869565 7.51797175866495 C 15.8659420289855 7.77257167308515 16 8.09991442019683 16 8.5 C 16 8.89281129653402 15.8659420289855 9.22379118528027 15.5978260869565 9.49293966623877 L 8.52173913043478 16.5962772785623 C 8.23913043478261 16.8654257595208 7.90942028985507 17 7.53260869565217 17 C 7.16304347826087 17 6.83695652173913 16.8654257595208 6.55434782608696 16.5962772785623 L 5.73913043478261 15.7779204107831 C 5.46376811594203 15.5014976465554 5.32608695652174 15.1705177578092 5.32608695652174 14.7849807445443 C 5.32608695652174 14.3994437312794 5.46376811594203 14.0684638425332 5.73913043478261 13.7920410783055 L 8.92391304347826 10.5949935815148 L 1.27173913043478 10.5949935815148 C 0.894927536231884 10.5949935815148 0.588768115942029 10.4586007702182 0.353260869565217 10.1858151476252 C 0.117753623188406 9.91302952503209 0 9.58386820710312 0 9.19833119383826 L 0 7.80166880616175 C 0 7.41613179289688 0.117753623188406 7.08697047496791 0.353260869565217 6.81418485237484 C 0.588768115942029 6.54139922978177 0.894927536231884 6.40500641848524 1.27173913043478 6.40500641848524 L 8.92391304347826 6.40500641848524 L 5.73913043478261 3.19704749679076 C 5.46376811594203 2.93517329910141 5.32608695652174 2.60783055198973 5.32608695652174 2.21501925545571 C 5.32608695652174 1.8222079589217 5.46376811594203 1.49486521181001 5.73913043478261 1.23299101412067 L 6.55434782608696 0.414634146341464 C 6.82971014492754 0.13821138211382 7.15579710144928 0 7.53260869565217 0 C 7.91666666666667 0 8.2463768115942 0.13821138211382 8.52173913043478 0.414634146341464 L 15.5978260869565 7.51797175866495 Z " fill-rule="nonzero" fill="#ffffff" stroke="none" transform="matrix(1 0 0 1 3899 1199 )" />
</g>
</svg>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,342 @@
<template>
<div class="date" v-if="item.timeText">{{ item.timeText }}</div>
<div class="right message" v-if="item.role == 'user'">{{ item.content }}</div>
<div class="left" :class="{ error: item.iserror }" v-if="item.role == 'assistant'">
<div class="loading-container" v-if="item.loading">
<div class="loading-item" style="--i: 0"></div>
<div class="loading-item" style="--i: 1"></div>
<div class="loading-item" style="--i: 2"></div>
<div class="loading-item" style="--i: 3"></div>
<div class="loading-item" style="--i: 4"></div>
<div class="loading-item" style="--i: 5"></div>
<span class="text">圆同学正为您努力找房中</span>
</div>
<template v-if="item.payload.type == 'recommendation'">
<div class="intro">{{ item.payload.intro }}</div>
<div class="point">推荐房源</div>
<div class="point-item" v-for="(item, ii) in item.payload.recommendations" :key="ii">
<a class="title flexacenter" url="{{ item.url }}">
<div class="serial flexcenter">{{ ii + 1 }}</div>
<div class="name one-line-display">{{ item.name }}</div>
<div class="full">已租满</div>
<img class="arrows" src="https://app.gter.net/image/miniApp/HKRenting/arrow-circular-orange-red.svg" />
</a>
<div class="spot flexflex">
<div class="dot"></div>
<div class="text flex1"><span class="spot-title">区域</span>{{ item.district }}</div>
</div>
<div class="spot flexflex">
<div class="dot"></div>
<div class="rent flex1 flexacenter">
<span class="spot-title">租金</span>
<div class="number">{{ item.price }}</div>
HKD/{{ item.rent_type == "per_person" ? "/人" : "/房" }}
</div>
</div>
<div class="spot flexflex">
<div class="dot"></div>
<div class="text flex1"><span class="spot-title">优势</span>{{ item.advantages }}</div>
</div>
<div class="spot flexflex">
<div class="dot"></div>
<div class="text flex1"><span class="spot-title">不足</span>{{ item.disadvantages }}</div>
</div>
<div class="spot flexflex">
<div class="dot"></div>
<div class="star-box flex1 flexacenter">
<span class="spot-title">推荐星级</span>
<img v-for="item in item.score" :key="item" class="star-item" src="https://app.gter.net/image/miniApp/HKRenting/star-icon.svg" />
<img v-if="item.scoreHalf" class="star-item" src="https://app.gter.net/image/miniApp/HKRenting/star-half.svg" />
</div>
</div>
<div class="spot flexflex" wx:if="{{ item.recommendation_reason }}">
<div class="dot"></div>
<div class="text flex1"><span class="spot-title">推荐理由</span>{{ item.recommendation_reason }}</div>
</div>
<div class="spot" v-if="item.images.length > 0">
<div class="scroll-view">
<img class="img" v-for="(source, index) in item.images" :key="index" :src="source" />
</div>
</div>
</div>
<template v-if="item?.payload?.notes?.length > 0">
<div class="point">注意事项</div>
<div class="point-item">
<div class="spot flexflex" v-for="(item, index) in item.payload.notes" :key="index">
<div class="dot"></div>
<div v-if="item.content" class="text flex1">
{{ item.content }}
<div v-if="item.copy" class="text link" bind:tap="consultStateCut" data-text="{{ item.copy }}">{{ item.copy }}</div>
{{ item.end }}
</div>
<div v-else class="text flex1">{{ item }}</div>
</div>
</div>
</template>
<div class="ai-hint">内容由AI生成请审慎参考</div>
</template>
<div v-else-if="item.payload.type == 'text' && item.payload.message.before">
{{ item.payload.message.before }}<span class="text link" bind:tap="consultStateCut" v-if="item.payload.message.wechat">{{ item.payload.message.wechat }}</span
>{{ item.payload.message.after }}
</div>
<div v-else>{{ item.payload.message || item.message }}</div>
</div>
<Contacts v-if="item.role == 'contacts'"></Contacts>
</template>
<script setup>
import { reactive, onMounted, ref, nextTick, onBeforeUnmount, watch } from "vue";
import Contacts from "./contacts.vue";
const props = defineProps({
item: {
type: Object,
default: () => {},
},
});
</script>
<style lang="less" scoped>
.date {
font-size: 14px;
color: #7f7f7f;
height: 24px;
margin: 0 auto 20px;
}
.right {
background-color: rgba(213, 245, 255, 1);
border-radius: 20px;
padding: 11px 15px;
font-size: 17px;
line-height: 26px;
color: #333333;
margin-left: auto;
margin-bottom: 30px;
font-family: "PingFangSC-Regular", "PingFang SC", sans-serif;
}
.left {
margin-bottom: 30px;
color: #333333;
font-size: 16px;
line-height: 26px;
font-family: "PingFangSC-Regular", "PingFang SC", sans-serif;
.loading-container {
display: inline-flex;
align-items: center;
justify-content: center;
color: #333333;
.loading-item {
width: 20px;
height: 20px;
margin-right: 4px;
border-radius: 4px;
animation: colorShift 1.2s infinite linear;
animation-delay: calc(var(--i) * 0.2s);
display: inline-flex;
}
.text {
margin-left: 5px;
}
/* 初始化默认颜色 */
.loading-item:nth-child(1) {
background-color: rgba(163, 230, 251, 1);
}
.loading-item:nth-child(2) {
background-color: rgba(180, 234, 251, 1);
}
.loading-item:nth-child(3) {
background-color: rgba(194, 238, 251, 1);
}
.loading-item:nth-child(4) {
background-color: rgba(213, 245, 255, 1);
}
.loading-item:nth-child(5) {
background-color: rgba(230, 249, 255, 1);
}
.loading-item:nth-child(6) {
background-color: rgba(241, 252, 255, 1);
}
/* 定义颜色向左移动的关键帧 */
@keyframes colorShift {
0% {
background-color: rgba(163, 230, 251, 1);
}
16.67% {
background-color: rgba(241, 252, 255, 1);
}
33.33% {
background-color: rgba(230, 249, 255, 1);
}
50% {
background-color: rgba(213, 245, 255, 1);
}
66.67% {
background-color: rgba(194, 238, 251, 1);
}
83.33% {
background-color: rgba(180, 234, 251, 1);
}
100% {
background-color: rgba(163, 230, 251, 1);
}
}
}
.intro {
font-family: "PingFangSC-Regular", "PingFang SC", sans-serif;
font-weight: 400;
font-style: normal;
font-size: 16px;
line-height: 26px;
color: #333333;
margin-bottom: 20px;
}
.point {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
font-style: normal;
font-size: 18px;
color: #000000;
line-height: 26px;
margin-bottom: 10px;
}
.point-item {
margin-bottom: 30px;
.title {
margin-bottom: 14px;
.serial {
width: 20px;
height: 20px;
line-height: 20px;
background-color: rgba(127, 221, 255, 1);
border-radius: 6px;
font-size: 14px;
color: #ffffff;
margin-right: 10px;
}
.name {
font-family: "PingFangSC-Regular", "PingFang SC", sans-serif;
font-weight: 400;
font-style: normal;
font-size: 16px;
text-decoration: underline;
color: #026277;
line-height: 26px;
margin-right: 10px;
}
.full {
color: #fff;
background-color: #333333;
padding: 0 6px;
border-radius: 6px;
height: 20px;
line-height: 20px;
margin-right: 10px;
font-size: 14px;
}
.arrows {
width: 14px;
height: 14px;
}
}
.spot {
margin-bottom: 10px;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #76c45e;
border: 1px solid #599146;
margin-top: 8px;
margin-left: 12px;
margin-right: 10px;
}
.text {
color: #333333;
font-size: 16px;
line-height: 26px;
}
.spot-title {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
color: #000000;
}
.star-item {
width: 18px;
height: 18px;
&:not(:last-of-type) {
margin-right: 4px;
}
}
.rent {
.number {
font-family: "Arial-Black", "Arial Black", sans-serif;
font-weight: 900;
color: #d35110;
margin-right: 5px;
}
}
}
}
.scroll-view {
width: 769px;
margin-left: 30px;
white-space: nowrap;
overflow: auto;
.img {
width: 165px;
height: 110px;
margin-right: 10px;
border-radius: 10px;
object-fit: cover;
display: inline-block;
}
}
.ai-hint {
margin: 0 auto;
color: #aaaaaa;
font-size: 14px;
text-align: center;
}
.link {
color: #026277;
text-decoration: underline;
display: inline-block;
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<div class="contacts">
如需进一步了解具体房源信息或香港租房生活相关问题请添加方同学微信
<div class="text link" bind:tap="consultStateCut">gternet2</div>
</div>
</template>
<script setup>
import { ref } from "vue";
const consultState = ref(false);
const consultStateCut = () => {
consultState.value = true;
};
</script>
<style lang="less" scoped>
.contacts {
color: #333333;
font-size: 16px;
line-height: 26px;
margin-bottom: 30px;
.text {
color: #026277;
text-decoration: underline;
display: inline-block;
cursor: pointer;
}
}
</style>

View File

@@ -121,6 +121,14 @@ const routes = [
path: "/apartmentDetail",
},
},
{
path: "/ai",
component: () => import(/* webpackChunkName: "housing" */ "@/views/ai.vue"),
meta: {
title: "AI找房",
path: "/ai",
},
},
];
const router = createRouter({

View File

@@ -45,6 +45,23 @@ export default{
},
apartmentCollection:(params={})=>{// 公寓列表 - 收藏
return axios.post('/tenement/pc/api/user/apartmentCollection',params)
}
},
alInit:(params={})=>{// AI 初始化
return axios.post('https://api.gter.net/v1/chat/init',params)
},
alHistory:(params={})=>{// AI 历史记录
return axios.post('https://api.gter.net/v1/chat/history',params)
},
alChat:(params={})=>{// AI 发送信息
return axios.post('https://fangchat.x-php.com/api/v1/chat',params)
},
alResume:(params={})=>{// AI 恢复会话
return axios.post('https://fangchat.x-php.com/api/v1/chat/resume',params)
},
alEnd:(params={})=>{// AI 结束会话
return axios.post('https://api.gter.net/v1/chat/end',params)
},
alNew:(params={})=>{// AI 新建会话
return axios.post('https://api.gter.net/v1/chat/new',params)
},
}

View File

@@ -20,7 +20,7 @@ axios.interceptors.request.use(
if (config.url != "/tenement/pc/api/user/operation" && !noMask) showFullScreenLoading()
// 开发时登录用的,可以直接替换小程序的 authorization
if (process.env.NODE_ENV !== "production") {
const miucms_session = "fb685339c8ec5030749c4d85d3c1a7fd";
const miucms_session = "01346a38444d71aaadb3adad52b52c39";
document.cookie = "miucms_session=" + miucms_session;
config["headers"]["authorization"] = miucms_session;
}
@@ -97,9 +97,8 @@ const $post = (url, params) => {
resolve(res.data)
})
.catch(err => {
if (err.data.code == 401) {
resolve(err.data)
} else reject(err.data)
if (err.data.code == 401) resolve(err.data)
else reject(err.data)
})
})
}

410
src/views/ai.vue Normal file
View File

@@ -0,0 +1,410 @@
<template>
<div class="main">
<header class="header flexacenter">
<div class="title">寄托AI找房</div>
<div class="logo flex1 flexacenter">
<img class="icon" src="../assets/img/publicImage/round-icon.gif" />
圆同学
</div>
</header>
<div class="content flexflex" ref="contentRef">
<template v-if="isLogo">
<img class="logo" src="../assets/img/publicImage/round-icon.gif" />
<div class="hint">Hi我是你的租房小助手圆同学初次见面很开心目前我还属于初代产品只能提供房源推荐服务暂时干不了其他事来吧告诉我你想找什么样的房子</div>
</template>
<template v-if="historyList.length > 0">
<AiItem v-for="(item, index) in historyList" :key="index" :item="item"></AiItem>
<Contacts></Contacts>
</template>
<div v-if="historyList.length > 0" class="history-hint flexcenter flex1">
<div class="history-line history-left"></div>
以上是历史对话
<div class="history-line history-right"></div>
</div>
<AiItem v-for="(item, index) in list" :key="index" :item="item"></AiItem>
<!-- fixed -->
<div class="input-box fixed">
<div class="input">
<textarea class="input-text" placeholder="请输入" @input="autoResize"></textarea>
<div class="send pitch flexcenter">
<img class="icon" src="../assets/img/publicImage/arrows-deep-white.svg" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, ref, nextTick, onBeforeUnmount, watch } from "vue";
import api from "../utils/api";
import store from "@/store/index";
import AiItem from "../components/ai/aiItem.vue";
import Contacts from "../components/ai/contacts.vue";
onMounted(() => {
console.log("onMounted");
init();
});
let initState = false;
let session_id = "";
let historyObj = {
isHistory: true,
role: "assistant",
payload: {
type: "text",
message: "很高兴又见面了,快快告诉我你的租房需求吧",
},
};
// 初始化
const init = () => {
api.alInit().then((res) => {
if (res.code == 401) store.state.showloginmodal = true;
if (res.code != 200) return;
const data = res.data;
let history = data.history || [];
session_id = data.session_id;
initState = true;
const target = history[0] || {};
historyObj["payload"] = target["payload"];
historyObj["timestamp"] = target["timestamp"];
historyObj["timeText"] = formatTimestamp(getTimestamp(target["timestamp"])) || "";
getHistory();
});
};
const contentRef = ref(null);
// 滚动到最后一条
const scrollEnd = async () => {
await nextTick();
nextTick(() => {
// 1. 获取当前水平滚动位置
const currentX = window.pageXOffset;
// 2. 设置新的滚动位置:水平位置不变,垂直滚动到 500px 处
window.scrollTo(currentX, 100000);
});
};
// 滚动到指定位置
const scrollItem = (id) => {
nextTick(() => {
const element = contentRef.value.querySelector(`[data-id="${id}"]`);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
});
};
let page = 1;
let loading = false;
let list = ref([]);
let historyList = ref([]);
let isLogo = ref(true);
// 获取历史记录
const getHistory = () => {
if (page == 0 || loading) return;
loading = true;
const limit = 10;
const aiId = historyList.value[0]?.id || "";
api.alHistory({
page,
limit,
}).then((res) => {
console.log(res);
if (res.code != 200) return;
let data = res.data || {};
// data.count = 0
// data.history = []
let history = data.history || [];
history.reverse();
history = history.concat(historyList.value);
history = calculateRecordTimeText(history);
if (history.length > 0 && page == 1) {
let obj = historyObj || {};
list.value.push(obj);
}
console.log("history", history);
historyList.value = history;
isLogo.value = history.length == 0 ? true : false;
if (page > 1) scrollItem(aiId);
else scrollEnd();
page = data.page * limit >= data.count ? 0 : data.page + 1;
});
};
let firstTimeStamp = null;
// 计算 聊天记录时间显示
const calculateRecordTimeText = (list = []) => {
let interval = 5 * 60 * 1000; // 5 分钟
// interval = 1000
list = list.map((item) => {
const currentTimeStamp = getTimestamp(item.timestamp || "") || 0;
if (firstTimeStamp === null || Math.abs(currentTimeStamp - firstTimeStamp) > interval) {
firstTimeStamp = currentTimeStamp;
return {
...item,
timeText: formatTimestamp(currentTimeStamp) || "",
};
}
return {
...item,
};
});
return list;
};
// 自动输入框增高
const autoResize = (e) => {
e.target.style.height = "auto"; // 重置高度
e.target.style.height = `${e.target.scrollHeight}px`; // 设置为内容高度
};
const getTimestamp = (time) => {
if (!time || typeof time !== "string" || time.trim() === "") return null;
return new Date(time.replace(" ", "T")).getTime();
};
// 加载历史消息(插入到列表顶部)
const loadHistory = async () => {
isLoading.value = true;
try {
// 步骤1记录加载前的滚动状态关键用于后续校准位置
const container = chatContainer.value;
const beforeScrollHeight = container.scrollHeight; // 加载前的总高度
// 步骤2模拟请求历史消息实际项目替换为API调用
const historyMsgs = await fetchHistory(page.value);
if (historyMsgs.length === 0) {
hasMore.value = false;
return;
}
// 步骤3插入新消息到列表顶部历史消息比现有消息更早
messages.value = [...historyMsgs, ...messages.value];
// 步骤4等待DOM更新后校准滚动位置核心逻辑
await nextTick();
const afterScrollHeight = container.scrollHeight; // 加载后的总高度
// 计算新消息增加的高度,让滚动条保持在加载前的相对位置
const heightDiff = afterScrollHeight - beforeScrollHeight;
container.scrollTop = heightDiff; // 关键通过设置scrollTop抵消高度变化
page.value++; // 页数+1
} catch (err) {
console.error("加载失败:", err);
} finally {
isLoading.value = false;
}
};
const formatTimestamp = (timestamp) => {
// 创建时间对象(处理毫秒级时间戳)
const date = new Date(timestamp);
const now = new Date();
// 获取年、月、日、时、分
const year = date.getFullYear();
const month = date.getMonth() + 1; // 月份从0开始需要加1
const day = date.getDate();
const hours = String(date.getHours()).padStart(2, "0"); // 确保两位数
const minutes = String(date.getMinutes()).padStart(2, "0");
// 获取当前日期的年、月、日
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
const currentDay = now.getDate();
let str = "";
if (year === currentYear && month === currentMonth && day === currentDay) str = `${hours}:${minutes}`; // 检查是否是今天
else if (year === currentYear) str = `${month}${day}${hours}:${minutes}`; // 检查是否是同一年
else str = `${year}${month}${day}${hours}:${minutes}`; // 不同年
return str;
};
</script>
<style scoped lang="less">
.main {
min-height: 100vh;
background-color: #fff;
display: flex;
flex-direction: column;
.header {
width: 100%;
height: 50px;
background-color: #fff;
border-bottom: 1px solid #ebebeb;
position: fixed;
top: 0;
left: 0;
.title {
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
font-weight: 650;
font-style: normal;
font-size: 18px;
color: #000000;
line-height: 26px;
padding-left: 11px;
padding-right: 15px;
border-right: 1px solid #d7d7d7;
}
.logo {
font-family: "PingFangSC-Regular", "PingFang SC", sans-serif;
font-weight: 400;
font-style: normal;
font-size: 16px;
color: #6e6e96;
padding-left: 18px;
.icon {
width: 37px;
height: 40px;
margin-right: 8px;
}
}
}
.content {
width: 800px;
margin: 0 auto;
height: 800px;
flex: 1;
// background: aqua;
flex-direction: column;
padding-top: 50px;
padding-bottom: 150px;
.logo {
width: 110px;
height: 119px;
margin: 122px auto 15px;
}
.hint {
font-family: "PingFangSC-Regular", "PingFang SC", sans-serif;
font-weight: 400;
font-style: normal;
font-size: 16px;
line-height: 26px;
color: #333333;
width: 430px;
margin: 0 auto 86px;
}
.input-box {
width: 800px;
&.fixed {
position: fixed;
bottom: 0;
z-index: 1;
padding-bottom: 38px;
background: #fff;
}
.input {
background-color: rgba(255, 255, 255, 1);
border: 1px solid rgba(235, 235, 235, 1);
border-radius: 20px;
.input-text {
border: none;
outline: none;
width: 100%;
border-radius: 20px;
padding: 15px 20px 0 15px;
resize: none;
display: block;
font-size: 16px;
height: 51px;
max-height: 230px;
}
.send {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #d7d7d7;
margin-left: auto;
margin-right: 20px;
margin-bottom: 15px;
cursor: not-allowed;
&.pitch {
background-color: #7fddff;
cursor: pointer;
}
.icon {
width: 16px;
height: 16px;
transform: rotate(270deg);
}
}
}
}
.history-hint {
font-size: 16px;
line-height: 26px;
color: #7f7f7f;
text-align: center;
margin-bottom: 30px;
position: relative;
padding: 10px 0;
.history-line {
width: 150px;
height: 2px;
margin: 0 20px;
&.history-left {
background: linear-gradient(to left, #ebebeb, #f7f8fa);
}
&.history-right {
background: linear-gradient(to right, #ebebeb, #f7f8fa);
}
}
}
}
}
</style>
<style lang="less">
header.page-header,
section.index-footer,
.left-side-bar1 {
display: none !important;
}
</style>