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
This commit is contained in:
bujinyuan
2026-03-02 17:32:52 +08:00
parent 618c3f4409
commit a508a4cfbc
18 changed files with 3368 additions and 188 deletions

View File

@@ -0,0 +1,256 @@
<!--
黑名单管理页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B
ui-ux-pro-max 规范防重复提交 / 确认弹窗 / 过渡动画
-->
<template>
<view class="page-wrapper">
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-item">
<view class="skeleton-avatar"></view>
<view class="skeleton-info">
<view class="skeleton-line"></view>
</view>
<view class="skeleton-btn"></view>
</view>
</view>
<!-- 黑名单列表 -->
<view v-else-if="contactStore.blockList.length > 0" class="block-list">
<view
v-for="user in contactStore.blockList"
:key="user.user_id"
class="block-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
class="unblock-btn"
:class="{ 'unblock-btn--disabled': processingMap[user.user_id] }"
@tap="handleUnblock(user)"
>
<text class="unblock-btn-text">取消拉黑</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<text class="empty-title">黑名单为空</text>
<text class="empty-desc">被拉黑的用户会显示在这里</text>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useContactStore } from '@/store/contact'
const contactStore = useContactStore()
const loading = ref(true)
const processingMap = 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 handleUnblock = (user) => {
if (processingMap[user.user_id]) return
uni.showModal({
title: '取消拉黑',
content: `确定要取消拉黑 ${user.nickname || user.username} 吗?`,
success: async (res) => {
if (res.confirm) {
processingMap[user.user_id] = true
try {
await contactStore.unblockUser(user.user_id)
uni.showToast({ title: '已取消拉黑', icon: 'success' })
} catch (e) {
console.error('取消拉黑失败', e)
} finally {
processingMap[user.user_id] = false
}
}
}
})
}
onMounted(async () => {
try {
await contactStore.fetchBlockList()
} catch (e) {
console.error('获取黑名单失败', e)
}
loading.value = false
})
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
}
/* 骨架屏 */
.skeleton-list {
background-color: #FFFFFF;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
gap: 20rpx;
border-bottom: 1rpx solid #F1F5F9;
}
.skeleton-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-info {
flex: 1;
}
.skeleton-line {
width: 40%;
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-btn {
width: 120rpx;
height: 56rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 黑名单列表 */
.block-list {
background-color: #FFFFFF;
}
.block-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
gap: 20rpx;
}
.block-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;
}
.unblock-btn {
padding: 12rpx 24rpx;
border: 2rpx solid #E2E8F0;
border-radius: 12rpx;
flex-shrink: 0;
cursor: pointer;
transition: all 200ms ease;
}
.unblock-btn:active {
background-color: #F1F5F9;
}
.unblock-btn--disabled {
opacity: 0.5;
pointer-events: none;
}
.unblock-btn-text {
font-size: 24rpx;
color: #64748B;
font-weight: 500;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
}
.empty-title {
font-size: 32rpx;
color: #64748B;
font-weight: 500;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94A3B8;
}
</style>

View File

@@ -0,0 +1,405 @@
<!--
好友详情页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B / Danger #EF4444
ui-ux-pro-max 规范确认弹窗 / 防重复提交 / 过渡动画
-->
<template>
<view class="page-wrapper">
<view v-if="friend" class="detail-content">
<!-- 头像卡片 -->
<view class="profile-card">
<view class="avatar-lg" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
<text class="avatar-text-lg">{{ getInitial(friend.remark || friend.nickname || friend.username) }}</text>
</view>
<text class="profile-name">{{ friend.remark || friend.nickname || friend.username }}</text>
<text class="profile-username">@{{ friend.username }}</text>
<view class="online-badge" :class="friend.is_online ? 'online-badge--on' : 'online-badge--off'">
<view class="online-badge-dot"></view>
<text class="online-badge-text">{{ friend.is_online ? '在线' : '离线' }}</text>
</view>
</view>
<!-- 信息编辑 -->
<view class="info-section">
<view class="info-item" @tap="editRemark">
<text class="info-label">备注名</text>
<view class="info-value-wrap">
<text class="info-value">{{ friend.remark || '未设置' }}</text>
<text class="arrow">&#8250;</text>
</view>
</view>
<view class="info-item" @tap="selectGroup">
<text class="info-label">所属分组</text>
<view class="info-value-wrap">
<text class="info-value">{{ currentGroupName }}</text>
<text class="arrow">&#8250;</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-section">
<view class="action-btn action-btn--primary" @tap="sendMessage">
<text class="action-btn-text action-btn-text--primary">发消息</text>
</view>
</view>
<view class="danger-section">
<view class="danger-item" @tap="handleBlock">
<text class="danger-text">拉黑该用户</text>
</view>
<view class="danger-item danger-item--last" @tap="handleDelete">
<text class="danger-text">删除好友</text>
</view>
</view>
</view>
<!-- 未找到好友 -->
<view v-else-if="!loading" class="empty-state">
<text class="empty-title">未找到好友信息</text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useContactStore } from '@/store/contact'
const contactStore = useContactStore()
const loading = ref(true)
const friend = ref(null)
const userId = ref(0)
const processing = ref(false)
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 currentGroupName = computed(() => {
if (!friend.value || !friend.value.group_id) return '默认分组'
const group = contactStore.groups.find(g => g.id === friend.value.group_id)
return group ? group.name : '默认分组'
})
onMounted(async () => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
userId.value = Number(currentPage.options?.userId || 0)
if (contactStore.friendList.length === 0) {
await contactStore.fetchFriends()
}
if (contactStore.groups.length === 0) {
await contactStore.fetchGroups()
}
friend.value = contactStore.friendList.find(f => f.user_id === userId.value) || null
loading.value = false
})
const editRemark = () => {
uni.showModal({
title: '修改备注',
editable: true,
placeholderText: '请输入备注名',
content: friend.value?.remark || '',
success: async (res) => {
if (res.confirm && res.content !== undefined) {
try {
await contactStore.updateRemark(userId.value, res.content.trim())
friend.value = contactStore.friendList.find(f => f.user_id === userId.value)
uni.showToast({ title: '已更新', icon: 'success' })
} catch (e) {
console.error('更新备注失败', e)
}
}
}
})
}
const selectGroup = () => {
const groups = [{ id: 0, name: '默认分组' }, ...contactStore.groups]
const names = groups.map(g => g.name)
uni.showActionSheet({
itemList: names,
success: async (res) => {
const selectedGroup = groups[res.tapIndex]
try {
await contactStore.moveToGroup(userId.value, selectedGroup.id)
friend.value = contactStore.friendList.find(f => f.user_id === userId.value)
uni.showToast({ title: '已移动', icon: 'success' })
} catch (e) {
console.error('移动分组失败', e)
}
}
})
}
const sendMessage = () => {
uni.showToast({ title: '聊天功能开发中', icon: 'none' })
}
const handleBlock = () => {
if (processing.value) return
uni.showModal({
title: '确认拉黑',
content: `确定要拉黑 ${friend.value.remark || friend.value.nickname || friend.value.username} 吗?拉黑后将自动解除好友关系。`,
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
processing.value = true
try {
await contactStore.blockUser(userId.value)
uni.showToast({ title: '已拉黑', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
} catch (e) {
console.error('拉黑失败', e)
} finally {
processing.value = false
}
}
}
})
}
const handleDelete = () => {
if (processing.value) return
uni.showModal({
title: '确认删除',
content: `确定要删除好友 ${friend.value.remark || friend.value.nickname || friend.value.username} 吗?删除后需要重新申请添加。`,
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
processing.value = true
try {
await contactStore.deleteFriend(userId.value)
uni.showToast({ title: '已删除', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
} catch (e) {
console.error('删除好友失败', e)
} finally {
processing.value = false
}
}
}
})
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
}
/* 头像卡片 */
.profile-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 32rpx 40rpx;
background-color: #FFFFFF;
}
.avatar-lg {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
}
.avatar-text-lg {
font-size: 56rpx;
color: #FFFFFF;
font-weight: 600;
}
.profile-name {
font-size: 36rpx;
color: #1E293B;
font-weight: 700;
margin-bottom: 6rpx;
}
.profile-username {
font-size: 26rpx;
color: #94A3B8;
margin-bottom: 16rpx;
}
.online-badge {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 20rpx;
border-radius: 20rpx;
}
.online-badge--on {
background-color: #ECFDF5;
}
.online-badge--off {
background-color: #F1F5F9;
}
.online-badge-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
}
.online-badge--on .online-badge-dot {
background-color: #10B981;
}
.online-badge--off .online-badge-dot {
background-color: #CBD5E1;
}
.online-badge-text {
font-size: 22rpx;
font-weight: 500;
}
.online-badge--on .online-badge-text {
color: #059669;
}
.online-badge--off .online-badge-text {
color: #94A3B8;
}
/* 信息编辑 */
.info-section {
margin-top: 16rpx;
background-color: #FFFFFF;
}
.info-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
cursor: pointer;
transition: background-color 200ms ease;
}
.info-item:active {
background-color: #F8FAFC;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 30rpx;
color: #1E293B;
}
.info-value-wrap {
display: flex;
align-items: center;
gap: 8rpx;
}
.info-value {
font-size: 28rpx;
color: #94A3B8;
}
.arrow {
font-size: 28rpx;
color: #CBD5E1;
}
/* 操作按钮 */
.action-section {
margin-top: 32rpx;
padding: 0 32rpx;
}
.action-btn {
width: 100%;
height: 88rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 200ms ease;
}
.action-btn:active {
opacity: 0.8;
}
.action-btn--primary {
background-color: #2563EB;
}
.action-btn-text--primary {
font-size: 30rpx;
font-weight: 600;
color: #FFFFFF;
}
/* 危险操作 */
.danger-section {
margin-top: 32rpx;
background-color: #FFFFFF;
}
.danger-item {
display: flex;
align-items: center;
justify-content: center;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
cursor: pointer;
transition: background-color 200ms ease;
}
.danger-item:active {
background-color: #FEF2F2;
}
.danger-item--last {
border-bottom: none;
}
.danger-text {
font-size: 30rpx;
color: #EF4444;
font-weight: 500;
}
/* 空状态 */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding-top: 300rpx;
}
.empty-title {
font-size: 32rpx;
color: #64748B;
}
</style>

View File

@@ -0,0 +1,336 @@
<!--
好友分组管理页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B
ui-ux-pro-max 规范防重复提交 / 确认弹窗 / 过渡动画
-->
<template>
<view class="page-wrapper">
<!-- 新建分组 -->
<view class="create-bar" @tap="handleCreate">
<text class="create-icon">+</text>
<text class="create-text">新建分组</text>
</view>
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-item">
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--name"></view>
<view class="skeleton-line skeleton-line--count"></view>
</view>
</view>
</view>
<!-- 默认分组 -->
<view v-else class="group-list">
<view class="group-item group-item--default">
<view class="group-left">
<view class="group-icon group-icon--default">
<text class="group-icon-text">&#9733;</text>
</view>
<view class="group-info">
<text class="group-name">默认分组</text>
<text class="group-count">系统默认</text>
</view>
</view>
</view>
<!-- 自定义分组 -->
<view
v-for="group in contactStore.groups"
:key="group.id"
class="group-item"
>
<view class="group-left">
<view class="group-icon">
<text class="group-icon-text">&#9776;</text>
</view>
<view class="group-info">
<text class="group-name">{{ group.name }}</text>
<text class="group-count">{{ group.friend_count || 0 }} 位好友</text>
</view>
</view>
<view class="group-actions">
<view class="icon-btn" @tap="handleEdit(group)">
<text class="icon-btn-text">&#9998;</text>
</view>
<view class="icon-btn icon-btn--danger" @tap="handleDelete(group)">
<text class="icon-btn-text icon-btn-text--danger">&#10005;</text>
</view>
</view>
</view>
<!-- 空分组提示 -->
<view v-if="contactStore.groups.length === 0" class="empty-hint">
<text class="empty-hint-text">还没有自定义分组点击上方按钮创建</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useContactStore } from '@/store/contact'
const contactStore = useContactStore()
const loading = ref(true)
const processing = ref(false)
onMounted(async () => {
try {
await contactStore.fetchGroups()
} catch (e) {
console.error('获取分组失败', e)
}
loading.value = false
})
const handleCreate = () => {
if (processing.value) return
uni.showModal({
title: '新建分组',
editable: true,
placeholderText: '请输入分组名称',
content: '',
success: async (res) => {
if (res.confirm && res.content?.trim()) {
processing.value = true
try {
await contactStore.createGroup(res.content.trim())
uni.showToast({ title: '创建成功', icon: 'success' })
} catch (e) {
console.error('创建分组失败', e)
} finally {
processing.value = false
}
}
}
})
}
const handleEdit = (group) => {
if (processing.value) return
uni.showModal({
title: '修改分组名',
editable: true,
placeholderText: '请输入新的分组名称',
content: group.name,
success: async (res) => {
if (res.confirm && res.content?.trim() && res.content.trim() !== group.name) {
processing.value = true
try {
await contactStore.updateGroup(group.id, res.content.trim())
uni.showToast({ title: '已修改', icon: 'success' })
} catch (e) {
console.error('修改分组失败', e)
} finally {
processing.value = false
}
}
}
})
}
const handleDelete = (group) => {
if (processing.value) return
uni.showModal({
title: '确认删除',
content: `确定要删除分组"${group.name}"吗?分组内的好友将移至默认分组。`,
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
processing.value = true
try {
await contactStore.deleteGroup(group.id)
uni.showToast({ title: '已删除', icon: 'none' })
} catch (e) {
console.error('删除分组失败', e)
} finally {
processing.value = false
}
}
}
})
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
}
/* 新建分组 */
.create-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 28rpx 32rpx;
background-color: #FFFFFF;
margin-bottom: 16rpx;
cursor: pointer;
transition: background-color 200ms ease;
}
.create-bar:active {
background-color: #F8FAFC;
}
.create-icon {
font-size: 36rpx;
color: #2563EB;
font-weight: 300;
}
.create-text {
font-size: 30rpx;
color: #2563EB;
font-weight: 500;
}
/* 骨架屏 */
.skeleton-list {
background-color: #FFFFFF;
}
.skeleton-item {
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
}
.skeleton-info {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.skeleton-line {
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line--name {
width: 40%;
height: 28rpx;
}
.skeleton-line--count {
width: 25%;
height: 22rpx;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 分组列表 */
.group-list {
background-color: #FFFFFF;
}
.group-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
}
.group-item:last-child {
border-bottom: none;
}
.group-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.group-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
background-color: #DBEAFE;
display: flex;
align-items: center;
justify-content: center;
}
.group-icon--default {
background-color: #FEF3C7;
}
.group-icon-text {
font-size: 28rpx;
}
.group-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.group-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
}
.group-count {
font-size: 24rpx;
color: #94A3B8;
}
.group-actions {
display: flex;
gap: 16rpx;
}
.icon-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: #F1F5F9;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 200ms ease;
}
.icon-btn:active {
background-color: #E2E8F0;
}
.icon-btn--danger:active {
background-color: #FEE2E2;
}
.icon-btn-text {
font-size: 24rpx;
color: #64748B;
}
.icon-btn-text--danger {
color: #EF4444;
}
/* 空提示 */
.empty-hint {
padding: 40rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-hint-text {
font-size: 26rpx;
color: #94A3B8;
}
</style>

View File

@@ -1,27 +1,172 @@
<!--
联系人页占位
联系人列表TabBar 页面
设计系统design-system/echochat/MASTER.md
TabBar 页面Phase 2好友模块中实现具体功能
色板Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
ui-ux-pro-max 规范v-for :key / 触摸目标 >= 88rpx / 骨架屏 loading / cursor-pointer
-->
<template>
<view class="page-wrapper">
<view class="placeholder-content">
<text class="placeholder-icon">👥</text>
<text class="placeholder-title">联系人</text>
<text class="placeholder-desc">好友管理功能开发中</text>
<!-- 顶部栏 -->
<view class="header">
<text class="header-title">联系人</text>
<view class="header-actions">
<view class="action-btn" @tap="goToGroups">
<text class="action-icon">&#9776;</text>
</view>
<view class="action-btn" @tap="goToSearch">
<text class="action-icon">+</text>
</view>
</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input-wrap">
<text class="search-icon">&#128269;</text>
<input
class="search-input"
v-model="searchKeyword"
placeholder="搜索好友"
placeholder-style="color: #94A3B8"
confirm-type="search"
/>
</view>
</view>
<!-- 功能入口 -->
<view class="entry-section">
<view class="entry-item" @tap="goToRequests">
<view class="entry-left">
<view class="entry-icon entry-icon--request">
<text class="entry-icon-text">&#9993;</text>
</view>
<text class="entry-label">好友申请</text>
</view>
<view class="entry-right">
<view v-if="contactStore.pendingCount > 0" class="badge">
{{ contactStore.pendingCount > 99 ? '99+' : contactStore.pendingCount }}
</view>
<text class="arrow">&#8250;</text>
</view>
</view>
<view class="entry-item" @tap="goToBlacklist">
<view class="entry-left">
<view class="entry-icon entry-icon--block">
<text class="entry-icon-text">&#8856;</text>
</view>
<text class="entry-label">黑名单</text>
</view>
<view class="entry-right">
<text class="arrow">&#8250;</text>
</view>
</view>
</view>
<!-- 好友列表 -->
<view class="section-header" v-if="!loading">
<text class="section-title">好友列表</text>
<text class="section-count">{{ filteredFriends.length }} </text>
</view>
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item">
<view class="skeleton-avatar"></view>
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--name"></view>
<view class="skeleton-line skeleton-line--sub"></view>
</view>
</view>
</view>
<!-- 好友列表 -->
<view v-else-if="filteredFriends.length > 0" class="friend-list">
<view
v-for="friend in filteredFriends"
:key="friend.user_id"
class="friend-item"
@tap="goToDetail(friend)"
>
<view class="avatar-wrap">
<view class="avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
<text class="avatar-text">{{ getInitial(friend.remark || friend.nickname || friend.username) }}</text>
</view>
<view v-if="friend.is_online" class="online-dot"></view>
</view>
<view class="friend-info">
<text class="friend-name">{{ friend.remark || friend.nickname || friend.username }}</text>
<text class="friend-status">{{ friend.is_online ? '在线' : '离线' }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<text class="empty-title">暂无好友</text>
<text class="empty-desc">点击右上角 "+" 搜索并添加好友</text>
</view>
<CustomTabBar :current="1" />
</view>
</template>
<script>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useContactStore } from '@/store/contact'
import { useWebSocketStore } from '@/store/websocket'
import { useUserStore } from '@/store/user'
import CustomTabBar from '@/components/CustomTabBar.vue'
export default {
name: 'ContactIndex',
components: { CustomTabBar }
const contactStore = useContactStore()
const wsStore = useWebSocketStore()
const userStore = useUserStore()
const searchKeyword = ref('')
const loading = ref(true)
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#7C3AED', '#4F46E5']
const getAvatarColor = (name) => {
if (!name) return AVATAR_COLORS[0]
const code = name.charCodeAt(0)
return AVATAR_COLORS[code % AVATAR_COLORS.length]
}
const getInitial = (name) => {
if (!name) return '?'
return name.charAt(0).toUpperCase()
}
const filteredFriends = computed(() => {
if (!searchKeyword.value) return contactStore.friendList
const kw = searchKeyword.value.toLowerCase()
return contactStore.friendList.filter(f =>
(f.remark || f.nickname || f.username || '').toLowerCase().includes(kw)
)
})
onMounted(async () => {
if (userStore.isLoggedIn) {
wsStore.connect()
contactStore.initWsListeners()
try {
await Promise.all([
contactStore.fetchFriends(),
contactStore.fetchPendingRequests()
])
} catch (e) {
console.error('获取联系人数据失败', e)
}
}
loading.value = false
})
const goToSearch = () => uni.navigateTo({ url: '/pages/contact/search' })
const goToRequests = () => uni.navigateTo({ url: '/pages/contact/request' })
const goToDetail = (friend) => uni.navigateTo({ url: `/pages/contact/detail?userId=${friend.user_id}` })
const goToGroups = () => uni.navigateTo({ url: '/pages/contact/groups' })
const goToBlacklist = () => uni.navigateTo({ url: '/pages/contact/blacklist' })
</script>
<style scoped>
@@ -31,28 +176,319 @@ export default {
padding-bottom: 120rpx;
}
.placeholder-content {
/* 顶部栏 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
padding-top: calc(var(--status-bar-height, 44px) + 20rpx);
background-color: #FFFFFF;
}
.header-title {
font-size: 36rpx;
font-weight: 700;
color: #1E293B;
}
.header-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #F1F5F9;
cursor: pointer;
transition: background-color 200ms ease;
}
.action-btn:active {
background-color: #E2E8F0;
}
.action-icon {
font-size: 36rpx;
color: #1E293B;
line-height: 1;
}
/* 搜索栏 */
.search-bar {
padding: 16rpx 32rpx;
background-color: #FFFFFF;
}
.search-input-wrap {
display: flex;
align-items: center;
background-color: #F1F5F9;
border-radius: 16rpx;
padding: 0 24rpx;
height: 72rpx;
}
.search-icon {
font-size: 28rpx;
color: #94A3B8;
margin-right: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
height: 72rpx;
}
/* 功能入口 */
.entry-section {
margin-top: 16rpx;
background-color: #FFFFFF;
}
.entry-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
cursor: pointer;
transition: background-color 200ms ease;
}
.entry-item:active {
background-color: #F8FAFC;
}
.entry-item:last-child {
border-bottom: none;
}
.entry-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.entry-icon {
width: 72rpx;
height: 72rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.entry-icon--request {
background-color: #DBEAFE;
}
.entry-icon--block {
background-color: #FEE2E2;
}
.entry-icon-text {
font-size: 32rpx;
}
.entry-label {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
}
.entry-right {
display: flex;
align-items: center;
gap: 12rpx;
}
.badge {
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: 18rpx;
background-color: #EF4444;
color: #FFFFFF;
font-size: 22rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.arrow {
font-size: 28rpx;
color: #CBD5E1;
}
/* section header */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
}
.section-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
.section-count {
font-size: 24rpx;
color: #CBD5E1;
}
/* 骨架屏 */
.skeleton-list {
background-color: #FFFFFF;
margin-top: 4rpx;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
gap: 20rpx;
}
.skeleton-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.skeleton-line {
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line--name {
width: 50%;
height: 28rpx;
}
.skeleton-line--sub {
width: 30%;
height: 22rpx;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 好友列表 */
.friend-list {
background-color: #FFFFFF;
}
.friend-item {
display: flex;
align-items: center;
padding: 20rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
cursor: pointer;
transition: background-color 200ms ease;
}
.friend-item:active {
background-color: #F8FAFC;
}
.friend-item:last-child {
border-bottom: none;
}
.avatar-wrap {
position: relative;
margin-right: 20rpx;
flex-shrink: 0;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 32rpx;
color: #FFFFFF;
font-weight: 600;
}
.online-dot {
position: absolute;
right: 0;
bottom: 0;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background-color: #10B981;
border: 3rpx solid #FFFFFF;
}
.friend-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.friend-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
}
.friend-status {
font-size: 24rpx;
color: #94A3B8;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
padding-top: 200rpx;
}
.placeholder-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.placeholder-title {
font-size: 36rpx;
font-weight: 600;
color: #1E293B;
.empty-title {
font-size: 32rpx;
color: #64748B;
font-weight: 500;
margin-bottom: 12rpx;
}
.placeholder-desc {
font-size: 28rpx;
.empty-desc {
font-size: 26rpx;
color: #94A3B8;
}
</style>

View File

@@ -0,0 +1,337 @@
<!--
好友申请列表页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
ui-ux-pro-max 规范防重复提交 / 骨架屏 / 过渡动画
-->
<template>
<view class="page-wrapper">
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item">
<view class="skeleton-avatar"></view>
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--name"></view>
<view class="skeleton-line skeleton-line--msg"></view>
</view>
<view class="skeleton-actions">
<view class="skeleton-btn"></view>
<view class="skeleton-btn"></view>
</view>
</view>
</view>
<!-- 申请列表 -->
<view v-else-if="contactStore.pendingRequests.length > 0" class="request-list">
<view
v-for="req in contactStore.pendingRequests"
:key="req.id"
class="request-item"
>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(req.nickname || req.username) }">
<text class="avatar-text">{{ getInitial(req.nickname || req.username) }}</text>
</view>
<view class="request-info">
<text class="request-name">{{ req.nickname || req.username }}</text>
<text class="request-msg" v-if="req.message">{{ req.message }}</text>
<text class="request-msg" v-else>请求添加你为好友</text>
<text class="request-time">{{ formatTime(req.created_at) }}</text>
</view>
<view class="request-actions">
<view
class="btn btn--accept"
:class="{ 'btn--disabled': processingMap[req.id] }"
@tap="handleAccept(req.id)"
>
<text class="btn-text">接受</text>
</view>
<view
class="btn btn--reject"
:class="{ 'btn--disabled': processingMap[req.id] }"
@tap="handleReject(req.id)"
>
<text class="btn-text btn-text--reject">拒绝</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<text class="empty-title">暂无好友申请</text>
<text class="empty-desc">当有人向你发送好友申请时会在这里显示</text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { useContactStore } from '@/store/contact'
const contactStore = useContactStore()
const loading = ref(true)
const processingMap = 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 formatTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const now = new Date()
const diff = now - date
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 7) return `${days} 天前`
return `${date.getMonth() + 1}/${date.getDate()}`
}
const handleAccept = async (requestId) => {
if (processingMap[requestId]) return
processingMap[requestId] = true
try {
await contactStore.acceptRequest(requestId)
uni.showToast({ title: '已接受', icon: 'success' })
} catch (e) {
console.error('接受好友申请失败', e)
} finally {
processingMap[requestId] = false
}
}
const handleReject = async (requestId) => {
if (processingMap[requestId]) return
processingMap[requestId] = true
try {
await contactStore.rejectRequest(requestId)
uni.showToast({ title: '已拒绝', icon: 'none' })
} catch (e) {
console.error('拒绝好友申请失败', e)
} finally {
processingMap[requestId] = false
}
}
onMounted(async () => {
try {
await contactStore.fetchPendingRequests()
} catch (e) {
console.error('获取好友申请失败', e)
}
loading.value = false
})
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
}
/* 骨架屏 */
.skeleton-list {
background-color: #FFFFFF;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
gap: 20rpx;
border-bottom: 1rpx solid #F1F5F9;
}
.skeleton-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.skeleton-line {
border-radius: 8rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line--name {
width: 40%;
height: 28rpx;
}
.skeleton-line--msg {
width: 60%;
height: 24rpx;
}
.skeleton-actions {
display: flex;
gap: 12rpx;
}
.skeleton-btn {
width: 100rpx;
height: 56rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 申请列表 */
.request-list {
background-color: #FFFFFF;
}
.request-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #F1F5F9;
gap: 20rpx;
}
.request-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;
}
.request-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
min-width: 0;
}
.request-name {
font-size: 30rpx;
color: #1E293B;
font-weight: 500;
}
.request-msg {
font-size: 24rpx;
color: #64748B;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-time {
font-size: 22rpx;
color: #94A3B8;
}
.request-actions {
display: flex;
flex-direction: column;
gap: 12rpx;
flex-shrink: 0;
}
.btn {
padding: 12rpx 28rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 200ms ease;
}
.btn:active {
opacity: 0.7;
}
.btn--accept {
background-color: #2563EB;
}
.btn--reject {
background-color: #F1F5F9;
}
.btn--disabled {
opacity: 0.5;
pointer-events: none;
}
.btn-text {
font-size: 24rpx;
font-weight: 500;
color: #FFFFFF;
}
.btn-text--reject {
color: #64748B;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
}
.empty-title {
font-size: 32rpx;
color: #64748B;
font-weight: 500;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94A3B8;
text-align: center;
padding: 0 60rpx;
}
</style>

View File

@@ -0,0 +1,373 @@
<!--
搜索添加好友页
设计系统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>