4.流式布局、首页、搜索与好友入口

4.流式布局、首页、搜索与好友入口

Lionlnuc
Lionlnuc Lv4

流式布局、首页、搜索与好友入口

一、目标

本节主要完成展示层与信息流相关功能:

  • 个人空间页面;
  • 角色卡片组件;
  • 无限滚动 / 流式加载;
  • 首页角色广场;
  • 搜索功能;
  • 好友模型与好友列表;
  • 点击角色进入聊天弹窗的前置逻辑。

涉及的重要知识点:

  • IntersectionObserver;
  • CSS Grid 自适应布局;
  • Django 分页查询;
  • Django ORM Q 对象;
  • 前端 query 参数搜索;
  • 事件冒泡与 @click.stop;
  • 外键级联删除。

二、个人空间模块

2.1 业务目标

个人空间用于展示某个用户的信息和他创建的 AI 角色。

页面路径示例:

1
/user/space/:user_id/

展示内容:

  • 用户头像;
  • 用户名;
  • 用户 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 User
from rest_framework.views import APIView
from rest_framework.response import Response

from web.models.character import Character
from web.models.user import UserProfile


class 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 Q
from rest_framework.views import APIView
from rest_framework.response import Response
from web.models.character import Character


class 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 的好处

使用:

1
/?q=关键词

好处:

  • 刷新页面后搜索条件不丢失;
  • 可以复制链接分享;
  • 浏览器前进后退能恢复搜索状态。

九、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 Q

Character.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/

逻辑:

  1. 当前用户必须已登录;
  2. 获取角色 ID;
  3. 如果已有 Friend,直接返回;
  4. 如果没有,创建 Friend;
  5. 返回 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>

不是:

1
@click.top

十三、路由汇总

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 },
}

十四、易错点总结

  1. 首页接口返回字段应统一为 result,不要写成 results;
  2. items_count 从 query 参数取出是字符串,要转成 int;
  3. 前端接口路径建议以 / 开头;
  4. IntersectionObserver 创建后必须 observe(sentinelRef.value);
  5. 组件卸载时必须 disconnect();
  6. 删除按钮要用 @click.stop;
  7. 搜索 query 改变后要清空旧列表再重新请求;
  8. 个人空间从一个用户切换到另一个用户时,要监听 route.params.user_id 并重置页面;
  9. 当前项目接口 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 后端

  1. 写 GetListCharacterView;
  2. 从 query 中读取 user_id 和 items_count;
  3. 查询用户和用户资料;
  4. 查询这个用户创建的角色;
  5. 用切片返回 20 条;
  6. 在 urls.py 中配置接口。

17.2 前端

  1. 创建 SpaceIndex.vue;
  2. 创建 UserInfoField.vue;
  3. 创建或复用 Character.vue;
  4. 页面加载时请求后端;
  5. 使用 grid 显示角色;
  6. 底部放哨兵;
  7. 哨兵出现时继续请求。

17.3 如何验证

  1. 用账号 A 创建几个角色;
  2. 打开账号 A 的个人空间,应该能看到角色;
  3. 用账号 B 创建角色;
  4. 打开账号 B 的个人空间,只能看到 B 的角色;
  5. 回到首页,应该能看到所有角色。

十八、跟做步骤:首页搜索

18.1 前端导航栏

  1. 搜索框用 v-model 绑定 searchQuery;
  2. 表单提交时调用 handleSearch;
  3. 使用 router.push 跳转首页;
  4. 把搜索词放进 URL query。
1
2
3
4
router.push({
name: 'homepage-index',
query: { q: searchQuery.value.trim() },
})

18.2 首页页面

  1. 从 route.query.q 读取搜索词;
  2. 请求后端时传 search_query;
  3. 监听 query 改变;
  4. query 改变后清空列表并重新加载。

18.3 后端接口

  1. 从 request.query_params 读取 search_query;
  2. 如果为空,查询全部;
  3. 如果不为空,用 Q 查询名字或简介;
  4. 分页返回。

十九、新手重点:为什么用 URL query 做搜索

例如搜索“机器人”后 URL 变成:

1
/?q=机器人

好处:

  1. 刷新页面不会丢失搜索词;
  2. 可以复制链接给别人;
  3. 浏览器前进后退能恢复搜索状态;
  4. 搜索框可以根据 URL 自动回填。

如果只把搜索词存在组件变量里,刷新页面就没了。


二十、新手重点:事件冒泡

假设角色卡片是一个大按钮,点击卡片会打开聊天框。

卡片里面还有一个删除按钮。如果点击删除按钮时不阻止冒泡,浏览器会认为你同时点击了:

  1. 删除按钮;
  2. 外层角色卡片。

结果就是:删除的同时还打开了聊天框。

解决:

1
<button @click.stop="handleRemoveFriend">删除</button>

.stop 表示事件到这个按钮就停止,不再传给外层。


二十一、常见报错排查

21.1 页面一直显示加载中

检查:

  1. isLoading 是否在 finally 中恢复为 false;
  2. 接口是否报错;
  3. data.result 是否和后端返回字段一致;
  4. 后端是否误写成 results;
  5. Network 中请求是否成功。

21.2 无限重复请求

检查:

  1. hasCharacters 是否在无数据时设为 false;
  2. isLoading 是否能阻止重复请求;
  3. checkSentinelVisible() 是否逻辑错误;
  4. observer.observe 是否监听了正确元素。

21.3 搜索后结果没变

检查:

  1. 导航栏是否把搜索词写进 URL;
  2. 首页是否监听 route.query.q;
  3. 请求是否传了 search_query;
  4. 后端是否使用 Q(name__icontains=...);
  5. 搜索后是否清空旧列表。

21.4 删除好友时打开聊天框

检查删除按钮是否写了:

1
@click.stop

不是:

1
@click.top

二十二、本节复习问题

  1. 为什么列表要分页加载?
  2. items_count 表示什么?
  3. IntersectionObserver 用来解决什么问题?
  4. 哨兵元素为什么放在列表底部?
  5. CSS Grid 中 auto-fill 和 minmax 有什么效果?
  6. 搜索为什么用 URL query 保存?
  7. Django Q 对象为什么能实现 OR 查询?
  8. Friend 模型为什么不能直接用 Character 替代?
  9. @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
{
"character_id": 12
}

成功响应:

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
{
"friend_id": 5
}

成功响应:

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,聊天模块才知道当前用户是在和哪个角色建立的哪一段关系聊天。

  • 标题: 4.流式布局、首页、搜索与好友入口
  • 作者: Lionlnuc
  • 创建于 : 2026-07-12 00:00:00
  • 更新于 : 2026-07-12 00:00:00
  • 链接: https://lionlnuc.github.io/2026/07/12/AgentTalk/4. 流式布局/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论
目录
4.流式布局、首页、搜索与好友入口