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',
|
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: '好友管理' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
</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>
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
78
docs/api/admin/contact.md
Normal file
78
docs/api/admin/contact.md
Normal 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
56
docs/api/admin/online.md
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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 | 是 | 目标分组 ID,0 为默认分组 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
141
docs/api/frontend/websocket.md
Normal file
141
docs/api/frontend/websocket.md
Normal 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` 四个事件。
|
||||||
@@ -1,187 +1,209 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-03-02(角色等级体系与权限管控实施)
|
> **最后更新**:2026-03-02(Phase 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 策略**:有状态 JWT,Token 按 clientType 隔离存储在 Redis(`echo:auth:token:{frontend|admin}:{user_id}`)
|
2. **JWT 策略**:有状态 JWT,Token 按 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.0(Vue 3.4.21 框架锁定)
|
1. **框架**:uni-app 3.0(Vue 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:3100(Vite 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
|
||||||
|
|
||||||
# 启动前台 H5(http://localhost:5173+)
|
# 4. 启动前台 H5(http://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 长连接 + 消息系统
|
|
||||||
- 联系人/好友管理
|
|
||||||
- 消息通知
|
- 消息通知
|
||||||
|
- 已读回执
|
||||||
|
|||||||
@@ -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": "黑名单"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
256
frontend/src/pages/contact/blacklist.vue
Normal file
256
frontend/src/pages/contact/blacklist.vue
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<!--
|
||||||
|
黑名单管理页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B
|
||||||
|
ui-ux-pro-max 规范:防重复提交 / 确认弹窗 / 过渡动画
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<view v-if="loading" class="skeleton-list">
|
||||||
|
<view v-for="i in 3" :key="i" class="skeleton-item">
|
||||||
|
<view class="skeleton-avatar"></view>
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<view class="skeleton-line"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-btn"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 黑名单列表 -->
|
||||||
|
<view v-else-if="contactStore.blockList.length > 0" class="block-list">
|
||||||
|
<view
|
||||||
|
v-for="user in contactStore.blockList"
|
||||||
|
:key="user.user_id"
|
||||||
|
class="block-item"
|
||||||
|
>
|
||||||
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
|
||||||
|
<text class="avatar-text">{{ getInitial(user.nickname || user.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="user-info">
|
||||||
|
<text class="user-name">{{ user.nickname || user.username }}</text>
|
||||||
|
<text class="user-account">@{{ user.username }}</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="unblock-btn"
|
||||||
|
:class="{ 'unblock-btn--disabled': processingMap[user.user_id] }"
|
||||||
|
@tap="handleUnblock(user)"
|
||||||
|
>
|
||||||
|
<text class="unblock-btn-text">取消拉黑</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-title">黑名单为空</text>
|
||||||
|
<text class="empty-desc">被拉黑的用户会显示在这里</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
|
||||||
|
const contactStore = useContactStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const processingMap = reactive({})
|
||||||
|
|
||||||
|
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
|
||||||
|
|
||||||
|
const getAvatarColor = (name) => {
|
||||||
|
if (!name) return AVATAR_COLORS[0]
|
||||||
|
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitial = (name) => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnblock = (user) => {
|
||||||
|
if (processingMap[user.user_id]) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '取消拉黑',
|
||||||
|
content: `确定要取消拉黑 ${user.nickname || user.username} 吗?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
processingMap[user.user_id] = true
|
||||||
|
try {
|
||||||
|
await contactStore.unblockUser(user.user_id)
|
||||||
|
uni.showToast({ title: '已取消拉黑', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('取消拉黑失败', e)
|
||||||
|
} finally {
|
||||||
|
processingMap[user.user_id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await contactStore.fetchBlockList()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取黑名单失败', e)
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
width: 40%;
|
||||||
|
height: 28rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-btn {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 黑名单列表 */
|
||||||
|
.block-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-account {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unblock-btn {
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
border: 2rpx solid #E2E8F0;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unblock-btn:active {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unblock-btn--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unblock-btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748B;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #64748B;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
405
frontend/src/pages/contact/detail.vue
Normal file
405
frontend/src/pages/contact/detail.vue
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
<!--
|
||||||
|
好友详情页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B / Danger #EF4444
|
||||||
|
ui-ux-pro-max 规范:确认弹窗 / 防重复提交 / 过渡动画
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<view v-if="friend" class="detail-content">
|
||||||
|
<!-- 头像卡片 -->
|
||||||
|
<view class="profile-card">
|
||||||
|
<view class="avatar-lg" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
|
||||||
|
<text class="avatar-text-lg">{{ getInitial(friend.remark || friend.nickname || friend.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="profile-name">{{ friend.remark || friend.nickname || friend.username }}</text>
|
||||||
|
<text class="profile-username">@{{ friend.username }}</text>
|
||||||
|
<view class="online-badge" :class="friend.is_online ? 'online-badge--on' : 'online-badge--off'">
|
||||||
|
<view class="online-badge-dot"></view>
|
||||||
|
<text class="online-badge-text">{{ friend.is_online ? '在线' : '离线' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 信息编辑 -->
|
||||||
|
<view class="info-section">
|
||||||
|
<view class="info-item" @tap="editRemark">
|
||||||
|
<text class="info-label">备注名</text>
|
||||||
|
<view class="info-value-wrap">
|
||||||
|
<text class="info-value">{{ friend.remark || '未设置' }}</text>
|
||||||
|
<text class="arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-item" @tap="selectGroup">
|
||||||
|
<text class="info-label">所属分组</text>
|
||||||
|
<view class="info-value-wrap">
|
||||||
|
<text class="info-value">{{ currentGroupName }}</text>
|
||||||
|
<text class="arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view class="action-section">
|
||||||
|
<view class="action-btn action-btn--primary" @tap="sendMessage">
|
||||||
|
<text class="action-btn-text action-btn-text--primary">发消息</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="danger-section">
|
||||||
|
<view class="danger-item" @tap="handleBlock">
|
||||||
|
<text class="danger-text">拉黑该用户</text>
|
||||||
|
</view>
|
||||||
|
<view class="danger-item danger-item--last" @tap="handleDelete">
|
||||||
|
<text class="danger-text">删除好友</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 未找到好友 -->
|
||||||
|
<view v-else-if="!loading" class="empty-state">
|
||||||
|
<text class="empty-title">未找到好友信息</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
|
||||||
|
const contactStore = useContactStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const friend = ref(null)
|
||||||
|
const userId = ref(0)
|
||||||
|
const processing = ref(false)
|
||||||
|
|
||||||
|
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
|
||||||
|
|
||||||
|
const getAvatarColor = (name) => {
|
||||||
|
if (!name) return AVATAR_COLORS[0]
|
||||||
|
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitial = (name) => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGroupName = computed(() => {
|
||||||
|
if (!friend.value || !friend.value.group_id) return '默认分组'
|
||||||
|
const group = contactStore.groups.find(g => g.id === friend.value.group_id)
|
||||||
|
return group ? group.name : '默认分组'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
userId.value = Number(currentPage.options?.userId || 0)
|
||||||
|
|
||||||
|
if (contactStore.friendList.length === 0) {
|
||||||
|
await contactStore.fetchFriends()
|
||||||
|
}
|
||||||
|
if (contactStore.groups.length === 0) {
|
||||||
|
await contactStore.fetchGroups()
|
||||||
|
}
|
||||||
|
|
||||||
|
friend.value = contactStore.friendList.find(f => f.user_id === userId.value) || null
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const editRemark = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '修改备注',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入备注名',
|
||||||
|
content: friend.value?.remark || '',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm && res.content !== undefined) {
|
||||||
|
try {
|
||||||
|
await contactStore.updateRemark(userId.value, res.content.trim())
|
||||||
|
friend.value = contactStore.friendList.find(f => f.user_id === userId.value)
|
||||||
|
uni.showToast({ title: '已更新', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('更新备注失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectGroup = () => {
|
||||||
|
const groups = [{ id: 0, name: '默认分组' }, ...contactStore.groups]
|
||||||
|
const names = groups.map(g => g.name)
|
||||||
|
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: names,
|
||||||
|
success: async (res) => {
|
||||||
|
const selectedGroup = groups[res.tapIndex]
|
||||||
|
try {
|
||||||
|
await contactStore.moveToGroup(userId.value, selectedGroup.id)
|
||||||
|
friend.value = contactStore.friendList.find(f => f.user_id === userId.value)
|
||||||
|
uni.showToast({ title: '已移动', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('移动分组失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
uni.showToast({ title: '聊天功能开发中', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlock = () => {
|
||||||
|
if (processing.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认拉黑',
|
||||||
|
content: `确定要拉黑 ${friend.value.remark || friend.value.nickname || friend.value.username} 吗?拉黑后将自动解除好友关系。`,
|
||||||
|
confirmColor: '#EF4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
await contactStore.blockUser(userId.value)
|
||||||
|
uni.showToast({ title: '已拉黑', icon: 'none' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 800)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('拉黑失败', e)
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (processing.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除好友 ${friend.value.remark || friend.value.nickname || friend.value.username} 吗?删除后需要重新申请添加。`,
|
||||||
|
confirmColor: '#EF4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
await contactStore.deleteFriend(userId.value)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'none' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 800)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('删除好友失败', e)
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像卡片 */
|
||||||
|
.profile-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48rpx 32rpx 40rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-lg {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text-lg {
|
||||||
|
font-size: 56rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-username {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge--on {
|
||||||
|
background-color: #ECFDF5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge--off {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge-dot {
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge--on .online-badge-dot {
|
||||||
|
background-color: #10B981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge--off .online-badge-dot {
|
||||||
|
background-color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge--on .online-badge-text {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-badge--off .online-badge-text {
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息编辑 */
|
||||||
|
.info-section {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item:active {
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
|
.action-section {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--primary {
|
||||||
|
background-color: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-text--primary {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 危险操作 */
|
||||||
|
.danger-section {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-item:active {
|
||||||
|
background-color: #FEF2F2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-item--last {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #EF4444;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
336
frontend/src/pages/contact/groups.vue
Normal file
336
frontend/src/pages/contact/groups.vue
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
<!--
|
||||||
|
好友分组管理页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B
|
||||||
|
ui-ux-pro-max 规范:防重复提交 / 确认弹窗 / 过渡动画
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 新建分组 -->
|
||||||
|
<view class="create-bar" @tap="handleCreate">
|
||||||
|
<text class="create-icon">+</text>
|
||||||
|
<text class="create-text">新建分组</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<view v-if="loading" class="skeleton-list">
|
||||||
|
<view v-for="i in 3" :key="i" class="skeleton-item">
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<view class="skeleton-line skeleton-line--name"></view>
|
||||||
|
<view class="skeleton-line skeleton-line--count"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 默认分组 -->
|
||||||
|
<view v-else class="group-list">
|
||||||
|
<view class="group-item group-item--default">
|
||||||
|
<view class="group-left">
|
||||||
|
<view class="group-icon group-icon--default">
|
||||||
|
<text class="group-icon-text">★</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-info">
|
||||||
|
<text class="group-name">默认分组</text>
|
||||||
|
<text class="group-count">系统默认</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 自定义分组 -->
|
||||||
|
<view
|
||||||
|
v-for="group in contactStore.groups"
|
||||||
|
:key="group.id"
|
||||||
|
class="group-item"
|
||||||
|
>
|
||||||
|
<view class="group-left">
|
||||||
|
<view class="group-icon">
|
||||||
|
<text class="group-icon-text">☰</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-info">
|
||||||
|
<text class="group-name">{{ group.name }}</text>
|
||||||
|
<text class="group-count">{{ group.friend_count || 0 }} 位好友</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="group-actions">
|
||||||
|
<view class="icon-btn" @tap="handleEdit(group)">
|
||||||
|
<text class="icon-btn-text">✎</text>
|
||||||
|
</view>
|
||||||
|
<view class="icon-btn icon-btn--danger" @tap="handleDelete(group)">
|
||||||
|
<text class="icon-btn-text icon-btn-text--danger">✕</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空分组提示 -->
|
||||||
|
<view v-if="contactStore.groups.length === 0" class="empty-hint">
|
||||||
|
<text class="empty-hint-text">还没有自定义分组,点击上方按钮创建</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
|
||||||
|
const contactStore = useContactStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const processing = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await contactStore.fetchGroups()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取分组失败', e)
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (processing.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '新建分组',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入分组名称',
|
||||||
|
content: '',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm && res.content?.trim()) {
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
await contactStore.createGroup(res.content.trim())
|
||||||
|
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('创建分组失败', e)
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (group) => {
|
||||||
|
if (processing.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '修改分组名',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入新的分组名称',
|
||||||
|
content: group.name,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm && res.content?.trim() && res.content.trim() !== group.name) {
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
await contactStore.updateGroup(group.id, res.content.trim())
|
||||||
|
uni.showToast({ title: '已修改', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('修改分组失败', e)
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (group) => {
|
||||||
|
if (processing.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除分组"${group.name}"吗?分组内的好友将移至默认分组。`,
|
||||||
|
confirmColor: '#EF4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
await contactStore.deleteGroup(group.id)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'none' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('删除分组失败', e)
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新建分组 */
|
||||||
|
.create-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-bar:active {
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #2563EB;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #2563EB;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-item {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--name {
|
||||||
|
width: 40%;
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--count {
|
||||||
|
width: 25%;
|
||||||
|
height: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组列表 */
|
||||||
|
.group-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background-color: #DBEAFE;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-icon--default {
|
||||||
|
background-color: #FEF3C7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-icon-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-count {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:active {
|
||||||
|
background-color: #E2E8F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--danger:active {
|
||||||
|
background-color: #FEE2E2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-text--danger {
|
||||||
|
color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空提示 */
|
||||||
|
.empty-hint {
|
||||||
|
padding: 40rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,27 +1,172 @@
|
|||||||
<!--
|
<!--
|
||||||
联系人页面(占位)
|
联系人列表页(TabBar 页面)
|
||||||
|
|
||||||
设计系统:design-system/echochat/MASTER.md
|
设计系统: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">☰</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">🔍</text>
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索好友"
|
||||||
|
placeholder-style="color: #94A3B8"
|
||||||
|
confirm-type="search"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 功能入口 -->
|
||||||
|
<view class="entry-section">
|
||||||
|
<view class="entry-item" @tap="goToRequests">
|
||||||
|
<view class="entry-left">
|
||||||
|
<view class="entry-icon entry-icon--request">
|
||||||
|
<text class="entry-icon-text">✉</text>
|
||||||
|
</view>
|
||||||
|
<text class="entry-label">好友申请</text>
|
||||||
|
</view>
|
||||||
|
<view class="entry-right">
|
||||||
|
<view v-if="contactStore.pendingCount > 0" class="badge">
|
||||||
|
{{ contactStore.pendingCount > 99 ? '99+' : contactStore.pendingCount }}
|
||||||
|
</view>
|
||||||
|
<text class="arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="entry-item" @tap="goToBlacklist">
|
||||||
|
<view class="entry-left">
|
||||||
|
<view class="entry-icon entry-icon--block">
|
||||||
|
<text class="entry-icon-text">⊘</text>
|
||||||
|
</view>
|
||||||
|
<text class="entry-label">黑名单</text>
|
||||||
|
</view>
|
||||||
|
<view class="entry-right">
|
||||||
|
<text class="arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 好友列表 -->
|
||||||
|
<view class="section-header" v-if="!loading">
|
||||||
|
<text class="section-title">好友列表</text>
|
||||||
|
<text class="section-count">{{ filteredFriends.length }} 人</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<view v-if="loading" class="skeleton-list">
|
||||||
|
<view v-for="i in 5" :key="i" class="skeleton-item">
|
||||||
|
<view class="skeleton-avatar"></view>
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<view class="skeleton-line skeleton-line--name"></view>
|
||||||
|
<view class="skeleton-line skeleton-line--sub"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 好友列表 -->
|
||||||
|
<view v-else-if="filteredFriends.length > 0" class="friend-list">
|
||||||
|
<view
|
||||||
|
v-for="friend in filteredFriends"
|
||||||
|
:key="friend.user_id"
|
||||||
|
class="friend-item"
|
||||||
|
@tap="goToDetail(friend)"
|
||||||
|
>
|
||||||
|
<view class="avatar-wrap">
|
||||||
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
|
||||||
|
<text class="avatar-text">{{ getInitial(friend.remark || friend.nickname || friend.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="friend.is_online" class="online-dot"></view>
|
||||||
|
</view>
|
||||||
|
<view class="friend-info">
|
||||||
|
<text class="friend-name">{{ friend.remark || friend.nickname || friend.username }}</text>
|
||||||
|
<text class="friend-status">{{ friend.is_online ? '在线' : '离线' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-title">暂无好友</text>
|
||||||
|
<text class="empty-desc">点击右上角 "+" 搜索并添加好友</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<CustomTabBar :current="1" />
|
<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>
|
||||||
|
|||||||
337
frontend/src/pages/contact/request.vue
Normal file
337
frontend/src/pages/contact/request.vue
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<!--
|
||||||
|
好友申请列表页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
|
||||||
|
ui-ux-pro-max 规范:防重复提交 / 骨架屏 / 过渡动画
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<view v-if="loading" class="skeleton-list">
|
||||||
|
<view v-for="i in 4" :key="i" class="skeleton-item">
|
||||||
|
<view class="skeleton-avatar"></view>
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<view class="skeleton-line skeleton-line--name"></view>
|
||||||
|
<view class="skeleton-line skeleton-line--msg"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-actions">
|
||||||
|
<view class="skeleton-btn"></view>
|
||||||
|
<view class="skeleton-btn"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 申请列表 -->
|
||||||
|
<view v-else-if="contactStore.pendingRequests.length > 0" class="request-list">
|
||||||
|
<view
|
||||||
|
v-for="req in contactStore.pendingRequests"
|
||||||
|
:key="req.id"
|
||||||
|
class="request-item"
|
||||||
|
>
|
||||||
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(req.nickname || req.username) }">
|
||||||
|
<text class="avatar-text">{{ getInitial(req.nickname || req.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="request-info">
|
||||||
|
<text class="request-name">{{ req.nickname || req.username }}</text>
|
||||||
|
<text class="request-msg" v-if="req.message">{{ req.message }}</text>
|
||||||
|
<text class="request-msg" v-else>请求添加你为好友</text>
|
||||||
|
<text class="request-time">{{ formatTime(req.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="request-actions">
|
||||||
|
<view
|
||||||
|
class="btn btn--accept"
|
||||||
|
:class="{ 'btn--disabled': processingMap[req.id] }"
|
||||||
|
@tap="handleAccept(req.id)"
|
||||||
|
>
|
||||||
|
<text class="btn-text">接受</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="btn btn--reject"
|
||||||
|
:class="{ 'btn--disabled': processingMap[req.id] }"
|
||||||
|
@tap="handleReject(req.id)"
|
||||||
|
>
|
||||||
|
<text class="btn-text btn-text--reject">拒绝</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-title">暂无好友申请</text>
|
||||||
|
<text class="empty-desc">当有人向你发送好友申请时,会在这里显示</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, reactive } from 'vue'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
|
||||||
|
const contactStore = useContactStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const processingMap = reactive({})
|
||||||
|
|
||||||
|
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
|
||||||
|
|
||||||
|
const getAvatarColor = (name) => {
|
||||||
|
if (!name) return AVATAR_COLORS[0]
|
||||||
|
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitial = (name) => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 1) return '刚刚'
|
||||||
|
if (minutes < 60) return `${minutes} 分钟前`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} 小时前`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days} 天前`
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAccept = async (requestId) => {
|
||||||
|
if (processingMap[requestId]) return
|
||||||
|
processingMap[requestId] = true
|
||||||
|
try {
|
||||||
|
await contactStore.acceptRequest(requestId)
|
||||||
|
uni.showToast({ title: '已接受', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('接受好友申请失败', e)
|
||||||
|
} finally {
|
||||||
|
processingMap[requestId] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async (requestId) => {
|
||||||
|
if (processingMap[requestId]) return
|
||||||
|
processingMap[requestId] = true
|
||||||
|
try {
|
||||||
|
await contactStore.rejectRequest(requestId)
|
||||||
|
uni.showToast({ title: '已拒绝', icon: 'none' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('拒绝好友申请失败', e)
|
||||||
|
} finally {
|
||||||
|
processingMap[requestId] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await contactStore.fetchPendingRequests()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取好友申请失败', e)
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--name {
|
||||||
|
width: 40%;
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--msg {
|
||||||
|
width: 60%;
|
||||||
|
height: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-btn {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 申请列表 */
|
||||||
|
.request-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-msg {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748B;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--accept {
|
||||||
|
background-color: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--reject {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text--reject {
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #64748B;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 60rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
373
frontend/src/pages/contact/search.vue
Normal file
373
frontend/src/pages/contact/search.vue
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
<!--
|
||||||
|
搜索添加好友页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B
|
||||||
|
ui-ux-pro-max 规范:防重复提交 / loading 状态 / 触摸目标 >= 88rpx
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<view class="search-input-wrap">
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="输入用户名或昵称搜索"
|
||||||
|
placeholder-style="color: #94A3B8"
|
||||||
|
confirm-type="search"
|
||||||
|
@confirm="doSearch"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="search-btn" @tap="doSearch">
|
||||||
|
<text class="search-btn-text">搜索</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索结果 -->
|
||||||
|
<view v-if="searched" class="result-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">搜索结果</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="searchLoading" class="loading-wrap">
|
||||||
|
<text class="loading-text">搜索中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="searchResults.length > 0" class="result-list">
|
||||||
|
<view
|
||||||
|
v-for="user in searchResults"
|
||||||
|
:key="user.user_id"
|
||||||
|
class="user-item"
|
||||||
|
>
|
||||||
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
|
||||||
|
<text class="avatar-text">{{ getInitial(user.nickname || user.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="user-info">
|
||||||
|
<text class="user-name">{{ user.nickname || user.username }}</text>
|
||||||
|
<text class="user-account">@{{ user.username }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="user.is_friend" class="status-tag status-tag--friend">
|
||||||
|
<text class="status-tag-text">已是好友</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="add-btn"
|
||||||
|
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
|
||||||
|
@tap="showAddDialog(user)"
|
||||||
|
>
|
||||||
|
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-text">未找到相关用户</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 好友推荐 -->
|
||||||
|
<view v-if="!searched" class="recommend-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">好友推荐</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="recommendLoading" class="loading-wrap">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="recommendations.length > 0" class="result-list">
|
||||||
|
<view
|
||||||
|
v-for="user in recommendations"
|
||||||
|
:key="user.user_id"
|
||||||
|
class="user-item"
|
||||||
|
>
|
||||||
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
|
||||||
|
<text class="avatar-text">{{ getInitial(user.nickname || user.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="user-info">
|
||||||
|
<text class="user-name">{{ user.nickname || user.username }}</text>
|
||||||
|
<text class="user-desc" v-if="user.common_count">{{ user.common_count }} 位共同好友</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="add-btn"
|
||||||
|
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
|
||||||
|
@tap="showAddDialog(user)"
|
||||||
|
>
|
||||||
|
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-text">暂无推荐好友</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import contactApi from '@/api/contact'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
|
||||||
|
const contactStore = useContactStore()
|
||||||
|
|
||||||
|
const keyword = ref('')
|
||||||
|
const searched = ref(false)
|
||||||
|
const searchLoading = ref(false)
|
||||||
|
const searchResults = ref([])
|
||||||
|
const recommendLoading = ref(true)
|
||||||
|
const recommendations = ref([])
|
||||||
|
const addingMap = reactive({})
|
||||||
|
|
||||||
|
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
|
||||||
|
|
||||||
|
const getAvatarColor = (name) => {
|
||||||
|
if (!name) return AVATAR_COLORS[0]
|
||||||
|
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitial = (name) => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const doSearch = async () => {
|
||||||
|
const kw = keyword.value.trim()
|
||||||
|
if (!kw) {
|
||||||
|
uni.showToast({ title: '请输入搜索关键词', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searched.value = true
|
||||||
|
searchLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await contactApi.searchUsers(kw)
|
||||||
|
searchResults.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('搜索用户失败', e)
|
||||||
|
searchResults.value = []
|
||||||
|
}
|
||||||
|
searchLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAddDialog = (user) => {
|
||||||
|
if (addingMap[user.user_id]) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '添加好友',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入验证消息(可选)',
|
||||||
|
content: '',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
addingMap[user.user_id] = true
|
||||||
|
try {
|
||||||
|
await contactStore.sendRequest(user.user_id, res.content || '')
|
||||||
|
uni.showToast({ title: '申请已发送', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('发送好友申请失败', e)
|
||||||
|
addingMap[user.user_id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await contactApi.getRecommendFriends()
|
||||||
|
recommendations.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取推荐好友失败', e)
|
||||||
|
}
|
||||||
|
recommendLoading.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索栏 */
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
height: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
padding: 0 28rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
background-color: #2563EB;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* section */
|
||||||
|
.section-header {
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* loading */
|
||||||
|
.loading-wrap {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 结果列表 */
|
||||||
|
.result-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-account {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag--friend {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
background-color: #2563EB;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn--disabled {
|
||||||
|
background-color: #CBD5E1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user