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',
component: () => import('@/views/user/detail.vue'),
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>
<el-menu-item index="/user/list">用户列表</el-menu-item>
</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-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>