Files
EchoChat/frontend/src/pages/contact/search.vue
bujinyuan a508a4cfbc feat(phase2a): 前台联系人页面 + 管理端在线监控/好友管理 + API 文档
前台联系人模块(ui-ux-pro-max 规范):
- contact/index.vue: 好友列表(搜索/在线状态/骨架屏)
- contact/request.vue: 好友申请列表(接受/拒绝/防重复提交)
- contact/detail.vue: 好友详情(备注/分组/拉黑/删除)
- contact/search.vue: 搜索添加好友 + 好友推荐
- contact/groups.vue: 好友分组管理(CRUD)
- contact/blacklist.vue: 黑名单管理

管理端前端:
- views/monitor/online.vue: 在线监控(统计卡片/用户表格/30s 自动刷新)
- views/contact/list.vue: 好友关系管理(分页表格/强制删除)
- api/monitor.js + api/contact.js: 管理端 API 封装
- 路由 + 侧边栏导航更新

API 文档(4 份):
- docs/api/frontend/contact.md: 17 个接口完整文档
- docs/api/frontend/websocket.md: 前端 WS 事件协议
- docs/api/admin/online.md: 在线监控 API
- docs/api/admin/contact.md: 好友管理 API

进度文档更新至 Phase 2a 全部完成

Made-with: Cursor
2026-03-02 17:32:52 +08:00

374 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
搜索添加好友页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B
ui-ux-pro-max 规范防重复提交 / loading 状态 / 触摸目标 >= 88rpx
-->
<template>
<view class="page-wrapper">
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input-wrap">
<input
class="search-input"
v-model="keyword"
placeholder="输入用户名或昵称搜索"
placeholder-style="color: #94A3B8"
confirm-type="search"
@confirm="doSearch"
/>
</view>
<view class="search-btn" @tap="doSearch">
<text class="search-btn-text">搜索</text>
</view>
</view>
<!-- 搜索结果 -->
<view v-if="searched" class="result-section">
<view class="section-header">
<text class="section-title">搜索结果</text>
</view>
<view v-if="searchLoading" class="loading-wrap">
<text class="loading-text">搜索中...</text>
</view>
<view v-else-if="searchResults.length > 0" class="result-list">
<view
v-for="user in searchResults"
:key="user.user_id"
class="user-item"
>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
<text class="avatar-text">{{ getInitial(user.nickname || user.username) }}</text>
</view>
<view class="user-info">
<text class="user-name">{{ user.nickname || user.username }}</text>
<text class="user-account">@{{ user.username }}</text>
</view>
<view v-if="user.is_friend" class="status-tag status-tag--friend">
<text class="status-tag-text">已是好友</text>
</view>
<view
v-else
class="add-btn"
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
@tap="showAddDialog(user)"
>
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">未找到相关用户</text>
</view>
</view>
<!-- 好友推荐 -->
<view v-if="!searched" class="recommend-section">
<view class="section-header">
<text class="section-title">好友推荐</text>
</view>
<view v-if="recommendLoading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="recommendations.length > 0" class="result-list">
<view
v-for="user in recommendations"
:key="user.user_id"
class="user-item"
>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
<text class="avatar-text">{{ getInitial(user.nickname || user.username) }}</text>
</view>
<view class="user-info">
<text class="user-name">{{ user.nickname || user.username }}</text>
<text class="user-desc" v-if="user.common_count">{{ user.common_count }} 位共同好友</text>
</view>
<view
class="add-btn"
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
@tap="showAddDialog(user)"
>
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">暂无推荐好友</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import contactApi from '@/api/contact'
import { useContactStore } from '@/store/contact'
const contactStore = useContactStore()
const keyword = ref('')
const searched = ref(false)
const searchLoading = ref(false)
const searchResults = ref([])
const recommendLoading = ref(true)
const recommendations = ref([])
const addingMap = reactive({})
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
const getAvatarColor = (name) => {
if (!name) return AVATAR_COLORS[0]
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
}
const getInitial = (name) => {
if (!name) return '?'
return name.charAt(0).toUpperCase()
}
const doSearch = async () => {
const kw = keyword.value.trim()
if (!kw) {
uni.showToast({ title: '请输入搜索关键词', icon: 'none' })
return
}
searched.value = true
searchLoading.value = true
try {
const res = await contactApi.searchUsers(kw)
searchResults.value = res.data || []
} catch (e) {
console.error('搜索用户失败', e)
searchResults.value = []
}
searchLoading.value = false
}
const showAddDialog = (user) => {
if (addingMap[user.user_id]) return
uni.showModal({
title: '添加好友',
editable: true,
placeholderText: '请输入验证消息(可选)',
content: '',
success: async (res) => {
if (res.confirm) {
addingMap[user.user_id] = true
try {
await contactStore.sendRequest(user.user_id, res.content || '')
uni.showToast({ title: '申请已发送', icon: 'success' })
} catch (e) {
console.error('发送好友申请失败', e)
addingMap[user.user_id] = false
}
}
}
})
}
onMounted(async () => {
try {
const res = await contactApi.getRecommendFriends()
recommendations.value = res.data || []
} catch (e) {
console.error('获取推荐好友失败', e)
}
recommendLoading.value = false
})
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
}
/* 搜索栏 */
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background-color: #FFFFFF;
gap: 16rpx;
}
.search-input-wrap {
flex: 1;
background-color: #F1F5F9;
border-radius: 16rpx;
padding: 0 24rpx;
height: 72rpx;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
font-size: 28rpx;
color: #1E293B;
height: 72rpx;
}
.search-btn {
padding: 0 28rpx;
height: 72rpx;
background-color: #2563EB;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 200ms ease;
}
.search-btn:active {
opacity: 0.8;
}
.search-btn-text {
font-size: 28rpx;
color: #FFFFFF;
font-weight: 500;
}
/* section */
.section-header {
padding: 20rpx 32rpx;
}
.section-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
/* loading */
.loading-wrap {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
font-size: 28rpx;
color: #94A3B8;
}
/* 结果列表 */
.result-list {
background-color: #FFFFFF;
}
.user-item {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
gap: 20rpx;
}
.user-item:last-child {
border-bottom: none;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
font-size: 32rpx;
color: #FFFFFF;
font-weight: 600;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
min-width: 0;
}
.user-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
}
.user-account {
font-size: 24rpx;
color: #94A3B8;
}
.user-desc {
font-size: 24rpx;
color: #64748B;
}
.status-tag {
padding: 8rpx 20rpx;
border-radius: 12rpx;
flex-shrink: 0;
}
.status-tag--friend {
background-color: #F1F5F9;
}
.status-tag-text {
font-size: 24rpx;
color: #94A3B8;
}
.add-btn {
padding: 12rpx 28rpx;
background-color: #2563EB;
border-radius: 12rpx;
flex-shrink: 0;
cursor: pointer;
transition: opacity 200ms ease;
}
.add-btn:active {
opacity: 0.8;
}
.add-btn--disabled {
background-color: #CBD5E1;
pointer-events: none;
}
.add-btn-text {
font-size: 24rpx;
color: #FFFFFF;
font-weight: 500;
}
/* 空状态 */
.empty-state {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: #94A3B8;
}
</style>