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

34
admin/src/api/contact.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* 好友关系管理 API 模块(管理端)
*
* 对应后端路由:/api/v1/admin/contacts/*
* 所有接口需要 JWT + admin 角色权限
*/
import request from '@/utils/request'
/**
* 获取所有好友关系列表(分页)
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.page_size - 每页数量
* @returns {Promise<{data: {total: number, list: Array}}>}
*/
export function getAllContacts(params) {
return request({
url: '/api/v1/admin/contacts',
method: 'get',
params
})
}
/**
* 删除好友关系
* @param {number} id - 好友关系 ID
* @returns {Promise}
*/
export function deleteContact(id) {
return request({
url: `/api/v1/admin/contacts/${id}`,
method: 'delete'
})
}

29
admin/src/api/monitor.js Normal file
View File

@@ -0,0 +1,29 @@
/**
* 在线监控 API 模块
*
* 对应后端路由:/api/v1/admin/online/*
* 所有接口需要 JWT + admin 角色权限
*/
import request from '@/utils/request'
/**
* 获取在线用户列表
* @returns {Promise<{data: Array<{user_id: number, username: string}>}>}
*/
export function getOnlineUsers() {
return request({
url: '/api/v1/admin/online/users',
method: 'get'
})
}
/**
* 获取在线用户数量
* @returns {Promise<{data: {count: number}}>}
*/
export function getOnlineCount() {
return request({
url: '/api/v1/admin/online/count',
method: 'get'
})
}

View File

@@ -44,6 +44,18 @@ const routes = [
name: 'UserDetail', name: 'UserDetail',
component: () => import('@/views/user/detail.vue'), component: () => import('@/views/user/detail.vue'),
meta: { title: '用户详情' } meta: { title: '用户详情' }
},
{
path: 'monitor/online',
name: 'OnlineMonitor',
component: () => import('@/views/monitor/online.vue'),
meta: { title: '在线监控' }
},
{
path: 'contact/list',
name: 'ContactManage',
component: () => import('@/views/contact/list.vue'),
meta: { title: '好友管理' }
} }
] ]
} }

View File

@@ -0,0 +1,197 @@
<!--
好友关系管理页面管理端
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / Danger #EF4444 / Text #1E293B
ui-ux-pro-max 规范数据表格 / 分页 / 确认删除 / loading 状态
功能
- 好友关系列表分页表格
- 删除好友关系双向解除
- 搜索暂不支持后端预留
-->
<template>
<div class="contact-manage-page">
<!-- 页面标题 -->
<div class="page-header">
<h2 class="page-title">好友关系管理</h2>
</div>
<!-- 数据表格 -->
<el-card shadow="never" class="table-card">
<el-table
v-loading="loading"
:data="contactList"
stripe
border
style="width: 100%"
empty-text="暂无好友关系数据"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column label="用户 A" min-width="150">
<template #default="{ row }">
<div class="user-cell">
<span class="user-id">#{{ row.user_id }}</span>
<span class="username">{{ row.username || '--' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="用户 B" min-width="150">
<template #default="{ row }">
<div class="user-cell">
<span class="user-id">#{{ row.friend_id }}</span>
<span class="username">{{ row.friend_username || '--' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120">
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAllContacts, deleteContact } from '@/api/contact'
const loading = ref(false)
const contactList = ref([])
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const getStatusType = (status) => {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger' }
return map[status] || 'info'
}
const getStatusText = (status) => {
const map = { 0: '待确认', 1: '已通过', 2: '已拒绝', 3: '已拉黑' }
return map[status] || '未知'
}
const fetchList = async () => {
loading.value = true
try {
const res = await getAllContacts({
page: pagination.page,
page_size: pagination.pageSize
})
contactList.value = res.data?.list || []
pagination.total = res.data?.total || 0
} catch (e) {
console.error('获取好友关系列表失败', e)
} finally {
loading.value = false
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确定要删除用户 ${row.username || row.user_id}${row.friend_username || row.friend_id} 之间的好友关系吗?此操作将双向解除。`,
'删除确认',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
)
await deleteContact(row.id)
ElMessage.success('好友关系已删除')
fetchList()
} catch (err) {
if (err !== 'cancel' && err !== 'close') {
console.error('删除好友关系失败', err)
}
}
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.contact-manage-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1E293B;
margin: 0;
}
.table-card {
margin-bottom: 16px;
}
.user-cell {
display: flex;
align-items: center;
gap: 8px;
}
.user-id {
font-size: 12px;
color: #94A3B8;
font-family: monospace;
}
.username {
font-weight: 500;
color: #1E293B;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
</style>

View File

@@ -39,6 +39,19 @@
</template> </template>
<el-menu-item index="/user/list">用户列表</el-menu-item> <el-menu-item index="/user/list">用户列表</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="contact-manage">
<template #title>
<el-icon><Connection /></el-icon>
<span>联系人管理</span>
</template>
<el-menu-item index="/contact/list">好友关系</el-menu-item>
</el-sub-menu>
<el-menu-item index="/monitor/online">
<el-icon><Monitor /></el-icon>
<template #title>在线监控</template>
</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>

View File

@@ -0,0 +1,267 @@
<!--
在线监控页面管理端
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / Text #1E293B
ui-ux-pro-max 规范数据表格 / 自动刷新 / loading 状态
功能
- 在线用户数量统计卡片
- 在线用户列表表格展示
- 自动刷新30 秒轮询
- 手动刷新按钮
-->
<template>
<div class="online-monitor-page">
<!-- 页面标题 -->
<div class="page-header">
<h2 class="page-title">在线监控</h2>
<div class="header-actions">
<el-tag :type="autoRefresh ? 'success' : 'info'" size="small" class="refresh-tag">
{{ autoRefresh ? '自动刷新中' : '已暂停' }}
</el-tag>
<el-switch v-model="autoRefresh" active-text="自动" inactive-text="手动" />
<el-button :icon="Refresh" @click="fetchData" :loading="loading">刷新</el-button>
</div>
</div>
<!-- 统计卡片 -->
<el-row :gutter="16" class="stat-cards">
<el-col :span="8">
<el-card shadow="never" class="stat-card">
<div class="stat-content">
<div class="stat-number" :class="{ 'stat-loading': loading }">
{{ onlineCount }}
</div>
<div class="stat-label">当前在线</div>
</div>
<div class="stat-indicator stat-indicator--online"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="stat-card">
<div class="stat-content">
<div class="stat-number">{{ lastRefreshTime }}</div>
<div class="stat-label">最后刷新</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="stat-card">
<div class="stat-content">
<div class="stat-number">30s</div>
<div class="stat-label">刷新间隔</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 在线用户表格 -->
<el-card shadow="never" class="table-card">
<template #header>
<div class="card-header">
<span>在线用户列表</span>
<el-tag size="small" type="success">{{ onlineUsers.length }} </el-tag>
</div>
</template>
<el-table
v-loading="loading"
:data="onlineUsers"
stripe
border
style="width: 100%"
empty-text="暂无在线用户"
>
<el-table-column type="index" label="#" width="60" align="center" />
<el-table-column prop="user_id" label="用户 ID" width="120" align="center" />
<el-table-column prop="username" label="用户名" min-width="180">
<template #default="{ row }">
<span class="username-cell">{{ row.username }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default>
<div class="online-status">
<span class="online-dot"></span>
<span>在线</span>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getOnlineUsers, getOnlineCount } from '@/api/monitor'
const loading = ref(false)
const onlineCount = ref(0)
const onlineUsers = ref([])
const autoRefresh = ref(true)
const lastRefreshTime = ref('--')
let timer = null
const formatTime = () => {
const now = new Date()
const h = String(now.getHours()).padStart(2, '0')
const m = String(now.getMinutes()).padStart(2, '0')
const s = String(now.getSeconds()).padStart(2, '0')
return `${h}:${m}:${s}`
}
const fetchData = async () => {
loading.value = true
try {
const [countRes, usersRes] = await Promise.all([
getOnlineCount(),
getOnlineUsers()
])
onlineCount.value = countRes.data?.count || 0
onlineUsers.value = usersRes.data || []
lastRefreshTime.value = formatTime()
} catch (e) {
console.error('获取在线数据失败', e)
} finally {
loading.value = false
}
}
const startAutoRefresh = () => {
stopAutoRefresh()
timer = setInterval(fetchData, 30000)
}
const stopAutoRefresh = () => {
if (timer) {
clearInterval(timer)
timer = null
}
}
watch(autoRefresh, (val) => {
if (val) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
})
onMounted(() => {
fetchData()
if (autoRefresh.value) startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.online-monitor-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1E293B;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.refresh-tag {
font-size: 12px;
}
/* 统计卡片 */
.stat-cards {
margin-bottom: 16px;
}
.stat-card {
position: relative;
overflow: hidden;
}
.stat-content {
text-align: center;
padding: 8px 0;
}
.stat-number {
font-size: 28px;
font-weight: 700;
color: #1E293B;
margin-bottom: 4px;
transition: opacity 200ms ease;
}
.stat-loading {
opacity: 0.5;
}
.stat-label {
font-size: 13px;
color: #94A3B8;
}
.stat-indicator {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
}
.stat-indicator--online {
background-color: #10B981;
}
/* 表格 */
.table-card {
margin-bottom: 16px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.username-cell {
font-weight: 500;
color: #2563EB;
}
.online-status {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 13px;
color: #059669;
}
.online-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #10B981;
}
</style>

78
docs/api/admin/contact.md Normal file
View File

@@ -0,0 +1,78 @@
# 管理端好友关系管理 API
> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md)
> 所有接口需要 JWT + admin/super_admin 角色权限
---
## 接口列表
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/v1/admin/contacts | admin | 获取所有好友关系列表(分页) |
| DELETE | /api/v1/admin/contacts/:id | admin | 删除好友关系(双向解除) |
---
## 1. 获取所有好友关系列表
`GET /api/v1/admin/contacts`
**说明:** 分页查询系统中所有好友关系记录,包含双方用户名信息。
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码,默认 1 |
| page_size | int | 否 | 每页数量,默认 20 |
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": {
"total": 150,
"list": [
{
"id": 1,
"user_id": 1,
"username": "zhangsan",
"friend_id": 2,
"friend_username": "lisi",
"remark": "同事",
"status": 1,
"created_at": "2026-03-01 10:00:00"
}
]
}
}
```
**status 状态码:**
| 值 | 说明 |
|-----|------|
| 0 | 待确认pending |
| 1 | 已通过accepted |
| 2 | 已拒绝rejected |
| 3 | 已拉黑blocked |
---
## 2. 删除好友关系
`DELETE /api/v1/admin/contacts/:id`
**路径参数:** `id` — 好友关系记录 ID
**说明:** 管理员可以强制删除任意好友关系。此操作会双向解除(同时删除 A→B 和 B→A 的关系记录)。
**成功响应:**
```json
{
"code": 0,
"message": "ok"
}
```

56
docs/api/admin/online.md Normal file
View File

@@ -0,0 +1,56 @@
# 管理端在线监控 API
> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md)
> 所有接口需要 JWT + admin/super_admin 角色权限
---
## 接口列表
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/v1/admin/online/users | admin | 获取在线用户列表 |
| GET | /api/v1/admin/online/count | admin | 获取在线用户数量 |
---
## 1. 获取在线用户列表
`GET /api/v1/admin/online/users`
**说明:** 返回当前所有在线用户的基本信息。数据来源于 Redis SET `echo:user:online`
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": [
{
"user_id": 1,
"username": "super_admin"
},
{
"user_id": 2,
"username": "lisi"
}
]
}
```
---
## 2. 获取在线用户数量
`GET /api/v1/admin/online/count`
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": {
"count": 25
}
}
```

View File

@@ -12,10 +12,19 @@
| POST | /api/v1/contacts/request | 需认证 | 发送好友申请 | | POST | /api/v1/contacts/request | 需认证 | 发送好友申请 |
| POST | /api/v1/contacts/accept | 需认证 | 接受好友申请 | | POST | /api/v1/contacts/accept | 需认证 | 接受好友申请 |
| POST | /api/v1/contacts/reject | 需认证 | 拒绝好友申请 | | POST | /api/v1/contacts/reject | 需认证 | 拒绝好友申请 |
| GET | /api/v1/contacts/requests | 需认证 | 获取待处理的好友申请列表 |
| DELETE | /api/v1/contacts/:id | 需认证 | 删除好友 | | DELETE | /api/v1/contacts/:id | 需认证 | 删除好友 |
| PUT | /api/v1/contacts/:id/remark | 需认证 | 修改好友备注 | | PUT | /api/v1/contacts/:id/remark | 需认证 | 修改好友备注 |
| PUT | /api/v1/contacts/:id/group | 需认证 | 移动好友到分组 |
| POST | /api/v1/contacts/block | 需认证 | 拉黑用户 |
| DELETE | /api/v1/contacts/block/:id | 需认证 | 取消拉黑 |
| GET | /api/v1/contacts/block | 需认证 | 获取黑名单 |
| GET | /api/v1/contacts/groups | 需认证 | 获取好友分组列表 | | GET | /api/v1/contacts/groups | 需认证 | 获取好友分组列表 |
| POST | /api/v1/contacts/groups | 需认证 | 创建好友分组 | | POST | /api/v1/contacts/groups | 需认证 | 创建好友分组 |
| PUT | /api/v1/contacts/groups/:id | 需认证 | 修改好友分组 |
| DELETE | /api/v1/contacts/groups/:id | 需认证 | 删除好友分组 |
| GET | /api/v1/contacts/recommend | 需认证 | 好友推荐 |
| GET | /api/v1/users/search | 需认证 | 搜索用户 |
--- ---
@@ -23,13 +32,11 @@
`GET /api/v1/contacts` `GET /api/v1/contacts`
**权限:** 需认证
**查询参数:** **查询参数:**
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| group_id | int | 否 | 按分组筛选 | | group_id | int | 否 | 按分组筛选,不传则返回全部 |
**成功响应:** **成功响应:**
```json ```json
@@ -38,15 +45,13 @@
"message": "ok", "message": "ok",
"data": [ "data": [
{ {
"id": 1, "user_id": 2,
"friend_id": 2,
"username": "lisi", "username": "lisi",
"nickname": "李四", "nickname": "李四",
"avatar": "",
"remark": "我的同事", "remark": "我的同事",
"avatar": "https://cdn.echochat.com/avatar/2.jpg",
"online": true,
"group_id": 1, "group_id": 1,
"group_name": "同事" "is_online": true
} }
] ]
} }
@@ -58,16 +63,18 @@
`POST /api/v1/contacts/request` `POST /api/v1/contacts/request`
**权限:** 需认证
**请求参数:** **请求参数:**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| target_id | int | 是 | 目标用户 ID | | target_id | int | 是 | 目标用户 ID |
| message | string | 否 | 申请附言,如"我是张三的同事" | | message | string | 否 | 申请附言 |
**可能的错误码:** 1004用户不存在1005已是好友或已发送过申请 **错误场景:**
- 400: 不能添加自己为好友
- 400: 已是好友
- 400: 已有待处理的申请
- 403: 对方已将你拉黑
--- ---
@@ -75,15 +82,13 @@
`POST /api/v1/contacts/accept` `POST /api/v1/contacts/accept`
**权限:** 需认证
**请求参数:** **请求参数:**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| friendship_id | int | 是 | 好友关系记录 ID | | request_id | int | 是 | 好友申请记录 ID |
**说明:** 接受后系统自动创建双向好友关系,并发送通知给对方。 **说明:** 接受后系统自动创建双向好友关系,并通过 WebSocket 推送 `contact.request.accepted` 事件给对方。
--- ---
@@ -91,35 +96,54 @@
`POST /api/v1/contacts/reject` `POST /api/v1/contacts/reject`
**权限:** 需认证
**请求参数:** **请求参数:**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| friendship_id | int | 是 | 好友关系记录 ID | | request_id | int | 是 | 好友申请记录 ID |
--- ---
## 5. 删除好友 ## 5. 获取待处理的好友申请
`GET /api/v1/contacts/requests`
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": [
{
"id": 5,
"user_id": 3,
"username": "wangwu",
"nickname": "王五",
"avatar": "",
"message": "我是你的同学",
"created_at": "2026-03-01T10:30:00Z"
}
]
}
```
---
## 6. 删除好友
`DELETE /api/v1/contacts/:id` `DELETE /api/v1/contacts/:id`
**权限** 需认证 **路径参数** `id` — 好友的用户 ID
**路径参数** `id` — 好友关系记录 ID **说明** 删除后双向关系均解除。
**说明:** 删除后双向关系均解除,关联的单聊会话不会删除(消息记录保留)。
--- ---
## 6. 修改好友备注 ## 7. 修改好友备注
`PUT /api/v1/contacts/:id/remark` `PUT /api/v1/contacts/:id/remark`
**权限** 需认证 **路径参数** `id` — 好友的用户 ID
**路径参数:** `id` — 好友关系记录 ID
**请求参数:** **请求参数:**
@@ -129,11 +153,45 @@
--- ---
## 7. 获取好友分组列表 ## 8. 移动好友分组
`GET /api/v1/contacts/groups` `PUT /api/v1/contacts/:id/group`
**权限** 需认证 **路径参数** `id` — 好友的用户 ID
**请求参数:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| group_id | int | 是 | 目标分组 ID0 为默认分组 |
---
## 9. 拉黑用户
`POST /api/v1/contacts/block`
**请求参数:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| target_id | int | 是 | 目标用户 ID |
**说明:** 拉黑后自动解除好友关系(如果存在),对方无法向你发送好友申请和消息。
---
## 10. 取消拉黑
`DELETE /api/v1/contacts/block/:id`
**路径参数:** `id` — 被拉黑用户的 ID
---
## 11. 获取黑名单
`GET /api/v1/contacts/block`
**成功响应:** **成功响应:**
```json ```json
@@ -141,24 +199,123 @@
"code": 0, "code": 0,
"message": "ok", "message": "ok",
"data": [ "data": [
{ "id": 1, "name": "同事", "sort_order": 0, "count": 15 }, {
{ "id": 2, "name": "朋友", "sort_order": 1, "count": 8 } "user_id": 5,
"username": "blocked_user",
"nickname": "某用户",
"avatar": ""
}
] ]
} }
``` ```
--- ---
## 8. 创建好友分组 ## 12. 获取好友分组列表
`GET /api/v1/contacts/groups`
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": [
{ "id": 1, "name": "同事", "sort_order": 0, "friend_count": 15 },
{ "id": 2, "name": "朋友", "sort_order": 1, "friend_count": 8 }
]
}
```
---
## 13. 创建好友分组
`POST /api/v1/contacts/groups` `POST /api/v1/contacts/groups`
**权限:** 需认证
**请求参数:** **请求参数:**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| name | string | 是 | 分组名称,最多 50 字符 | | name | string | 是 | 分组名称,最多 50 字符 |
**可能的错误码:** 1005同名分组已存在 ---
## 14. 修改好友分组
`PUT /api/v1/contacts/groups/:id`
**路径参数:** `id` — 分组 ID
**请求参数:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | 是 | 新分组名称 |
| sort_order | int | 否 | 排序序号 |
---
## 15. 删除好友分组
`DELETE /api/v1/contacts/groups/:id`
**路径参数:** `id` — 分组 ID
**说明:** 删除分组后该分组内的好友自动移至默认分组group_id = 0
---
## 16. 好友推荐
`GET /api/v1/contacts/recommend`
**说明:** 基于共同好友算法推荐可能认识的人。
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": [
{
"user_id": 8,
"username": "zhaoliu",
"nickname": "赵六",
"avatar": "",
"common_count": 3
}
]
}
```
---
## 17. 搜索用户
`GET /api/v1/users/search`
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| keyword | string | 是 | 搜索关键词(用户名/昵称模糊匹配) |
| page | int | 否 | 页码,默认 1 |
| page_size | int | 否 | 每页数量,默认 20 |
**成功响应:**
```json
{
"code": 0,
"message": "ok",
"data": [
{
"user_id": 10,
"username": "newuser",
"nickname": "新用户",
"avatar": "",
"is_friend": false
}
]
}
```

View File

@@ -0,0 +1,141 @@
# 前端 WebSocket 事件协议
> 完整的 WebSocket 协议文档见 [websocket.md](../websocket.md)
> 本文档补充前端联系人模块使用的 WebSocket 事件及对接说明
---
## 连接管理
### 连接地址
| 环境 | 地址 |
|------|------|
| 开发环境 | `ws://localhost:8085/ws?token=<access_token>` |
| 生产环境 | `wss://api.echochat.com/ws?token=<access_token>` |
### 前端实现
- **连接服务:** `frontend/src/services/websocket.js`WebSocketService 单例)
- **状态管理:** `frontend/src/store/websocket.js`Pinia Store
- **联系人监听:** `frontend/src/store/contact.js`initWsListeners
### 心跳与重连
- 心跳间隔30 秒
- 重连策略:指数退避 1s → 2s → 4s → 8s → 16s → 最大 30s
- 连接时自动发送 heartbeat 事件
---
## 联系人相关事件
### heartbeat
**方向:** 客户端 → 服务端
**说明:** 心跳消息,服务端收到后续期在线状态 TTL
**发送格式:**
```json
{
"event": "heartbeat",
"data": {}
}
```
---
### notify.friend.request
**方向:** 服务端 → 客户端
**说明:** 收到新的好友申请推送
**data 内容:**
```json
{
"friendship_id": 5,
"from_user_id": 2,
"from_nickname": "李四",
"from_avatar": "",
"message": "我是你的同事"
}
```
**前端处理:** `contactStore.initWsListeners` 监听此事件,自动刷新待处理申请列表
---
### contact.request.accepted
**方向:** 服务端 → 客户端
**说明:** 好友申请被对方接受的通知
**data 内容:**
```json
{
"friendship_id": 5,
"user_id": 1,
"username": "zhangsan",
"nickname": "张三"
}
```
**前端处理:** `contactStore.initWsListeners` 监听此事件,自动刷新好友列表
---
### user.status.online
**方向:** 服务端 → 客户端
**说明:** 好友上线通知
**data 内容:**
```json
{
"user_id": 2,
"nickname": "李四"
}
```
**前端处理:** 更新 `contactStore.onlineMap` 和好友列表中对应用户的 `is_online` 状态
---
### user.status.offline
**方向:** 服务端 → 客户端
**说明:** 好友离线通知
**data 内容:**
```json
{
"user_id": 2
}
```
**前端处理:** 更新 `contactStore.onlineMap` 和好友列表中对应用户的 `is_online` 状态
---
## 事件监听代码示例
```javascript
import { useContactStore } from '@/store/contact'
import { useWebSocketStore } from '@/store/websocket'
const contactStore = useContactStore()
const wsStore = useWebSocketStore()
// 建立 WebSocket 连接
wsStore.connect()
// 初始化联系人事件监听
contactStore.initWsListeners()
```
以上代码会自动监听 `notify.friend.request``contact.request.accepted``user.status.online``user.status.offline` 四个事件。

View File

@@ -1,187 +1,209 @@
# EchoChat 项目开发进度 # EchoChat 项目开发进度
> **最后更新**2026-03-02角色等级体系与权限管控实施 > **最后更新**2026-03-02Phase 2a 完成 - WebSocket 实时通讯与联系人管理
> **当前阶段**Phase 1 - 基础设施与用户认证 > **当前阶段**Phase 2a - WebSocket 实时通讯与联系人管理
> **当前分支**`feature/phase1-foundation-and-auth` > **当前分支**`feature/phase2a-websocket-contacts`
> **实施计划**`docs/plans/2026-02-27-phase1-foundation-and-auth.md` > **实施计划**`phase_2a_实施计划_221003ce.plan.md`
> **设计文档**`docs/plans/2026-03-02-phase2a-design.md`
--- ---
## 一、Task 完成状态 ## 一、Phase 2a Task 完成状态
| Task | 描述 | 状态 | 备注 | | Task | 描述 | 状态 | 备注 |
|------|------|------|------| |------|------|------|------|
| Task 1 | Docker Compose 开发环境搭建 | ✅ 完成 | PostgreSQL 17 + Redis 7 | | Task 0 | 设计文档 + 新分支 | ✅ 完成 | 架构设计、Redis Pub/Sub、文档策略 |
| Task 2 | 数据库初始化脚本 | ✅ 完成 | users + user_roles + roles 表 | | Task 1 | 数据库表结构 | ✅ 完成 | contact_friendships + contact_groups |
| Task 3 | Go 后端服务骨架 | ✅ 完成 | Gin + GORM + Wire + Zap | | Task 2 | WebSocket 核心模块 | ✅ 完成 | Hub + Client + PubSub + Handler |
| Task 4 | Auth Service 层 | ✅ 完成 | 注册/登录/Token/Profile API | | Task 3 | Contact 模型与 DAO | ✅ 完成 | friendship + friend_group DAO |
| Task 5 | Auth Controller & Router | ✅ 完成 | JWT + Redis 有状态校验 | | Task 4 | Contact Service | ✅ 完成 | 好友申请/分组/黑名单/搜索/推荐 |
| Task 6 | uniapp 前端骨架 | ✅ 完成 | request/storage/api/store/pages.json | | Task 5 | Contact Controller & Router | ✅ 完成 | 17 个 REST API + Wire 集成 |
| Task 7 | uniapp 登录/注册页面 | ✅ 完成 | 基于 ui-ux-pro-max 设计系统 | | Task 6 | 在线状态管理 | ✅ 完成 | Redis SET + TTL 心跳续期 |
| Task 8 | 首页框架与 TabBar | ✅ 完成 | 自定义 TabBar + 路由分发 | | Task 7 | 管理端后端 | ✅ 完成 | 在线监控 + 好友关系管理 API |
| Task 9 | Vue 3 管理端项目搭建 | ✅ 完成 | Element Plus + Pinia 3.x | | Task 8 | 前台 WS 客户端 + Store + API | ✅ 完成 | websocket.js + contact.js Store/API |
| Task 10 | 管理端用户管理模块 | ✅ 完成 | 后端 admin 模块 + 前端列表/详情页 | | Task 9 | 前台联系人页面 | ✅ 完成 | 6 个页面ui-ux-pro-max 规范) |
| Task 11 | 端到端集成测试与文档 | ✅ 完成 | Dockerfile + Docker Compose + README + 全流程 API 验证 + Playwright 页面验证 + 代码审查 | | Task 10 | 管理端前端 | ✅ 完成 | 在线监控 + 好友管理页面 |
| Task 11 | API 文档编写 | ✅ 完成 | 4 份独立文档 |
| Task 12 | 集成测试 + 文档更新 + 代码审查 | ✅ 完成 | 三端编译通过 |
--- ---
## 二、关键技术决策记录 ## 二、Phase 2a 新增功能
### WebSocket 实时通讯
- **连接管理**`gorilla/websocket` + JWT 认证 + 心跳30s
- **消息架构**Redis Pub/Sub 跨实例消息路由
- **Hub**:本地连接管理(注册/注销/按用户发送)
- **Client**:读写泵 + 断线回调 + 缓冲通道
### 联系人管理17 个 API
- 好友申请(发送/接受/拒绝)
- 好友列表(按分组筛选 + 在线状态)
- 好友详情(备注/分组移动)
- 好友删除 + 拉黑/取消拉黑
- 好友分组CRUD + 排序)
- 用户搜索 + 好友推荐(共同好友算法)
### 在线状态管理
- Redis SET `echo:user:online` 存储在线用户集合
- Redis STRING `echo:user:status:{user_id}` + TTL 心跳续期
- Pub/Sub 推送好友上下线通知
### 管理端扩展
- 在线监控页面(自动 30s 刷新 + 统计卡片)
- 好友关系管理(分页列表 + 强制删除)
---
## 三、Phase 1 完成总结
| Task | 描述 | 状态 |
|------|------|------|
| Task 1-11 | 基础设施 + 认证 + 用户管理 | ✅ 全部完成 |
- Go 后端 15+ API、JWT 有状态认证、RBAC 角色权限level 等级体系)
- 前台 uni-app 登录/注册/TabBar/个人中心
- 管理端 Vue 3 登录/仪表盘/用户列表/详情
- Docker Compose 一键启动
---
## 四、关键技术决策记录
### 后端Go ### 后端Go
1. **框架组合**Gin + GORM + Wire + Zap + Viper 1. **框架组合**Gin + GORM + Wire + Zap + Viper
2. **JWT 策略**:有状态 JWTToken 按 clientType 隔离存储在 Redis`echo:auth:token:{frontend|admin}:{user_id}` 2. **JWT 策略**:有状态 JWTToken 按 clientType 隔离存储在 Redis
3. **密码加密**bcrypt 3. **WebSocket**`gorilla/websocket` + Redis Pub/Sub 跨实例路由
4. **数据库时间精度**`TIMESTAMP(0)` 精确到秒 4. **在线状态**混合方案Redis SET + STRING TTL + Pub/Sub 推送)
5. **API 响应格式**:统一 `{ "code": 0, "message": "success", "data": ... }` 5. **角色等级**`auth_roles.level`1=超管, 10=管理员, 100=普通用户)
6. **常量命名**Go camelCase`UserStatusActive`),非大写下划线
7. **模块路由**:模块内自注册 + 中央 router 聚合
8. **角色等级体系**`auth_roles.level` 字段值越小权限越高1=超管, 10=管理员, 100=普通用户),所有管理操作强制执行"高等级管理低等级"规则
### 前台用户端frontend/ ### 前台用户端frontend/
1. **框架**uni-app 3.0Vue 3.4.21 框架锁定 1. **框架**uni-app 3.0Vue 3.4.21
2. **状态管理**Pinia 2.1.7 + pinia-plugin-persistedstate@3 2. **状态管理**Pinia 2.1.7 + pinia-plugin-persistedstate@3
3. **npm 配置**`.npmrc` 设置 `legacy-peer-deps=true`uni-app 兼容性 3. **WebSocket**`uni.connectSocket`(小程序)/ `WebSocket`H5
4. **模块系统**ESM`export` / `import`),禁止 CommonJS 4. **设计系统**ui-ux-pro-max 规范
5. **响应式单位**`rpx`750rpx = 屏幕宽度)
6. **设计系统**ui-ux-pro-max 生成,持久化在 `design-system/echochat/`
7. **色板**Primary `#2563EB` / BG `#F8FAFC` / Text `#1E293B`
8. **开发端口**`npm run dev:h5` → localhost:5173+
### 后台管理端admin/ ### 后台管理端admin/
1. **框架**Vue 3.5+ + Vite 7.x(独立项目,不受 uni-app 限制) 1. **框架**Vue 3.5+ + Vite 7.x + Element Plus
2. **UI 组件**Element Plus中文语言包 2. **HTTP 客户端**Axios
3. **状态管理**Pinia 3.x最新版 3. **存储隔离**localStorage key 前缀 `admin_`
4. **HTTP 客户端**Axios
5. **存储隔离**localStorage key 前缀 `admin_`
6. **主题色**CSS 变量覆盖 Element Plus → `--el-color-primary: #2563EB`
7. **开发端口**`npm run dev` → localhost:3100Vite proxy 代理后端)
--- ---
## 、目录结构概览 ## 、目录结构概览
``` ```
EchoChat/ EchoChat/
├── backend/go-service/ # Go 后端服务 ├── backend/go-service/
│ ├── app/ │ ├── app/
│ │ ├── admin/ # 管理端模块controller/service/dao/router/provider │ │ ├── admin/ # 管理端controller/service/provider
│ │ ├── auth/ # 认证模块controller/service/dao/model/router │ │ ├── auth/ # 认证模块
│ │ ├── constants/ # 常量role_code/user_status │ │ ├── contact/ # [Phase 2a] 联系人模块
│ │ ├── dto/ # 数据传输对象auth_dto + admin_dto │ │ │ ├── controller/
│ │ └── provider/ # Wire 依赖注入 │ │ │ ├── dao/
├── cmd/server/main.go # 入口 │ │ ├── model/
├── config/ # 配置 ├── service/
├── pkg/ # 公共包db/logs/middleware/utils ├── router.go
│ └── router/router.go # 中央路由聚合 │ │ │ └── provider.go
├── frontend/ # 前台用户端uni-app │ │ ├── ws/ # [Phase 2a] WebSocket 模块
│ │ │ ├── handler.go
│ │ │ ├── online_service.go
│ │ │ ├── provider.go
│ │ │ └── router.go
│ │ ├── constants/
│ │ ├── dto/
│ │ └── provider/
│ ├── pkg/
│ │ ├── ws/ # [Phase 2a] WebSocket 核心
│ │ │ ├── hub.go
│ │ │ ├── client.go
│ │ │ ├── pubsub.go
│ │ │ └── message.go
│ │ ├── db/ logs/ middleware/ utils/
│ └── router/router.go
├── frontend/ # 前台uni-app
│ └── src/ │ └── src/
│ ├── api/auth.js │ ├── api/{auth,contact,user}.js
│ ├── components/CustomTabBar.vue │ ├── services/websocket.js # [Phase 2a]
│ ├── pages/{auth,chat,contact,meeting,profile,index}/ │ ├── store/{user,websocket,contact}.js
│ ├── store/user.js │ ├── pages/contact/ # [Phase 2a] 6 个页面
└── utils/{request,storage}.js │ ├── index.vue
├── admin/ # 后台管理端Vue 3 + Element Plus ├── request.vue
│ │ ├── detail.vue
│ │ ├── search.vue
│ │ ├── groups.vue
│ │ └── blacklist.vue
│ └── components/CustomTabBar.vue
├── admin/ # 管理端Vue 3 + Element Plus
│ └── src/ │ └── src/
│ ├── api/auth.js │ ├── api/{auth,user,monitor,contact}.js
│ ├── router/index.js │ ├── views/
├── store/user.js │ ├── monitor/online.vue # [Phase 2a]
├── utils/{request,storage}.js │ ├── contact/list.vue # [Phase 2a]
└── views/{layout,login,dashboard,user}/ │ ├── layout/ login/ dashboard/ user/
│ └── router/index.js
├── deploy/ ├── deploy/
├── docker-compose.dev.yml ├── design-system/
│ └── docker/postgres/init.sql
├── design-system/echochat/ # ui-ux-pro-max 生成的设计系统
│ ├── MASTER.md
│ └── pages/{login,admin-login}.md
└── docs/ └── docs/
├── api/ # API 接口文档 ├── api/
├── architecture/ # 系统架构文档 │ ├── frontend/{auth,contact,websocket}.md
├── conventions/ # 开发规范文档 │ ├── admin/{auth,user,online,contact}.md
│ └── frontend-backend-integration.md # 前后端集成规范 │ └── websocket.md
├── plans/ # 实施计划文档 ├── plans/
── progress/ # 进度文档(本文件) ── progress/CURRENT_STATUS.md
└── conventions/
``` ```
--- ---
## 、开发流程规范 ## 、开发测试指南
1. **工作流**:使用 superpowers 流程控制开发节奏 ### 启动命令
2. **前端设计****必须**使用 ui-ux-pro-max 技能包,禁止手动设计
3. **代码注释**所有公开函数、组件、Store 必须有详细注释
4. **文档同步**:代码变更后必须同步更新相关文档
5. **Git 分支**`feature/phase1-foundation-and-auth`
6. **验证方式**Playwright MCP 进行页面自动化验证
7. **代码审查**:每个 Task 完成后,使用 `code-reviewer` 子代理进行结构化代码审查,对照实施计划和编码标准检查
8. **完成验证**:使用 `verification-before-completion` 技能,在声称完成前必须运行验证命令并确认输出结果
9. **前后端联动规范**:详见 `docs/conventions/frontend-backend-integration.md`,核心要求:前端错误提示必须优先使用后端 message禁止硬编码覆盖后端错误处理禁止忽略 error登录安全统一返回 401
---
## 五、开发测试指南
### 启动命令(开发模式)
前提postgres 和 redis 已通过 Docker Compose 运行。
```bash ```bash
# 启动 Go 后端http://localhost:8085 # 1. 启动 PostgreSQL + Redis
cd deploy && docker compose -f docker-compose.dev.yml up -d postgres redis
# 2. 启动 Go 后端http://localhost:8085
cd backend/go-service && go run cmd/server/main.go cd backend/go-service && go run cmd/server/main.go
# 启动管理端http://localhost:3100 # 3. 启动管理端http://localhost:3100
cd admin && npm run dev cd admin && npm run dev
# 启动前台 H5http://localhost:5173+ # 4. 启动前台 H5http://localhost:5173+
cd frontend && npm run dev:h5 cd frontend && npm run dev:h5
``` ```
如需全容器启动(包括 Go 服务):`cd deploy && docker compose -f docker-compose.dev.yml up -d`
### 测试账号 ### 测试账号
| 账号 | 密码 | 角色 | 用途 | | 账号 | 密码 | 角色 | 用途 |
|------|------|------|------| |------|------|------|------|
| `admin_test` | `admin123456` | user + admin | **管理端登录推荐** | | `super_admin` | `admin123456` | super_admin | 系统预置唯一超管 |
| `admin_test` | `admin123456` | user + admin | 管理端登录推荐 |
| `testuser1` | `test123456` | user + admin | 前台登录测试 | | `testuser1` | `test123456` | user + admin | 前台登录测试 |
| `testuser` | `test123456` | user | 前台登录测试 | | `testuser` | `test123456` | user | 前台登录测试 |
| `testuser3` | `test123456` | user | 前台登录测试 |
| `created_by_admin` | `pass123456` | user | 管理端创建的用户 |
| `super_admin` | `admin123456` | super_admin | **系统预置唯一超管账号** |
> 也可以通过注册接口或管理端"创建用户"功能创建新的测试账号。 ### Phase 2a 可测试功能
### 可测试功能 - **前台联系人**:好友列表 → 搜索添加 → 好友申请 → 好友详情 → 备注/分组 → 拉黑/删除
- **前台 WebSocket**:自动连接 → 心跳 → 在线状态实时更新 → 好友申请推送
- **管理端**:登录 → 仪表盘 → 用户列表 → 搜索/筛选 → 用户详情 → 禁用/启用(受角色等级约束) → 批量设置角色Checkbox 多选 + 等级管控) → 创建用户 - **管理端在线监控**:在线用户数 → 在线用户列表 → 自动刷新
- **前台 H5**:注册 → 登录 → 个人中心 → 修改资料 → 退出登录 - **管理端好友管理**:好友关系列表 → 强制删除关系
- **API**`GET http://localhost:8085/health`(健康检查)
--- ---
## 、已知问题与注意事项 ## 、已知问题
1. ~~前端工具模块使用了 CommonJS已修复为 ESM~~(已解决) 1. uni-app 的 `tabBar.custom: true` 配合自定义 TabBar 组件使用
2. ~~前端登录失败时错误提示不正确(硬编码"登录已过期"覆盖后端消息)~~(已修复,详见 `docs/conventions/frontend-backend-integration.md` 2. Go 依赖版本需匹配 Go 1.23.12
3. ~~后端登录时用户不存在返回 404 暴露用户是否注册~~(已修复为统一返回 401 3. 管理端 Element Plus 全量导入导致打包体积较大(后续可改为按需导入
4. uni-app 的 `tabBar.custom: true` 配合自定义 TabBar 组件使用
5. 管理端和前台的 localStorage key 通过前缀隔离(`admin_` vs `echo_`
6. Go 依赖版本需匹配 Go 1.23.12,不要随意升级 Go 工具链
7. `.gitignore` 已配置忽略:`.cursor/``.vite/``node_modules/``dist/``*.png`(根目录截图)、`logs/`
--- ---
## 六、Phase 1 完成总结 ## 八、下一阶段规划
### Phase 1 阶段成果 ### Phase 2b - 即时通讯消息系统
- **11 个 Task 全部完成**,端到端验证通过 - 会话管理(单聊/群聊)
- **Go 后端**15 个 API 端点JWT 有状态认证RBAC 角色权限 - 消息收发 + 离线消息
- **前台 uni-app**:登录/注册/TabBar/个人中心
- **管理端 Vue 3**:登录/仪表盘/用户列表/用户详情
- **基础设施**Docker Compose 一键启动PostgreSQL + Redis + Go 服务)
- **代码审查**code-reviewer 审查通过,已修复 panic 风险、错误处理等问题
### 下一阶段Phase 2 - 即时通讯
- 待制定实施计划
- WebSocket 长连接 + 消息系统
- 联系人/好友管理
- 消息通知 - 消息通知
- 已读回执

View File

@@ -28,7 +28,38 @@
{ {
"path": "pages/contact/index", "path": "pages/contact/index",
"style": { "style": {
"navigationBarTitleText": "联系人" "navigationBarTitleText": "联系人",
"navigationStyle": "custom"
}
},
{
"path": "pages/contact/request",
"style": {
"navigationBarTitleText": "好友申请"
}
},
{
"path": "pages/contact/detail",
"style": {
"navigationBarTitleText": "好友详情"
}
},
{
"path": "pages/contact/search",
"style": {
"navigationBarTitleText": "搜索好友"
}
},
{
"path": "pages/contact/groups",
"style": {
"navigationBarTitleText": "好友分组"
}
},
{
"path": "pages/contact/blacklist",
"style": {
"navigationBarTitleText": "黑名单"
} }
}, },
{ {

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 设计系统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> <template>
<view class="page-wrapper"> <view class="page-wrapper">
<view class="placeholder-content"> <!-- 顶部栏 -->
<text class="placeholder-icon">👥</text> <view class="header">
<text class="placeholder-title">联系人</text> <text class="header-title">联系人</text>
<text class="placeholder-desc">好友管理功能开发中</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>
<!-- 搜索栏 -->
<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" /> <CustomTabBar :current="1" />
</view> </view>
</template> </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' import CustomTabBar from '@/components/CustomTabBar.vue'
export default { const contactStore = useContactStore()
name: 'ContactIndex', const wsStore = useWebSocketStore()
components: { CustomTabBar } 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> </script>
<style scoped> <style scoped>
@@ -31,28 +176,319 @@ export default {
padding-bottom: 120rpx; 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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding-top: 300rpx; padding-top: 200rpx;
} }
.placeholder-icon { .empty-title {
font-size: 80rpx; font-size: 32rpx;
margin-bottom: 24rpx; color: #64748B;
} font-weight: 500;
.placeholder-title {
font-size: 36rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 12rpx; margin-bottom: 12rpx;
} }
.placeholder-desc { .empty-desc {
font-size: 28rpx; font-size: 26rpx;
color: #94A3B8; color: #94A3B8;
} }
</style> </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>