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:
34
admin/src/api/contact.js
Normal file
34
admin/src/api/contact.js
Normal 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
29
admin/src/api/monitor.js
Normal 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'
|
||||
})
|
||||
}
|
||||
@@ -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: '好友管理' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
197
admin/src/views/contact/list.vue
Normal file
197
admin/src/views/contact/list.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
267
admin/src/views/monitor/online.vue
Normal file
267
admin/src/views/monitor/online.vue
Normal 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>
|
||||
Reference in New Issue
Block a user