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:
256
frontend/src/pages/contact/blacklist.vue
Normal file
256
frontend/src/pages/contact/blacklist.vue
Normal 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>
|
||||
405
frontend/src/pages/contact/detail.vue
Normal file
405
frontend/src/pages/contact/detail.vue
Normal 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">›</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">›</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>
|
||||
336
frontend/src/pages/contact/groups.vue
Normal file
336
frontend/src/pages/contact/groups.vue
Normal 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">★</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">☰</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">✎</text>
|
||||
</view>
|
||||
<view class="icon-btn icon-btn--danger" @tap="handleDelete(group)">
|
||||
<text class="icon-btn-text icon-btn-text--danger">✕</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>
|
||||
@@ -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">☰</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">🔍</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">✉</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">›</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">⊘</text>
|
||||
</view>
|
||||
<text class="entry-label">黑名单</text>
|
||||
</view>
|
||||
<view class="entry-right">
|
||||
<text class="arrow">›</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>
|
||||
|
||||
337
frontend/src/pages/contact/request.vue
Normal file
337
frontend/src/pages/contact/request.vue
Normal 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>
|
||||
373
frontend/src/pages/contact/search.vue
Normal file
373
frontend/src/pages/contact/search.vue
Normal 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>
|
||||
Reference in New Issue
Block a user