流式布局、首页、搜索与好友入口 一、目标 本节主要完成展示层与信息流相关功能:
个人空间页面;
角色卡片组件;
无限滚动 / 流式加载;
首页角色广场;
搜索功能;
好友模型与好友列表;
点击角色进入聊天弹窗的前置逻辑。
涉及的重要知识点:
IntersectionObserver;
CSS Grid 自适应布局;
Django 分页查询;
Django ORM Q 对象;
前端 query 参数搜索;
事件冒泡与 @click.stop;
外键级联删除。
二、个人空间模块 2.1 业务目标 个人空间用于展示某个用户的信息和他创建的 AI 角色。
页面路径示例:
展示内容:
用户头像;
用户名;
用户 ID;
用户简介;
该用户创建的所有角色;
如果当前登录用户是角色作者,则显示编辑和删除按钮。
三、个人空间后端接口 3.1 接口设计 1 GET /api/create/character/get_list/?user_id=1&items_count=0
请求参数:
参数
说明
user_id
要查看的用户 ID
items_count
前端当前已经加载的角色数量
返回:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 { "result" : "success" , "user_profile" : { "user_id" : 1 , "username" : "alice" , "profile" : "简介" , "photo" : "/media/user/photos/default.png" } , "characters" : [ { "id" : 1 , "name" : "角色名" , "profile" : "角色简介" , "photo" : "/media/..." , "background_image" : "/media/..." , "author" : { "user_id" : 1 , "username" : "alice" , "photo" : "/media/..." } } ] }
3.2 后端核心逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 from django.contrib.auth.models import Userfrom rest_framework.views import APIViewfrom rest_framework.response import Responsefrom web.models.character import Characterfrom web.models.user import UserProfileclass GetListCharacterView (APIView ): def get (self, request ): items_count = int (request.query_params.get('items_count' , 0 )) user_id = int (request.query_params.get('user_id' )) user = User.objects.get(id =user_id) user_profile = UserProfile.objects.get(user=user) characters_raw = Character.objects.filter (author=user_profile).order_by('-id' )[items_count:items_count + 20 ] characters = [] for character in characters_raw: author = character.author characters.append({ 'id' : character.id , 'name' : character.name, 'profile' : character.profile, 'photo' : character.photo.url, 'background_image' : character.background_image.url, 'author' : { 'user_id' : author.user.id , 'username' : author.user.username, 'photo' : author.photo.url, } }) return Response({ 'result' : 'success' , 'user_profile' : { 'user_id' : user.id , 'username' : user.username, 'profile' : user_profile.profile, 'photo' : user_profile.photo.url, }, 'characters' : characters, })
3.3 分页切片 1 .order_by('-id' )[items_count:items_count + 20 ]
含义:
按 ID 倒序,新角色在前;
跳过前端已加载的 items_count 条;
再取 20 条。
四、个人空间前端 4.1 UserInfoField.vue 职责:展示用户资料。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <script setup> defineProps(['userProfile']) </script> <template> <div v-if="userProfile" class="flex mt-12 gap-8"> <div class="avatar"> <div class="w-44 rounded-full"> <img :src="userProfile.photo" alt="" /> </div> </div> <div class="flex flex-col justify-center w-64 h-44"> <div class="text-2xl font-bold line-clamp-1 break-all">{{ userProfile.username }}</div> <div class="text-sm text-gray-500 mt-5">AgentTalk 号:{{ userProfile.user_id }}</div> <div class="text-sm h-20 line-clamp-4 break-all">{{ userProfile.profile }}</div> </div> </div> </template>
4.2 SpaceIndex.vue 状态设计 1 2 3 4 5 const userProfile = ref (null )const characters = ref ([])const isLoading = ref (false )const hasCharacters = ref (true )const sentinelRef = useTemplateRef ('sentinel-ref' )
含义:
状态
说明
userProfile
当前空间用户信息
characters
已加载角色列表
isLoading
是否正在请求
hasCharacters
是否还有更多角色
sentinelRef
底部哨兵元素
4.3 网格布局 1 2 3 <div class ="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-9 mt-12 justify-items-center w-full px-9" > ... </div >
解释:
repeat(auto-fill, minmax(240px, 1fr)):自动计算每行能放多少列;
每个卡片最小 240px,最大占剩余空间;
屏幕宽时每行更多卡片,屏幕窄时自动换行。
五、无限滚动 / 流式加载 5.1 核心思想 在列表底部放一个很小的元素,称为“哨兵”。当哨兵进入可视区域,说明用户快滚到底了,就加载下一页。
1 <div ref ="sentinel-ref" class ="h-2 mt-8" > </div >
5.2 IntersectionObserver 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 let observer = null onMounted (async () => { await loadMore () observer = new IntersectionObserver ( entries => { entries.forEach (entry => { if (entry.isIntersecting ) { loadMore () } }) }, { root : null , rootMargin : '2px' , threshold : 0 } ) observer.observe (sentinelRef.value ) }) onBeforeUnmount (() => { observer?.disconnect () })
5.3 loadMore 逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 async function loadMore ( ) { if (isLoading.value || !hasCharacters.value ) return isLoading.value = true let newCharacters = [] try { const res = await api.get ('/api/create/character/get_list/' , { params : { items_count : characters.value .length , user_id : route.params .user_id , }, }) const data = res.data if (data.result === 'success' ) { userProfile.value = data.user_profile newCharacters = data.characters } } finally { isLoading.value = false if (newCharacters.length === 0 ) { hasCharacters.value = false } else { characters.value .push (...newCharacters) await nextTick () if (checkSentinelVisible ()) { await loadMore () } } } }
5.4 为什么加载后还要 checkSentinelVisible? 如果屏幕很高,一次加载 20 条后仍然没有填满页面,哨兵仍可见,这时需要继续加载,直到:
六、角色卡片 Character.vue 6.1 卡片展示内容
角色背景图;
角色头像;
角色名;
角色简介;
作者头像和用户名;
编辑按钮;
删除按钮。
6.2 悬浮放大效果 1 2 3 4 5 6 <img :src="character.background_image" class="transition-transform duration-300" :class="{ 'scale-120': isHover }" alt="" />
1 const isHover = ref (false )
模板:
1 2 3 <div @mouseover ="isHover = true" @mouseout ="isHover = false" > ... </div >
6.3 渐变遮罩 1 <div class ="absolute left-0 top-50 w-60 h-50 bg-linear-to-t from-black/40 to-transparent" > </div >
作用:让底部文字在背景图上更清晰。
6.4 编辑按钮 1 2 3 4 5 6 <RouterLink :to="{ name: 'update-character', params: { character_id: character.id } }" class="btn btn-circle btn-ghost bg-transparent" > 编辑 </RouterLink>
6.5 删除角色 1 2 3 4 5 6 7 8 9 10 const emit = defineEmits (['remove' ])async function handleRemoveCharacter ( ) { const res = await api.post ('/api/create/character/remove/' , { character_id : props.character .id , }) if (res.data .result === 'success' ) { emit ('remove' , props.character .id ) } }
父组件:
1 2 3 function removeCharacter (characterId ) { characters.value = characters.value .filter (c => c.id !== characterId) }
七、首页信息流 7.1 首页后端接口 1 GET /api/homepage/index/?items_count=0&search_query=xxx
未搜索时返回所有角色,搜索时按角色名或简介过滤。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 from django.db.models import Qfrom rest_framework.views import APIViewfrom rest_framework.response import Responsefrom web.models.character import Characterclass HomepageIndexView (APIView ): def get (self, request ): items_count = int (request.query_params.get('items_count' , 0 )) search_query = request.query_params.get('search_query' , '' ).strip() if search_query: queryset = Character.objects.filter ( Q(name__icontains=search_query) | Q(profile__icontains=search_query) ) else : queryset = Character.objects.all () characters_raw = queryset.order_by('-id' )[items_count:items_count + 20 ] characters = [] for character in characters_raw: author = character.author characters.append({ 'id' : character.id , 'name' : character.name, 'profile' : character.profile, 'photo' : character.photo.url, 'background_image' : character.background_image.url, 'author' : { 'user_id' : author.user.id , 'username' : author.user.username, 'photo' : author.photo.url, } }) return Response({'result' : 'success' , 'characters' : characters})
7.2 首页前端 首页与个人空间类似,区别是:
不传 user_id;
接口为 /api/homepage/index/;
根据 route.query.q 传搜索词。
1 2 3 4 5 6 const res = await api.get ('/api/homepage/index/' , { params : { items_count : characters.value .length , search_query : route.query .q || '' , }, })
八、搜索功能 8.1 导航栏提交搜索 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const searchQuery = ref ('' )const router = useRouter ()const route = useRoute ()watch (() => route.query .q , value => { searchQuery.value = value || '' }) function handleSearch ( ) { router.push ({ name : 'homepage-index' , query : { q : searchQuery.value .trim (), }, }) }
模板:
1 2 3 4 <form @submit.prevent="handleSearch" class="join w-4/5 flex justify-center"> <input v-model="searchQuery" class="input join-item rounded-l-full" placeholder="搜索你感兴趣的内容" /> <button class="btn join-item rounded-r-full">搜索</button> </form>
8.2 首页监听搜索变化 1 2 3 4 5 6 7 8 9 10 function reset ( ) { characters.value = [] isLoading.value = false hasCharacters.value = true loadMore () } watch (() => route.query .q , () => { reset () })
8.3 URL query 的好处 使用:
好处:
刷新页面后搜索条件不丢失;
可以复制链接分享;
浏览器前进后退能恢复搜索状态。
九、Django ORM 的 Q 对象 9.1 默认 filter 是 AND 1 Character.objects.filter (name='A' , profile='B' )
等价于:
1 name = A AND profile = B
9.2 Q 对象实现 OR 1 2 3 4 5 from django.db.models import QCharacter.objects.filter ( Q(name__icontains=search_query) | Q(profile__icontains=search_query) )
含义:
1 name 包含 search_query OR profile 包含 search_query
9.3 常见查询后缀
写法
含义
exact
精确匹配
iexact
忽略大小写精确匹配
contains
包含,区分大小写
icontains
包含,忽略大小写
gt / gte
大于 / 大于等于
lt / lte
小于 / 小于等于
in
在列表中
isnull
是否为空
十、好友模块 10.1 Friend 模型 Friend 表示某个用户和某个角色建立了聊天关系。
1 2 3 4 5 6 class Friend (models.Model): me = models.ForeignKey(UserProfile, on_delete=models.CASCADE) character = models.ForeignKey(Character, on_delete=models.CASCADE) memory = models.TextField(default='' , max_length=5000 , blank=True , null=True ) create_time = models.DateTimeField(default=now) update_time = models.DateTimeField(default=now)
10.2 on_delete=models.CASCADE 如果删除角色:
1 character = models.ForeignKey(Character, on_delete=models.CASCADE)
与该角色相关的好友关系会自动删除。
10.3 获取或创建好友 当用户点击角色卡片进入聊天:
1 POST /api/friend/get_or_creat/
逻辑:
当前用户必须已登录;
获取角色 ID;
如果已有 Friend,直接返回;
如果没有,创建 Friend;
返回 friend 信息与角色信息。
当前项目代码中接口名为 get_or_creat,复习时注意拼写;后续重构建议改为 get_or_create。
10.4 好友列表 1 GET /api/friend/get_list/?items_count=0
用于 FriendIndex.vue 展示当前用户已经建立的 AI 好友。
十一、点击卡片打开聊天框 11.1 Character.vue 逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const chatFieldRef = useTemplateRef ('chat-field-ref' )const friend = ref (null )async function openChatField ( ) { if (!user.isLogin ()) { await router.push ({ name : 'login-index' }) return } const res = await api.post ('/api/friend/get_or_creat/' , { character_id : props.character .id , }) const data = res.data if (data.result === 'success' ) { friend.value = data.friend chatFieldRef.value .showModal () } }
11.2 ChatField.vue 背景图 1 2 3 4 5 6 7 8 9 10 11 const modalStyle = computed (() => { if (props.friend ?.character ) { return { backgroundImage : `url(${props.friend.character.background_image} )` , backgroundSize : 'cover' , backgroundPosition : 'center' , backgroundRepeat : 'no-repeat' , } } return {} })
十二、好友页面 12.1 FriendIndex.vue 好友页面也是流式加载:
1 2 3 const friends = ref ([])const isLoading = ref (false )const hasFriends = ref (true )
请求:
1 2 3 4 5 const res = await api.get ('/api/friend/get_list/' , { params : { items_count : friends.value .length , }, })
渲染:
1 2 3 4 5 6 7 8 <Character v-for="friend in friends" :key="friend.id" :character="friend.character" :canRemoveFriend="true" :friendID="friend.id" @remove="removeFriend" />
12.2 删除好友 1 2 3 4 5 6 7 8 async function handleRemoveFriend ( ) { const res = await api.post ('/api/friend/remove/' , { friend_id : props.friendID , }) if (res.data .result === 'success' ) { emit ('remove' , props.friendID ) } }
12.3 阻止事件冒泡 删除按钮在角色卡片内部,如果点击删除按钮时不阻止冒泡,会同时触发外层卡片点击,导致打开聊天框。
正确写法:
1 <button @click.stop="handleRemoveFriend">删除</button>
不是:
十三、路由汇总 13.1 后端 1 2 3 4 5 path('api/create/character/get_list/' , GetListCharacterView.as_view()), path('api/homepage/index/' , HomepageIndexView.as_view()), path('api/friend/get_or_creat/' , GetOrCreateView.as_view()), path('api/friend/remove/' , RemoveFriendView.as_view()), path('api/friend/get_list/' , GetListFriendView.as_view()),
13.2 前端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { path : '/' , component : HomepageIndex , name : 'homepage-index' , }, { path : '/user/space/:user_id/' , component : SpaceIndex , name : 'user-space-index' , }, { path : '/friend/' , component : FriendIndex , name : 'friend-index' , meta : { needLogin : true }, }
十四、易错点总结
首页接口返回字段应统一为 result,不要写成 results;
items_count 从 query 参数取出是字符串,要转成 int;
前端接口路径建议以 / 开头;
IntersectionObserver 创建后必须 observe(sentinelRef.value);
组件卸载时必须 disconnect();
删除按钮要用 @click.stop;
搜索 query 改变后要清空旧列表再重新请求;
个人空间从一个用户切换到另一个用户时,要监听 route.params.user_id 并重置页面;
当前项目接口 get_or_creat 拼写特殊,调用时必须与后端一致。
十五、小结 本节把项目从“单点功能”扩展成“可浏览内容的平台”:
个人空间展示用户与角色;
首页展示全站角色;
搜索通过 URL query 和后端 Q 查询实现;
无限滚动通过哨兵与 IntersectionObserver 实现;
角色卡片成为后续聊天入口;
Friend 模型为每个用户和角色之间建立独立聊天关系。
十六、新手版:什么是信息流和无限滚动 首页角色广场、个人空间角色列表都属于“信息流”。
你可以把信息流想象成短视频 App 或商品列表:
一开始不加载全部内容;
先加载一部分;
用户滚动到底部时再加载下一部分;
这样页面更快,也不会一次请求太多数据。
16.1 为什么不能一次加载全部角色 如果数据库里只有 10 个角色,一次加载没问题。
但如果将来有 10 万个角色,一次性返回会导致:
后端查询慢;
网络传输慢;
浏览器渲染卡顿;
用户等待时间长。
所以本项目每次只取 20 条。
16.2 哨兵是什么 哨兵就是列表底部的一个小元素。
1 <div ref ="sentinel-ref" class ="h-2 mt-8" > </div >
它本身不重要,重要的是:当浏览器看到它时,说明用户快滚到底了,可以加载更多。
十七、跟做步骤:个人空间 17.1 后端
写 GetListCharacterView;
从 query 中读取 user_id 和 items_count;
查询用户和用户资料;
查询这个用户创建的角色;
用切片返回 20 条;
在 urls.py 中配置接口。
17.2 前端
创建 SpaceIndex.vue;
创建 UserInfoField.vue;
创建或复用 Character.vue;
页面加载时请求后端;
使用 grid 显示角色;
底部放哨兵;
哨兵出现时继续请求。
17.3 如何验证
用账号 A 创建几个角色;
打开账号 A 的个人空间,应该能看到角色;
用账号 B 创建角色;
打开账号 B 的个人空间,只能看到 B 的角色;
回到首页,应该能看到所有角色。
十八、跟做步骤:首页搜索 18.1 前端导航栏
搜索框用 v-model 绑定 searchQuery;
表单提交时调用 handleSearch;
使用 router.push 跳转首页;
把搜索词放进 URL query。
1 2 3 4 router.push ({ name : 'homepage-index' , query : { q : searchQuery.value .trim () }, })
18.2 首页页面
从 route.query.q 读取搜索词;
请求后端时传 search_query;
监听 query 改变;
query 改变后清空列表并重新加载。
18.3 后端接口
从 request.query_params 读取 search_query;
如果为空,查询全部;
如果不为空,用 Q 查询名字或简介;
分页返回。
十九、新手重点:为什么用 URL query 做搜索 例如搜索“机器人”后 URL 变成:
好处:
刷新页面不会丢失搜索词;
可以复制链接给别人;
浏览器前进后退能恢复搜索状态;
搜索框可以根据 URL 自动回填。
如果只把搜索词存在组件变量里,刷新页面就没了。
二十、新手重点:事件冒泡 假设角色卡片是一个大按钮,点击卡片会打开聊天框。
卡片里面还有一个删除按钮。如果点击删除按钮时不阻止冒泡,浏览器会认为你同时点击了:
删除按钮;
外层角色卡片。
结果就是:删除的同时还打开了聊天框。
解决:
1 <button @click.stop="handleRemoveFriend">删除</button>
.stop 表示事件到这个按钮就停止,不再传给外层。
二十一、常见报错排查 21.1 页面一直显示加载中 检查:
isLoading 是否在 finally 中恢复为 false;
接口是否报错;
data.result 是否和后端返回字段一致;
后端是否误写成 results;
Network 中请求是否成功。
21.2 无限重复请求 检查:
hasCharacters 是否在无数据时设为 false;
isLoading 是否能阻止重复请求;
checkSentinelVisible() 是否逻辑错误;
observer.observe 是否监听了正确元素。
21.3 搜索后结果没变 检查:
导航栏是否把搜索词写进 URL;
首页是否监听 route.query.q;
请求是否传了 search_query;
后端是否使用 Q(name__icontains=...);
搜索后是否清空旧列表。
21.4 删除好友时打开聊天框 检查删除按钮是否写了:
不是:
二十二、本节复习问题
为什么列表要分页加载?
items_count 表示什么?
IntersectionObserver 用来解决什么问题?
哨兵元素为什么放在列表底部?
CSS Grid 中 auto-fill 和 minmax 有什么效果?
搜索为什么用 URL query 保存?
Django Q 对象为什么能实现 OR 查询?
Friend 模型为什么不能直接用 Character 替代?
@click.stop 解决了什么问题?
二十三、核心接口完整请求 / 响应示例 23.1 获取个人空间角色列表 1 GET /api/character/getlist/?user_id=1&items_count=0
成功响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 { "result" : "success" , "user" : { "id" : 1 , "username" : "alice" , "photo" : "/media/user/photo/alice.png" , "background" : "/media/user/background/bg.png" , "description" : "AI 开发学习者" } , "characters" : [ { "id" : 12 , "name" : "小助手" , "description" : "擅长解释代码" , "photo" : "/media/character/photo/a.png" , "background" : "/media/character/background/b.png" } ] , "has_more" : true }
23.2 获取首页角色列表 1 GET /api/character/gethomepage/?items_count=0&search_query=助手
成功响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "result" : "success" , "characters" : [ { "id" : 12 , "name" : "小助手" , "description" : "擅长解释代码" , "author_id" : 1 , "author_name" : "alice" , "photo" : "/media/character/photo/a.png" } ] , "has_more" : false }
23.3 获取或创建好友关系 1 2 3 POST /api/friend/get_or_creat/ Authorization : Bearer <access_token>Content-Type : application/json
请求体:
成功响应:
1 2 3 4 5 6 7 8 9 { "result" : "success" , "friend_id" : 5 , "character" : { "id" : 12 , "name" : "小助手" , "photo" : "/media/character/photo/a.png" } }
23.4 删除好友关系 1 2 3 POST /api/friend/remove/ Authorization : Bearer <access_token>Content-Type : application/json
请求体:
成功响应:
1 2 3 { "result" : "success" }
二十四、关键代码逐行解释示例 24.1 IntersectionObserver 1 2 3 4 5 const observer = new IntersectionObserver ((entries ) => { if (entries[0 ].isIntersecting ) { loadMoreCharacters () } })
逐行解释:
代码
新手解释
new IntersectionObserver(...)
创建一个观察器,用来观察元素是否进入屏幕
entries
被观察元素的状态列表
entries[0]
当前项目通常只观察一个哨兵元素
isIntersecting
表示哨兵是否已经出现在视口中
loadMoreCharacters()
如果用户滚到底部,就加载下一页角色
24.2 Django Q 查询 1 2 3 4 characters = Character.objects.filter ( Q(name__icontains=search_query) | Q(description__icontains=search_query) )
逐行解释:
代码
新手解释
Q(...)
构造复杂查询条件
name__icontains
角色名包含关键词,不区分大小写
description__icontains
角色简介包含关键词,不区分大小写
`
`
filter(...)
从数据库中筛选符合条件的角色
二十五、Vue 组件数据传递图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 NavBar.vue │ 输入搜索词 ↓ router.push({ query: { q } }) ↓ HomePage.vue │ watch route.query.q ↓ 清空 characters 和 items_count ↓ 调用 getHomepageApi ↓ Character.vue 列表渲染 ↓ 点击角色卡片 ↓ get_or_creat Friend ↓ ChatField.vue 打开聊天框
个人空间:
1 2 3 4 5 6 7 8 9 10 11 12 SpaceIndex.vue │ route.params.user_id ↓ getListCharacterApi ↓ UserInfoField.vue 显示用户资料 ↓ Character.vue 显示角色卡片 ↓ 底部 sentinel 被看到 ↓ 加载下一页
二十六、后端视图处理流程 26.1 首页列表流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 收到 GET /character/gethomepage/ ↓ 读取 items_count 和 search_query ↓ 如果 search_query 为空,查询所有角色 ↓ 如果 search_query 不为空,用 Q 搜索名称和简介 ↓ 按 id 或创建时间排序 ↓ 切片取 items_count 到 items_count + 20 ↓ 判断是否还有更多 ↓ 返回角色列表
26.2 获取或创建 Friend 流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 收到 POST /friend/get_or_creat/ ↓ 检查用户是否登录 ↓ 读取 character_id ↓ 查询角色是否存在 ↓ 查找当前用户和该角色之间是否已有 Friend ↓ 如果有,直接返回旧 friend_id ↓ 如果没有,创建新的 Friend ↓ 返回 friend_id
二十七、小练习与自测答案 练习 1 问题:为什么无限滚动需要 isLoading?
答案:因为哨兵可能连续多次进入视口,如果没有 isLoading 防抖,会同时发出多个重复请求,造成列表重复和后端压力。
练习 2 问题:搜索后为什么要把旧列表清空?
答案:搜索条件变了,旧列表已经不属于新搜索结果。如果不清空,新旧结果会混在一起。
练习 3 问题:为什么点击角色卡片前要先获取或创建 Friend?
答案:聊天记录和记忆是按 Friend 保存的。只有拿到 friend_id,聊天模块才知道当前用户是在和哪个角色建立的哪一段关系聊天。