feat(chat): 添加私信功能和WebSocket实时通信
- 集成WebSocket管理器,支持心跳、自动重连、消息队列 - 实现私信相关API接口,包括会话列表、历史消息、已读标记等 - 添加聊天页面和会话列表页面,支持实时消息收发 - 配置地图权限和腾讯地图SDK用于酒局定位功能 - 修改应用名称为"喝酒了么"并更新应用描述 - 在App.vue中初始化WebSocket连接和私信功能 - 添加详细的私信模块接口文档和Mock数据 - 优化单图片动态高度计算和预加载逻辑
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { getCurrentTheme, applyTheme } from './common/utils'
|
||||
import client, { refreshAuthToken } from './common/api'
|
||||
import wsManager from './common/websocket'
|
||||
|
||||
export default {
|
||||
onLaunch() {
|
||||
@@ -9,6 +10,8 @@ export default {
|
||||
this.initTheme()
|
||||
// 尝试刷新 token
|
||||
this.restoreAuth()
|
||||
// 初始化 WebSocket 连接(私信功能)
|
||||
this.initWebSocket()
|
||||
// 数据迁移:清除旧版 logo.png 头像引用
|
||||
this.migrateUserData()
|
||||
// 启动路由守卫
|
||||
@@ -46,6 +49,13 @@ export default {
|
||||
refreshAuthToken().catch(() => {})
|
||||
}
|
||||
},
|
||||
// 初始化 WebSocket 连接(私信实时通信)
|
||||
initWebSocket() {
|
||||
const token = uni.getStorageSync('auth_token')
|
||||
if (token) {
|
||||
wsManager.connect(token)
|
||||
}
|
||||
},
|
||||
// 数据迁移:清除旧版 logo.png 头像引用
|
||||
migrateUserData() {
|
||||
try {
|
||||
|
||||
+33
-1
@@ -158,7 +158,7 @@ export async function refreshAuthToken() {
|
||||
// ==========================================
|
||||
// 酒友圈 API(当前返回 Mock 数据,后端就绪后切换为 SDK 调用)
|
||||
// ==========================================
|
||||
import { MOCK_FEEDS, MOCK_COMMENTS, MOCK_FRIENDS, MOCK_FRIEND_REQUESTS, MOCK_EVENTS } from './mock-data'
|
||||
import { MOCK_FEEDS, MOCK_COMMENTS, MOCK_FRIENDS, MOCK_FRIEND_REQUESTS, MOCK_EVENTS, MOCK_CONVERSATIONS, MOCK_MESSAGES } from './mock-data'
|
||||
|
||||
/** 模拟网络延迟 */
|
||||
function mockDelay(data, ms = 300) {
|
||||
@@ -258,3 +258,35 @@ export function QuitEvent({ eventId }) {
|
||||
export function CheckInEvent({ eventId }) {
|
||||
return mockDelay({ success: true })
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 私信 API(当前返回 Mock 数据,后端就绪后切换为 SDK 调用)
|
||||
// ==========================================
|
||||
|
||||
/** 获取会话列表 */
|
||||
export function GetConversations() {
|
||||
// TODO: 后端就绪后替换为 client.GetConversations(...)
|
||||
return mockDelay({ list: MOCK_CONVERSATIONS })
|
||||
}
|
||||
|
||||
/** 获取历史消息 */
|
||||
export function GetMessages({ conversationId, page = 1, pageSize = 20 } = {}) {
|
||||
// TODO: 后端就绪后替换为 client.GetMessages(...)
|
||||
const all = MOCK_MESSAGES[conversationId] || []
|
||||
const start = (page - 1) * pageSize
|
||||
const list = all.slice(start, start + pageSize)
|
||||
return mockDelay({ list, hasMore: start + pageSize < all.length })
|
||||
}
|
||||
|
||||
/** 标记会话已读 */
|
||||
export function MarkRead({ conversationId }) {
|
||||
// TODO: 后端就绪后替换为 client.MarkRead(...)
|
||||
return mockDelay({ success: true })
|
||||
}
|
||||
|
||||
/** 获取未读消息总数 */
|
||||
export function GetUnreadCount() {
|
||||
// TODO: 后端就绪后替换为 client.GetUnreadCount(...)
|
||||
const total = MOCK_CONVERSATIONS.reduce((sum, c) => sum + c.unread, 0)
|
||||
return mockDelay({ total })
|
||||
}
|
||||
|
||||
@@ -260,6 +260,46 @@ export const MOCK_COMMENTS = {
|
||||
]
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 私信 Mock 数据
|
||||
// ==========================================
|
||||
|
||||
// 模拟会话列表
|
||||
export const MOCK_CONVERSATIONS = [
|
||||
{ id: 'conv_001', friendId: 'f001', nickname: '老张', avatar: '', lastMessage: '今晚一起喝点?', lastTime: '10:32', unread: 2, online: true },
|
||||
{ id: 'conv_002', friendId: 'f002', nickname: '酒仙李白', avatar: '', lastMessage: '那批啤酒链接发你了', lastTime: '昨天', unread: 0, online: true },
|
||||
{ id: 'conv_003', friendId: 'f003', nickname: '小红', avatar: '', lastMessage: '梅见那个确实好喝!', lastTime: '周一', unread: 1, online: false },
|
||||
{ id: 'conv_004', friendId: 'f005', nickname: '品酒师Tony', avatar: '', lastMessage: '下次品鉴会带你一个', lastTime: '上周', unread: 0, online: true }
|
||||
]
|
||||
|
||||
// 模拟聊天记录(按 conversationId 分组)
|
||||
export const MOCK_MESSAGES = {
|
||||
conv_001: [
|
||||
{ id: 'msg_101', conversationId: 'conv_001', senderId: 'f001', receiverId: 'user_001', type: 'text', content: '在吗?', timestamp: 1721556000000, status: 'read' },
|
||||
{ id: 'msg_102', conversationId: 'conv_001', senderId: 'user_001', receiverId: 'f001', type: 'text', content: '在的,怎么了老张', timestamp: 1721556060000, status: 'read' },
|
||||
{ id: 'msg_103', conversationId: 'conv_001', senderId: 'f001', receiverId: 'user_001', type: 'text', content: '搞了瓶飞天,一个人喝没意思', timestamp: 1721556120000, status: 'read' },
|
||||
{ id: 'msg_104', conversationId: 'conv_001', senderId: 'user_001', receiverId: 'f001', type: 'text', content: '哟,茅台!几点?', timestamp: 1721556180000, status: 'read' },
|
||||
{ id: 'msg_105', conversationId: 'conv_001', senderId: 'f001', receiverId: 'user_001', type: 'text', content: '老地方,7点', timestamp: 1721556240000, status: 'read' },
|
||||
{ id: 'msg_106', conversationId: 'conv_001', senderId: 'f001', receiverId: 'user_001', type: 'text', content: '今晚一起喝点?', timestamp: 1721556300000, status: 'delivered' }
|
||||
],
|
||||
conv_002: [
|
||||
{ id: 'msg_201', conversationId: 'conv_002', senderId: 'user_001', receiverId: 'f002', type: 'text', content: '上次你说的那个精酿啤酒叫什么来着', timestamp: 1721470000000, status: 'read' },
|
||||
{ id: 'msg_202', conversationId: 'conv_002', senderId: 'f002', receiverId: 'user_001', type: 'text', content: '熊猫精酿,蜂蜜艾尔', timestamp: 1721470060000, status: 'read' },
|
||||
{ id: 'msg_203', conversationId: 'conv_002', senderId: 'f002', receiverId: 'user_001', type: 'text', content: '那批啤酒链接发你了', timestamp: 1721470120000, status: 'read' }
|
||||
],
|
||||
conv_003: [
|
||||
{ id: 'msg_301', conversationId: 'conv_003', senderId: 'f003', receiverId: 'user_001', type: 'text', content: '你动态里那个梅见好喝吗?', timestamp: 1721380000000, status: 'read' },
|
||||
{ id: 'msg_302', conversationId: 'conv_003', senderId: 'user_001', receiverId: 'f003', type: 'text', content: '超好喝!酸酸甜甜,推荐原味', timestamp: 1721380060000, status: 'read' },
|
||||
{ id: 'msg_303', conversationId: 'conv_003', senderId: 'f003', receiverId: 'user_001', type: 'text', content: '梅见那个确实好喝!', timestamp: 1721380120000, status: 'delivered' }
|
||||
],
|
||||
conv_004: [
|
||||
{ id: 'msg_401', conversationId: 'conv_004', senderId: 'f005', receiverId: 'user_001', type: 'text', content: '下周有个威士忌品鉴会,来不来?', timestamp: 1721290000000, status: 'read' },
|
||||
{ id: 'msg_402', conversationId: 'conv_004', senderId: 'user_001', receiverId: 'f005', type: 'text', content: '可以啊,都有什么酒?', timestamp: 1721290060000, status: 'read' },
|
||||
{ id: 'msg_403', conversationId: 'conv_004', senderId: 'f005', receiverId: 'user_001', type: 'text', content: '麦卡伦、格兰菲迪、百富,三款12年', timestamp: 1721290120000, status: 'read' },
|
||||
{ id: 'msg_404', conversationId: 'conv_004', senderId: 'f005', receiverId: 'user_001', type: 'text', content: '下次品鉴会带你一个', timestamp: 1721290180000, status: 'read' }
|
||||
]
|
||||
}
|
||||
|
||||
// 模拟酒局活动
|
||||
export const MOCK_EVENTS = [
|
||||
{
|
||||
@@ -268,6 +308,9 @@ export const MOCK_EVENTS = [
|
||||
organizer: { id: 'f001', nickname: '老张', avatar: '' },
|
||||
time: '2026-07-25 19:00',
|
||||
location: '渝味晓宇火锅(解放碑店)',
|
||||
address: '重庆市渝中区解放碑步行街88号',
|
||||
latitude: 29.5580,
|
||||
longitude: 106.5780,
|
||||
maxPeople: 8,
|
||||
joined: 5,
|
||||
participants: [
|
||||
@@ -288,6 +331,9 @@ export const MOCK_EVENTS = [
|
||||
organizer: { id: 'f005', nickname: '品酒师Tony', avatar: '' },
|
||||
time: '2026-07-27 20:00',
|
||||
location: 'Malt Bar(国贸店)',
|
||||
address: '北京市朝阳区建国门外大街1号国贸商城B1',
|
||||
latitude: 39.9087,
|
||||
longitude: 116.4605,
|
||||
maxPeople: 6,
|
||||
joined: 3,
|
||||
participants: [
|
||||
@@ -306,6 +352,9 @@ export const MOCK_EVENTS = [
|
||||
organizer: { id: 'f002', nickname: '酒仙李白', avatar: '' },
|
||||
time: '2026-07-20 18:00',
|
||||
location: '望京啤酒花园',
|
||||
address: '北京市朝阳区望京街9号',
|
||||
latitude: 39.9959,
|
||||
longitude: 116.4767,
|
||||
maxPeople: 12,
|
||||
joined: 10,
|
||||
participants: [],
|
||||
@@ -320,6 +369,9 @@ export const MOCK_EVENTS = [
|
||||
organizer: { id: 'f003', nickname: '小红', avatar: '' },
|
||||
time: '2026-07-15 19:30',
|
||||
location: 'The Grill(三里屯)',
|
||||
address: '北京市朝阳区三里屯路19号',
|
||||
latitude: 39.9325,
|
||||
longitude: 116.4536,
|
||||
maxPeople: 4,
|
||||
joined: 4,
|
||||
participants: [],
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/* 干杯日记 - WebSocket 管理器
|
||||
* 单例模式,负责私信实时通信
|
||||
* 支持心跳、自动重连、消息队列
|
||||
*/
|
||||
|
||||
const WS_HOST = 'wss://dev.wash-painting.cn/ws'
|
||||
|
||||
class WebSocketManager {
|
||||
constructor() {
|
||||
this.socketTask = null
|
||||
this.isConnected = false
|
||||
this.isConnecting = false
|
||||
this.listeners = {} // 事件订阅 { event: [callbacks] }
|
||||
this.messageQueue = [] // 断线期间暂存消息
|
||||
this.heartbeatTimer = null
|
||||
this.heartbeatTimeout = null
|
||||
this.reconnectTimer = null
|
||||
this.reconnectAttempts = 0
|
||||
this.maxReconnectDelay = 30000
|
||||
this.heartbeatInterval = 30000 // 30s 发一次 ping
|
||||
this.heartbeatWait = 60000 // 60s 无 pong 则重连
|
||||
this.manualClose = false
|
||||
this.token = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WebSocket 连接
|
||||
* @param {string} token - 用户认证 token
|
||||
*/
|
||||
connect(token) {
|
||||
if (this.isConnected || this.isConnecting) return
|
||||
this.token = token || uni.getStorageSync('auth_token') || ''
|
||||
if (!this.token) {
|
||||
console.warn('[WS] 无 token,跳过连接')
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
this.isConnecting = true
|
||||
|
||||
const url = `${WS_HOST}?token=${this.token}`
|
||||
console.log('[WS] 正在连接...', url)
|
||||
|
||||
this.socketTask = uni.connectSocket({
|
||||
url,
|
||||
complete: () => {}
|
||||
})
|
||||
|
||||
this.socketTask.onOpen(() => {
|
||||
console.log('[WS] 连接成功')
|
||||
this.isConnected = true
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
this._startHeartbeat()
|
||||
this._flushQueue()
|
||||
this._emit('connect', {})
|
||||
})
|
||||
|
||||
this.socketTask.onMessage((res) => {
|
||||
try {
|
||||
const msg = JSON.parse(res.data)
|
||||
this._handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.warn('[WS] 消息解析失败', res.data)
|
||||
}
|
||||
})
|
||||
|
||||
this.socketTask.onClose(() => {
|
||||
console.log('[WS] 连接关闭')
|
||||
this.isConnected = false
|
||||
this.isConnecting = false
|
||||
this._stopHeartbeat()
|
||||
this._emit('disconnect', {})
|
||||
if (!this.manualClose) {
|
||||
this._scheduleReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
this.socketTask.onError((err) => {
|
||||
console.warn('[WS] 连接错误', err)
|
||||
this.isConnected = false
|
||||
this.isConnecting = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 主动断开连接 */
|
||||
disconnect() {
|
||||
this.manualClose = true
|
||||
this._stopHeartbeat()
|
||||
this._clearReconnect()
|
||||
if (this.socketTask) {
|
||||
this.socketTask.close()
|
||||
this.socketTask = null
|
||||
}
|
||||
this.isConnected = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @param {string} type - 消息类型: chat | typing | read | ping
|
||||
* @param {object} data - 消息数据
|
||||
*/
|
||||
send(type, data = {}) {
|
||||
const payload = JSON.stringify({ type, data, ts: Date.now() })
|
||||
if (this.isConnected && this.socketTask) {
|
||||
this.socketTask.send({ data: payload })
|
||||
} else {
|
||||
// 断线暂存队列
|
||||
if (type !== 'ping') {
|
||||
this.messageQueue.push(payload)
|
||||
}
|
||||
// 尝试重连
|
||||
if (!this.isConnecting && !this.manualClose) {
|
||||
this._scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
* @param {string} event - message | typing | read | ack | connect | disconnect
|
||||
* @param {function} callback
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = []
|
||||
}
|
||||
this.listeners[event].push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
* @param {string} event
|
||||
* @param {function} callback
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (!this.listeners[event]) return
|
||||
if (callback) {
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
|
||||
} else {
|
||||
delete this.listeners[event]
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 内部方法 ==========
|
||||
|
||||
/** 处理收到的消息 */
|
||||
_handleMessage(msg) {
|
||||
const { type, data } = msg
|
||||
switch (type) {
|
||||
case 'pong':
|
||||
this._onPong()
|
||||
break
|
||||
case 'chat':
|
||||
this._emit('message', data)
|
||||
break
|
||||
case 'typing':
|
||||
this._emit('typing', data)
|
||||
break
|
||||
case 'read':
|
||||
this._emit('read', data)
|
||||
break
|
||||
case 'ack':
|
||||
this._emit('ack', data)
|
||||
break
|
||||
default:
|
||||
console.log('[WS] 未知消息类型', type)
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发事件 */
|
||||
_emit(event, data) {
|
||||
const cbs = this.listeners[event]
|
||||
if (cbs) {
|
||||
cbs.forEach(cb => {
|
||||
try { cb(data) } catch (e) { console.warn('[WS] 事件回调异常', e) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动心跳 */
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send('ping')
|
||||
// 设置超时检测
|
||||
this.heartbeatTimeout = setTimeout(() => {
|
||||
console.warn('[WS] 心跳超时,触发重连')
|
||||
if (this.socketTask) {
|
||||
this.socketTask.close()
|
||||
}
|
||||
}, this.heartbeatWait)
|
||||
}, this.heartbeatInterval)
|
||||
}
|
||||
|
||||
/** 收到 pong,清除超时 */
|
||||
_onPong() {
|
||||
if (this.heartbeatTimeout) {
|
||||
clearTimeout(this.heartbeatTimeout)
|
||||
this.heartbeatTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止心跳 */
|
||||
_stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
if (this.heartbeatTimeout) {
|
||||
clearTimeout(this.heartbeatTimeout)
|
||||
this.heartbeatTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 指数退避重连 */
|
||||
_scheduleReconnect() {
|
||||
if (this.manualClose || this.reconnectTimer) return
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay)
|
||||
this.reconnectAttempts++
|
||||
console.log(`[WS] ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.isConnecting = false
|
||||
this.connect(this.token)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 清除重连计时器 */
|
||||
_clearReconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
|
||||
/** 重连成功后重发队列消息 */
|
||||
_flushQueue() {
|
||||
if (!this.messageQueue.length) return
|
||||
console.log(`[WS] 重发 ${this.messageQueue.length} 条暂存消息`)
|
||||
const queue = [...this.messageQueue]
|
||||
this.messageQueue = []
|
||||
queue.forEach(payload => {
|
||||
if (this.socketTask && this.isConnected) {
|
||||
this.socketTask.send({ data: payload })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 导出全局单例
|
||||
const wsManager = new WebSocketManager()
|
||||
export default wsManager
|
||||
@@ -0,0 +1,465 @@
|
||||
# 干杯日记 - 私信模块接口文档
|
||||
|
||||
> 版本: v1.0
|
||||
> 日期: 2026-07-21
|
||||
> 模块: 私信(实时通信)
|
||||
> 小程序: 干杯日记(uni-app 微信小程序)
|
||||
> 基础URL: `https://dev.wash-painting.cn/api/v1`
|
||||
> WebSocket: `wss://dev.wash-painting.cn/ws`
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块概述
|
||||
|
||||
私信是酒友圈的实时通信子模块,允许酒友之间一对一发送消息。
|
||||
|
||||
| 子功能 | 说明 |
|
||||
|--------|------|
|
||||
| 会话列表 | 展示所有聊天会话,含最后消息、未读数 |
|
||||
| 聊天记录 | 分页加载历史消息 |
|
||||
| 实时收发 | 通过 WebSocket 实时推送/接收消息 |
|
||||
| 已读回执 | 标记消息已读,通知对方 |
|
||||
| 输入状态 | 实时显示"对方正在输入..." |
|
||||
|
||||
**前端当前状态:** 所有 REST 接口已在 `common/api.js` 中预留方法(暂用 Mock);WebSocket 管理器已在 `common/websocket.js` 中实现(含心跳、重连、消息队列)。后端实现后前端仅需切换调用方式。**请严格按照本文档的字段名和协议格式实现**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 通用约定
|
||||
|
||||
### 2.1 鉴权
|
||||
- REST 请求头:`Authorization: Bearer {token}`
|
||||
- WebSocket 连接:`wss://dev.wash-painting.cn/ws?token={token}`
|
||||
- 所有接口均需登录态
|
||||
|
||||
### 2.2 REST 统一响应格式
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 错误码
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 400 | 参数错误 |
|
||||
| 401 | 未登录/token过期 |
|
||||
| 403 | 无权限(如:非酒友关系不可发私信) |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器错误 |
|
||||
|
||||
### 2.4 时间格式
|
||||
- REST 接口时间字段使用 ISO 8601:`2026-07-21T22:30:00.000Z`
|
||||
- WebSocket 消息中 timestamp 使用**毫秒时间戳**(如 `1721560000000`),便于前端排序
|
||||
|
||||
---
|
||||
|
||||
## 3. REST 接口
|
||||
|
||||
### 3.1 获取会话列表
|
||||
```
|
||||
GET /chat/conversations
|
||||
```
|
||||
|
||||
**查询参数:** 无
|
||||
|
||||
**响应 data:**
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"id": "conv_001",
|
||||
"friendId": "user_002",
|
||||
"nickname": "老张",
|
||||
"avatar": "https://xxx/avatar.jpg",
|
||||
"lastMessage": "今晚一起喝点?",
|
||||
"lastTime": "2026-07-21T10:32:00.000Z",
|
||||
"unread": 2,
|
||||
"online": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Conversation 字段说明:**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | string | 会话ID |
|
||||
| friendId | string | 对方用户ID |
|
||||
| nickname | string | 对方昵称 |
|
||||
| avatar | string | 对方头像URL(可为空字符串) |
|
||||
| lastMessage | string | 最后一条消息摘要(截取前30字) |
|
||||
| lastTime | string | 最后消息时间 ISO 8601 |
|
||||
| unread | int | 未读消息数 |
|
||||
| online | boolean | 对方是否在线 |
|
||||
|
||||
**业务规则:**
|
||||
- 按 lastTime 倒序排列
|
||||
- 仅返回有消息记录的会话
|
||||
- 仅酒友之间可存在会话
|
||||
|
||||
---
|
||||
|
||||
### 3.2 获取历史消息
|
||||
```
|
||||
GET /chat/messages
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| conversationId | string | 是 | 会话ID |
|
||||
| page | int | 否 | 页码,默认1 |
|
||||
| pageSize | int | 否 | 每页数量,默认20 |
|
||||
|
||||
**响应 data:**
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"id": "msg_001",
|
||||
"conversationId": "conv_001",
|
||||
"senderId": "user_002",
|
||||
"receiverId": "user_001",
|
||||
"type": "text",
|
||||
"content": "今晚一起喝点?",
|
||||
"timestamp": 1721556300000,
|
||||
"status": "read"
|
||||
}
|
||||
],
|
||||
"hasMore": true
|
||||
}
|
||||
```
|
||||
|
||||
**Message 字段说明:**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | string | 消息ID(服务端生成) |
|
||||
| conversationId | string | 所属会话ID |
|
||||
| senderId | string | 发送者用户ID |
|
||||
| receiverId | string | 接收者用户ID |
|
||||
| type | string | 消息类型:`text` / `image` |
|
||||
| content | string | 消息内容(文本内容 或 图片URL) |
|
||||
| timestamp | long | 发送时间(毫秒时间戳) |
|
||||
| status | string | 消息状态:`sent` / `delivered` / `read` |
|
||||
|
||||
**业务规则:**
|
||||
- 按 timestamp 正序返回(最早在前)
|
||||
- status 表示该消息对接收方的状态(发送方自己的消息显示对方是否已读)
|
||||
|
||||
---
|
||||
|
||||
### 3.3 标记会话已读
|
||||
```
|
||||
POST /chat/conversations/:id/read
|
||||
```
|
||||
|
||||
**请求参数:** 无(路径参数 conversationId)
|
||||
|
||||
**响应 data:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
**业务规则:**
|
||||
- 将该会话中所有对方发来的消息标记为 read
|
||||
- 会话的 unread 清零
|
||||
- 幂等处理
|
||||
|
||||
---
|
||||
|
||||
### 3.4 获取未读消息总数
|
||||
```
|
||||
GET /chat/unread-count
|
||||
```
|
||||
|
||||
**响应 data:**
|
||||
```json
|
||||
{ "total": 3 }
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| total | int | 所有会话未读消息总和 |
|
||||
|
||||
---
|
||||
|
||||
## 4. WebSocket 协议
|
||||
|
||||
### 4.1 连接
|
||||
|
||||
```
|
||||
wss://dev.wash-painting.cn/ws?token={jwt_token}
|
||||
```
|
||||
|
||||
- 连接建立后服务端验证 token,无效则返回 close code 4001
|
||||
- 连接成功后服务端推送:
|
||||
```json
|
||||
{ "type": "connected", "data": { "userId": "user_001" } }
|
||||
```
|
||||
|
||||
### 4.2 消息帧格式
|
||||
|
||||
所有 WebSocket 消息均为 JSON 文本帧:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "消息类型",
|
||||
"data": { ... },
|
||||
"ts": 1721560000000
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| type | string | 消息类型标识 |
|
||||
| data | object | 业务数据(type=ping/pong 时可省略) |
|
||||
| ts | long | 客户端发送时间戳(毫秒),服务端原样返回用于延迟计算 |
|
||||
|
||||
---
|
||||
|
||||
### 4.3 客户端 → 服务端
|
||||
|
||||
#### 发送聊天消息
|
||||
```json
|
||||
{
|
||||
"type": "chat",
|
||||
"data": {
|
||||
"receiverId": "user_002",
|
||||
"content": "今晚一起喝点?",
|
||||
"msgType": "text",
|
||||
"clientMsgId": "msg_1721560000000_a3f2"
|
||||
},
|
||||
"ts": 1721560000000
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| receiverId | string | 是 | 接收者用户ID |
|
||||
| content | string | 是 | 消息内容(文本 或 图片URL) |
|
||||
| msgType | string | 是 | `text` / `image` |
|
||||
| clientMsgId | string | 是 | 客户端生成的临时消息ID(用于ACK匹配) |
|
||||
|
||||
#### 发送输入状态
|
||||
```json
|
||||
{
|
||||
"type": "typing",
|
||||
"data": { "receiverId": "user_002" },
|
||||
"ts": 1721560000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 发送已读回执
|
||||
```json
|
||||
{
|
||||
"type": "read",
|
||||
"data": {
|
||||
"conversationId": "conv_001",
|
||||
"lastMsgId": "msg_006"
|
||||
},
|
||||
"ts": 1721560000000
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| conversationId | string | 会话ID |
|
||||
| lastMsgId | string | 已读到的最后一条消息ID |
|
||||
|
||||
#### 心跳
|
||||
```json
|
||||
{ "type": "ping" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 服务端 → 客户端
|
||||
|
||||
#### 推送新消息
|
||||
```json
|
||||
{
|
||||
"type": "chat",
|
||||
"data": {
|
||||
"msgId": "msg_srv_001",
|
||||
"senderId": "user_002",
|
||||
"receiverId": "user_001",
|
||||
"conversationId": "conv_001",
|
||||
"content": "好啊,几点?",
|
||||
"msgType": "text",
|
||||
"timestamp": 1721560060000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| msgId | string | 服务端生成的消息ID |
|
||||
| senderId | string | 发送者ID |
|
||||
| receiverId | string | 接收者ID |
|
||||
| conversationId | string | 会话ID |
|
||||
| content | string | 消息内容 |
|
||||
| msgType | string | `text` / `image` |
|
||||
| timestamp | long | 服务端接收时间(毫秒时间戳) |
|
||||
|
||||
#### 消息送达确认(ACK)
|
||||
```json
|
||||
{
|
||||
"type": "ack",
|
||||
"data": {
|
||||
"clientMsgId": "msg_1721560000000_a3f2",
|
||||
"msgId": "msg_srv_001",
|
||||
"status": "sent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| clientMsgId | string | 客户端原始消息ID(用于匹配) |
|
||||
| msgId | string | 服务端分配的正式消息ID |
|
||||
| status | string | `sent`(已入库) |
|
||||
|
||||
> 前端收到 ACK 后将本地消息的 id 替换为 msgId,status 更新为 sent。
|
||||
|
||||
#### 推送输入状态
|
||||
```json
|
||||
{
|
||||
"type": "typing",
|
||||
"data": { "senderId": "user_002" }
|
||||
}
|
||||
```
|
||||
|
||||
#### 推送已读回执
|
||||
```json
|
||||
{
|
||||
"type": "read",
|
||||
"data": {
|
||||
"conversationId": "conv_001",
|
||||
"readerId": "user_002"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 前端收到后将该会话中自己发送的消息 status 更新为 read。
|
||||
|
||||
#### 心跳响应
|
||||
```json
|
||||
{ "type": "pong" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 心跳与重连机制(前端已实现,后端需配合)
|
||||
|
||||
| 机制 | 说明 |
|
||||
|------|------|
|
||||
| 心跳间隔 | 前端每 **30秒** 发送 `ping`,后端收到后立即回复 `pong` |
|
||||
| 超时判定 | 前端 60秒 未收到 `pong` 则主动断开重连 |
|
||||
| 重连策略 | 指数退避:1s → 2s → 4s → 8s → ... → 最大30s |
|
||||
| 离线消息 | 用户重新连接后,后端应推送离线期间收到的消息(按时间正序) |
|
||||
| 连接互踢 | 同一用户多端登录时,旧连接推送 `{"type":"kicked"}` 后关闭 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据库表设计参考
|
||||
|
||||
### 5.1 会话表 (conversations)
|
||||
```sql
|
||||
CREATE TABLE conversations (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
user_a_id VARCHAR(32) NOT NULL COMMENT '用户A(ID较小者)',
|
||||
user_b_id VARCHAR(32) NOT NULL COMMENT '用户B(ID较大者)',
|
||||
last_message VARCHAR(128) COMMENT '最后消息摘要',
|
||||
last_message_time DATETIME COMMENT '最后消息时间',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE INDEX idx_users (user_a_id, user_b_id),
|
||||
INDEX idx_user_a (user_a_id, updated_at),
|
||||
INDEX idx_user_b (user_b_id, updated_at)
|
||||
);
|
||||
```
|
||||
> 两人之间只有一个会话,user_a_id < user_b_id 保证唯一。
|
||||
|
||||
### 5.2 消息表 (messages)
|
||||
```sql
|
||||
CREATE TABLE messages (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
conversation_id VARCHAR(32) NOT NULL,
|
||||
sender_id VARCHAR(32) NOT NULL,
|
||||
receiver_id VARCHAR(32) NOT NULL,
|
||||
type ENUM('text', 'image') DEFAULT 'text',
|
||||
content TEXT NOT NULL,
|
||||
status ENUM('sent', 'delivered', 'read') DEFAULT 'sent',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_conv_time (conversation_id, created_at),
|
||||
INDEX idx_receiver_status (receiver_id, status)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.3 未读计数表 (unread_counts)
|
||||
```sql
|
||||
CREATE TABLE unread_counts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id VARCHAR(32) NOT NULL,
|
||||
conversation_id VARCHAR(32) NOT NULL,
|
||||
count INT DEFAULT 0,
|
||||
UNIQUE INDEX idx_user_conv (user_id, conversation_id),
|
||||
INDEX idx_user (user_id)
|
||||
);
|
||||
```
|
||||
> 收到新消息时 count+1,标记已读时归零。
|
||||
|
||||
---
|
||||
|
||||
## 6. 业务规则汇总
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 酒友限制 | 仅酒友关系可发私信,非酒友返回 403 |
|
||||
| 消息长度 | 文本消息最长 500 字 |
|
||||
| 图片消息 | content 为图片URL(先调上传接口),前端展示为图片气泡 |
|
||||
| 内容安全 | 文本消息需接入微信 msgSecCheck 审核,图片需接入 imgSecCheck |
|
||||
| 会话自动创建 | 首次发消息时自动创建会话记录 |
|
||||
| 未读计数 | 新消息到达时接收方 unread+1;调用标记已读或 WebSocket read 事件时归零 |
|
||||
| 消息撤回 | v1.0 暂不支持,后续扩展 |
|
||||
| 离线推送 | v1.0 暂不接入微信订阅消息推送,用户上线后拉取未读即可 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端对接说明
|
||||
|
||||
### 7.1 前端调用位置
|
||||
|
||||
**REST 接口**(`common/api.js`):
|
||||
```js
|
||||
GetConversations() // GET /chat/conversations
|
||||
GetMessages({ conversationId, page }) // GET /chat/messages
|
||||
MarkRead({ conversationId }) // POST /chat/conversations/:id/read
|
||||
GetUnreadCount() // GET /chat/unread-count
|
||||
```
|
||||
|
||||
**WebSocket**(`common/websocket.js`):
|
||||
```js
|
||||
wsManager.connect(token) // 建立连接
|
||||
wsManager.send('chat', { receiverId, content, msgType, clientMsgId })
|
||||
wsManager.send('typing', { receiverId })
|
||||
wsManager.send('read', { conversationId, lastMsgId })
|
||||
wsManager.on('message', handler) // 收到新消息
|
||||
wsManager.on('typing', handler) // 对方正在输入
|
||||
wsManager.on('read', handler) // 对方已读
|
||||
wsManager.on('ack', handler) // 消息送达确认
|
||||
```
|
||||
|
||||
### 7.2 注意事项
|
||||
1. **字段名严格一致**:前端已按本文档字段名渲染,请勿更改命名
|
||||
2. **timestamp 为毫秒时间戳**:WebSocket 消息中的时间使用 long 型毫秒时间戳
|
||||
3. **clientMsgId 必须原样返回**:ACK 中需包含客户端发送的 clientMsgId,前端依赖此字段匹配本地消息
|
||||
4. **conversationId 生成规则**:建议用双方 userId 排序拼接(如 `conv_{minId}_{maxId}`),确保两人间唯一
|
||||
5. **头像可为空**:avatar 字段允许空字符串,前端有默认占位头像
|
||||
6. **内容安全**:消息内容需接入微信内容安全审核(msgSecCheck / imgSecCheck)
|
||||
7. **幂等性**:标记已读接口需幂等,重复调用不报错
|
||||
8. **离线消息**:用户重连后需推送离线消息,或前端通过 REST 接口拉取(当前方案为 REST 拉取)
|
||||
+18
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name" : "干杯日记",
|
||||
"name" : "喝酒了么",
|
||||
"appid" : "__UNI__871D23D",
|
||||
"description" : "让每一杯酒都有迹可循 - 干杯日记",
|
||||
"description" : "让每一杯酒都有迹可循",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
@@ -41,7 +41,12 @@
|
||||
"minified" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"permission" : {},
|
||||
"requiredPrivateInfos" : ["chooseLocation", "getLocation"],
|
||||
"permission" : {
|
||||
"scope.userLocation" : {
|
||||
"desc" : "用于选择酒局地点和导航"
|
||||
}
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
}
|
||||
@@ -55,5 +60,14 @@
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
"vueVersion" : "3",
|
||||
"h5" : {
|
||||
"sdkConfigs" : {
|
||||
"maps" : {
|
||||
"tencent" : {
|
||||
"key" : "P54BZ-HDSYL-L7YPL-MJ2J7-FBNEQ-LPBEO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -97,6 +97,20 @@
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat-list/chat-list",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/chat",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
+13
-5
@@ -177,11 +177,22 @@ export default {
|
||||
// 动态计算卡片高度
|
||||
const drinks = rec.drinks || []
|
||||
const photoCount = (rec.photos || []).length
|
||||
let photoAreaH = 0
|
||||
const gap = 12
|
||||
const colW = (maxW - gap) / 2
|
||||
|
||||
// 预加载图片(获取宽高用于动态布局)
|
||||
const photos = rec.photos || []
|
||||
const imgs = photos.length > 0 ? await Promise.all(photos.map(p => this.loadImg(p))) : []
|
||||
|
||||
// 单图动态高度:按图片真实比例,限制在 200~560 之间
|
||||
let heroH = 340
|
||||
if (photoCount === 1 && imgs[0] && imgs[0].width && imgs[0].height) {
|
||||
heroH = Math.round(Math.min(560, Math.max(200, maxW * imgs[0].height / imgs[0].width)))
|
||||
}
|
||||
|
||||
let photoAreaH = 0
|
||||
if (photoCount === 0) photoAreaH = 240
|
||||
else if (photoCount === 1) photoAreaH = 340
|
||||
else if (photoCount === 1) photoAreaH = heroH
|
||||
else if (photoCount === 2) photoAreaH = colW
|
||||
else if (photoCount === 3) photoAreaH = 240 + gap + colW
|
||||
else if (photoCount === 4) photoAreaH = 2 * colW + gap
|
||||
@@ -252,11 +263,8 @@ export default {
|
||||
y += 50
|
||||
|
||||
// === 照片/图标区域 ===
|
||||
const photos = rec.photos || []
|
||||
if (photos.length > 0) {
|
||||
const imgs = await Promise.all(photos.map(p => this.loadImg(p)))
|
||||
if (photos.length === 1) {
|
||||
const heroH = 340
|
||||
ctx.save()
|
||||
this.rr(ctx, P, y, maxW, heroH, 24, 'fill')
|
||||
ctx.clip()
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<view :class="themeClass" class="page-container chatlist-page">
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="chatlist-nav" :style="{ paddingTop: headerPaddingTop }">
|
||||
<view class="chatlist-nav-inner">
|
||||
<view class="nav-back" @click="goBack">
|
||||
<text class="nav-back-icon">‹</text>
|
||||
</view>
|
||||
<text class="nav-title">消息</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<scroll-view
|
||||
class="chatlist-scroll"
|
||||
scroll-y
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<view class="chatlist-content">
|
||||
<view
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
@click="goChat(conv)"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<view class="conv-avatar-wrap">
|
||||
<image v-if="conv.avatar" class="conv-avatar" :src="conv.avatar" mode="aspectFill"></image>
|
||||
<view v-else class="conv-avatar conv-avatar-ph">
|
||||
<text class="conv-avatar-text">{{ conv.nickname ? conv.nickname[0] : '酒' }}</text>
|
||||
</view>
|
||||
<view v-if="conv.online" class="conv-online"></view>
|
||||
</view>
|
||||
|
||||
<!-- 信息 -->
|
||||
<view class="conv-info">
|
||||
<view class="conv-top">
|
||||
<text class="conv-name">{{ conv.nickname }}</text>
|
||||
<text class="conv-time">{{ conv.lastTime }}</text>
|
||||
</view>
|
||||
<view class="conv-bottom">
|
||||
<text class="conv-last">{{ conv.lastMessage }}</text>
|
||||
<view v-if="conv.unread > 0" class="conv-badge">
|
||||
<text class="conv-badge-text">{{ conv.unread > 99 ? '99+' : conv.unread }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState
|
||||
v-if="!conversations.length && !loading"
|
||||
icon="💬"
|
||||
title="暂无私信"
|
||||
desc="去酒友圈找个酒友聊聊吧"
|
||||
actionText="去酒友圈"
|
||||
@action="goCircle"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EmptyState from '../../components/EmptyState.vue'
|
||||
import { GetConversations } from '../../common/api'
|
||||
import themeMixin from '../../common/theme-mixin'
|
||||
|
||||
export default {
|
||||
mixins: [themeMixin],
|
||||
components: { EmptyState },
|
||||
data() {
|
||||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||
return {
|
||||
headerPaddingTop: (menuBtn.top + 8) + 'px',
|
||||
conversations: [],
|
||||
loading: false,
|
||||
refreshing: false
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadConversations()
|
||||
},
|
||||
methods: {
|
||||
async loadConversations() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await GetConversations()
|
||||
this.conversations = res.data.list
|
||||
} catch (e) {
|
||||
console.warn('加载会话失败', e)
|
||||
}
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
},
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.loadConversations()
|
||||
},
|
||||
goChat(conv) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/chat?friendId=${conv.friendId}&nickname=${encodeURIComponent(conv.nickname)}&conversationId=${conv.id}`
|
||||
})
|
||||
},
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
goCircle() {
|
||||
uni.switchTab({ url: '/pages/circle/circle' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chatlist-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chatlist-nav {
|
||||
background: $bg-base;
|
||||
padding-left: $sp-lg;
|
||||
padding-right: $sp-lg;
|
||||
padding-bottom: $sp-md;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chatlist-nav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $bg-card;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 44rpx;
|
||||
color: $text-primary;
|
||||
font-weight: $fw-bold;
|
||||
margin-top: -4rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: $fs-lg;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.nav-placeholder {
|
||||
width: 64rpx;
|
||||
}
|
||||
|
||||
.chatlist-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.chatlist-content {
|
||||
padding: $sp-md $sp-lg;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 40rpx);
|
||||
}
|
||||
|
||||
/* 会话项 */
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-md;
|
||||
padding: $sp-lg;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
margin-bottom: $sp-sm;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.conv-avatar-ph {
|
||||
background: linear-gradient(135deg, rgba(232,168,56,0.15), rgba(139,133,184,0.15));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.conv-avatar-text {
|
||||
font-size: $fs-lg;
|
||||
font-weight: $fw-bold;
|
||||
color: $amber;
|
||||
}
|
||||
|
||||
.conv-online {
|
||||
position: absolute;
|
||||
bottom: 4rpx;
|
||||
right: 4rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: $mint;
|
||||
border: 3rpx solid $bg-card;
|
||||
}
|
||||
|
||||
.conv-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.conv-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-size: $fs-base;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $sp-sm;
|
||||
}
|
||||
|
||||
.conv-last {
|
||||
flex: 1;
|
||||
font-size: $fs-sm;
|
||||
color: $text-tertiary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 36rpx;
|
||||
height: 36rpx;
|
||||
padding: 0 10rpx;
|
||||
border-radius: 18rpx;
|
||||
background: $coral;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.conv-badge-text {
|
||||
font-size: 20rpx;
|
||||
color: #fff;
|
||||
font-weight: $fw-bold;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<view :class="themeClass" class="page-container chat-page">
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="chat-nav" :style="{ paddingTop: headerPaddingTop }">
|
||||
<view class="chat-nav-inner">
|
||||
<view class="nav-back" @click="goBack">
|
||||
<text class="nav-back-icon">‹</text>
|
||||
</view>
|
||||
<view class="nav-center">
|
||||
<text class="nav-title">{{ nickname }}</text>
|
||||
<text class="nav-status">{{ peerTyping ? '正在输入...' : (isOnline ? '在线' : '离线') }}</text>
|
||||
</view>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<scroll-view
|
||||
class="chat-scroll"
|
||||
scroll-y
|
||||
:scroll-into-view="scrollToId"
|
||||
:scroll-with-animation="true"
|
||||
>
|
||||
<view class="chat-messages">
|
||||
<template v-for="(msg, idx) in messages" :key="msg.id">
|
||||
<!-- 时间分隔线 -->
|
||||
<view v-if="showTimeDivider(idx)" class="time-divider">
|
||||
<text class="time-divider-text">{{ formatTime(msg.timestamp) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<view
|
||||
:id="'msg-' + msg.id"
|
||||
class="msg-row"
|
||||
:class="{ 'msg-row-self': isSelf(msg) }"
|
||||
>
|
||||
<!-- 对方头像 -->
|
||||
<view v-if="!isSelf(msg)" class="msg-avatar-wrap">
|
||||
<view class="msg-avatar msg-avatar-ph">
|
||||
<text class="msg-avatar-text">{{ nickname ? nickname[0] : '酒' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 气泡 -->
|
||||
<view class="msg-bubble" :class="isSelf(msg) ? 'msg-bubble-self' : 'msg-bubble-peer'">
|
||||
<image
|
||||
v-if="msg.type === 'image'"
|
||||
class="msg-image"
|
||||
:src="msg.content"
|
||||
mode="widthFix"
|
||||
@click="previewImage(msg.content)"
|
||||
></image>
|
||||
<text v-else class="msg-text">{{ msg.content }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 自己头像 -->
|
||||
<view v-if="isSelf(msg)" class="msg-avatar-wrap">
|
||||
<view class="msg-avatar msg-avatar-ph msg-avatar-self">
|
||||
<text class="msg-avatar-text">我</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息状态(仅自己发送的) -->
|
||||
<view v-if="isSelf(msg) && msg.status === 'sending'" class="msg-status">
|
||||
<text class="msg-status-text">发送中...</text>
|
||||
</view>
|
||||
<view v-if="isSelf(msg) && msg.status === 'failed'" class="msg-status msg-status-fail" @click="resend(msg)">
|
||||
<text class="msg-status-text">发送失败,点击重试</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 对方正在输入 -->
|
||||
<view v-if="peerTyping" class="msg-row">
|
||||
<view class="msg-avatar-wrap">
|
||||
<view class="msg-avatar msg-avatar-ph">
|
||||
<text class="msg-avatar-text">{{ nickname ? nickname[0] : '酒' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="msg-bubble msg-bubble-peer msg-typing">
|
||||
<text class="msg-typing-dot">·</text>
|
||||
<text class="msg-typing-dot">·</text>
|
||||
<text class="msg-typing-dot">·</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部锚点 -->
|
||||
<view id="msg-bottom" style="height: 20rpx;"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入栏 -->
|
||||
<view class="chat-input-bar">
|
||||
<view class="chat-input-wrap">
|
||||
<input
|
||||
class="chat-input"
|
||||
v-model="inputText"
|
||||
placeholder="说点什么..."
|
||||
placeholder-class="chat-input-ph"
|
||||
confirm-type="send"
|
||||
:adjust-position="true"
|
||||
@confirm="sendMessage"
|
||||
@input="onInput"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
class="chat-send-btn"
|
||||
:class="{ 'chat-send-active': inputText.trim() }"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<text class="chat-send-icon">➤</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { GetMessages, MarkRead } from '../../common/api'
|
||||
import wsManager from '../../common/websocket'
|
||||
import themeMixin from '../../common/theme-mixin'
|
||||
|
||||
export default {
|
||||
mixins: [themeMixin],
|
||||
data() {
|
||||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||
return {
|
||||
headerPaddingTop: (menuBtn.top + 8) + 'px',
|
||||
friendId: '',
|
||||
nickname: '',
|
||||
conversationId: '',
|
||||
messages: [],
|
||||
inputText: '',
|
||||
scrollToId: '',
|
||||
isOnline: false,
|
||||
peerTyping: false,
|
||||
typingTimer: null,
|
||||
page: 1,
|
||||
hasMore: true
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.friendId = options.friendId || ''
|
||||
this.nickname = decodeURIComponent(options.nickname || '')
|
||||
this.conversationId = options.conversationId || ''
|
||||
this.loadMessages()
|
||||
this.markRead()
|
||||
this.bindWsEvents()
|
||||
},
|
||||
onUnload() {
|
||||
this.unbindWsEvents()
|
||||
if (this.typingTimer) {
|
||||
clearTimeout(this.typingTimer)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// === 数据加载 ===
|
||||
async loadMessages() {
|
||||
try {
|
||||
const res = await GetMessages({ conversationId: this.conversationId, page: this.page })
|
||||
this.messages = res.data.list
|
||||
this.hasMore = res.data.hasMore
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom()
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('加载消息失败', e)
|
||||
}
|
||||
},
|
||||
|
||||
async markRead() {
|
||||
try {
|
||||
await MarkRead({ conversationId: this.conversationId })
|
||||
// 通过 WebSocket 发送已读回执
|
||||
if (this.messages.length) {
|
||||
const lastMsg = this.messages[this.messages.length - 1]
|
||||
wsManager.send('read', {
|
||||
conversationId: this.conversationId,
|
||||
lastMsgId: lastMsg.id
|
||||
})
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
// === WebSocket 事件 ===
|
||||
bindWsEvents() {
|
||||
wsManager.on('message', this.onWsMessage)
|
||||
wsManager.on('typing', this.onWsTyping)
|
||||
wsManager.on('ack', this.onWsAck)
|
||||
},
|
||||
unbindWsEvents() {
|
||||
wsManager.off('message', this.onWsMessage)
|
||||
wsManager.off('typing', this.onWsTyping)
|
||||
wsManager.off('ack', this.onWsAck)
|
||||
},
|
||||
|
||||
/** 收到新消息 */
|
||||
onWsMessage(data) {
|
||||
// 只处理当前会话的消息
|
||||
if (data.senderId !== this.friendId) return
|
||||
const msg = {
|
||||
id: data.msgId || 'msg_' + Date.now(),
|
||||
conversationId: this.conversationId,
|
||||
senderId: data.senderId,
|
||||
receiverId: 'user_001',
|
||||
type: data.msgType || 'text',
|
||||
content: data.content,
|
||||
timestamp: data.timestamp || Date.now(),
|
||||
status: 'sent'
|
||||
}
|
||||
this.messages.push(msg)
|
||||
this.peerTyping = false
|
||||
this.$nextTick(() => this.scrollToBottom())
|
||||
// 发送已读回执
|
||||
wsManager.send('read', {
|
||||
conversationId: this.conversationId,
|
||||
lastMsgId: msg.id
|
||||
})
|
||||
},
|
||||
|
||||
/** 对方正在输入 */
|
||||
onWsTyping(data) {
|
||||
if (data.senderId !== this.friendId) return
|
||||
this.peerTyping = true
|
||||
if (this.typingTimer) clearTimeout(this.typingTimer)
|
||||
this.typingTimer = setTimeout(() => {
|
||||
this.peerTyping = false
|
||||
}, 3000)
|
||||
},
|
||||
|
||||
/** 消息送达确认 */
|
||||
onWsAck(data) {
|
||||
const msg = this.messages.find(m => m.id === data.clientMsgId)
|
||||
if (msg) {
|
||||
msg.id = data.msgId || msg.id
|
||||
msg.status = data.status || 'sent'
|
||||
}
|
||||
},
|
||||
|
||||
// === 发送消息 ===
|
||||
sendMessage() {
|
||||
const text = this.inputText.trim()
|
||||
if (!text) return
|
||||
|
||||
const clientMsgId = 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
|
||||
const msg = {
|
||||
id: clientMsgId,
|
||||
conversationId: this.conversationId,
|
||||
senderId: 'user_001',
|
||||
receiverId: this.friendId,
|
||||
type: 'text',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
status: 'sending'
|
||||
}
|
||||
|
||||
// 乐观更新:立即显示
|
||||
this.messages.push(msg)
|
||||
this.inputText = ''
|
||||
this.$nextTick(() => this.scrollToBottom())
|
||||
|
||||
// 通过 WebSocket 发送
|
||||
wsManager.send('chat', {
|
||||
receiverId: this.friendId,
|
||||
content: text,
|
||||
msgType: 'text',
|
||||
clientMsgId
|
||||
})
|
||||
|
||||
// 模拟确认(Mock模式下直接标记为已发送)
|
||||
setTimeout(() => {
|
||||
msg.status = 'sent'
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 重发失败消息 */
|
||||
resend(msg) {
|
||||
msg.status = 'sending'
|
||||
wsManager.send('chat', {
|
||||
receiverId: this.friendId,
|
||||
content: msg.content,
|
||||
msgType: msg.type,
|
||||
clientMsgId: msg.id
|
||||
})
|
||||
setTimeout(() => {
|
||||
msg.status = 'sent'
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 输入时通知对方 */
|
||||
onInput() {
|
||||
wsManager.send('typing', { receiverId: this.friendId })
|
||||
},
|
||||
|
||||
// === 辅助方法 ===
|
||||
isSelf(msg) {
|
||||
return msg.senderId === 'user_001'
|
||||
},
|
||||
|
||||
showTimeDivider(idx) {
|
||||
if (idx === 0) return true
|
||||
const prev = this.messages[idx - 1]
|
||||
const curr = this.messages[idx]
|
||||
return (curr.timestamp - prev.timestamp) > 5 * 60 * 1000
|
||||
},
|
||||
|
||||
formatTime(ts) {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
const isToday = d.toDateString() === now.toDateString()
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
if (isToday) return `${h}:${m}`
|
||||
const month = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
return `${month}月${day}日 ${h}:${m}`
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
this.scrollToId = ''
|
||||
this.$nextTick(() => {
|
||||
this.scrollToId = 'msg-bottom'
|
||||
})
|
||||
},
|
||||
|
||||
previewImage(url) {
|
||||
uni.previewImage({ urls: [url] })
|
||||
},
|
||||
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.chat-nav {
|
||||
background: $bg-base;
|
||||
padding-left: $sp-lg;
|
||||
padding-right: $sp-lg;
|
||||
padding-bottom: $sp-sm;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border-bottom: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.chat-nav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $bg-card;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 44rpx;
|
||||
color: $text-primary;
|
||||
font-weight: $fw-bold;
|
||||
margin-top: -4rpx;
|
||||
}
|
||||
|
||||
.nav-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: $fs-base;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.nav-status {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.nav-placeholder {
|
||||
width: 64rpx;
|
||||
}
|
||||
|
||||
/* 消息区域 */
|
||||
.chat-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
padding: $sp-lg;
|
||||
padding-bottom: $sp-md;
|
||||
}
|
||||
|
||||
/* 时间分隔 */
|
||||
.time-divider {
|
||||
text-align: center;
|
||||
padding: $sp-md 0;
|
||||
}
|
||||
|
||||
.time-divider-text {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
background: $bg-card;
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
/* 消息行 */
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $sp-sm;
|
||||
margin-bottom: $sp-md;
|
||||
}
|
||||
|
||||
.msg-row-self {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
.msg-avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.msg-avatar-ph {
|
||||
background: linear-gradient(135deg, rgba(232,168,56,0.15), rgba(139,133,184,0.15));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.msg-avatar-self {
|
||||
background: linear-gradient(135deg, rgba(232,168,56,0.25), rgba(232,168,56,0.1));
|
||||
}
|
||||
|
||||
.msg-avatar-text {
|
||||
font-size: $fs-sm;
|
||||
font-weight: $fw-bold;
|
||||
color: $amber;
|
||||
}
|
||||
|
||||
/* 气泡 */
|
||||
.msg-bubble {
|
||||
max-width: 65%;
|
||||
padding: $sp-md $sp-lg;
|
||||
border-radius: $radius-lg;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.msg-bubble-peer {
|
||||
background: $bg-card-alt;
|
||||
border-top-left-radius: 8rpx;
|
||||
}
|
||||
|
||||
.msg-bubble-self {
|
||||
background: $amber-glow;
|
||||
border: 1rpx solid rgba(232,168,56,0.2);
|
||||
border-top-right-radius: 8rpx;
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: $fs-base;
|
||||
color: $text-primary;
|
||||
line-height: $lh-normal;
|
||||
}
|
||||
|
||||
.msg-image {
|
||||
max-width: 360rpx;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
/* 消息状态 */
|
||||
.msg-status {
|
||||
text-align: right;
|
||||
padding-right: 100rpx;
|
||||
margin-top: -8rpx;
|
||||
margin-bottom: $sp-sm;
|
||||
}
|
||||
|
||||
.msg-status-text {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.msg-status-fail .msg-status-text {
|
||||
color: $coral;
|
||||
}
|
||||
|
||||
/* 正在输入动画 */
|
||||
.msg-typing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: $sp-md $sp-lg;
|
||||
}
|
||||
|
||||
.msg-typing-dot {
|
||||
font-size: $fs-xl;
|
||||
color: $text-tertiary;
|
||||
animation: typingBlink 1.4s infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typingBlink {
|
||||
0%, 60%, 100% { opacity: 0.3; }
|
||||
30% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 底部输入栏 */
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-md;
|
||||
padding: $sp-md $sp-lg;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-md});
|
||||
background: $bg-base;
|
||||
border-top: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.chat-input-wrap {
|
||||
flex: 1;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-full;
|
||||
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
padding: 0 $sp-lg;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
font-size: $fs-base;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.chat-input-ph {
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: $bg-card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all $duration-fast $ease-out;
|
||||
}
|
||||
|
||||
.chat-send-active {
|
||||
background: linear-gradient(135deg, $amber, $amber-deep);
|
||||
box-shadow: $shadow-amber;
|
||||
}
|
||||
|
||||
.chat-send-icon {
|
||||
font-size: 32rpx;
|
||||
color: $text-tertiary;
|
||||
|
||||
.chat-send-active & {
|
||||
color: $text-on-amber;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+80
-6
@@ -2,7 +2,15 @@
|
||||
<view :class="themeClass" class="page-container circle-page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="circle-header" :style="{ paddingTop: headerPaddingTop }">
|
||||
<text class="circle-title">酒友圈</text>
|
||||
<view class="circle-title-row" :style="{ paddingRight: headerPaddingRight }">
|
||||
<text class="circle-title">酒友圈</text>
|
||||
<view class="msg-entry" @click="goChatList">
|
||||
<text class="msg-entry-icon">💬</text>
|
||||
<view v-if="unreadTotal > 0" class="msg-badge">
|
||||
<text class="msg-badge-text">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- Tab 切换 -->
|
||||
<view class="circle-tabs">
|
||||
<view
|
||||
@@ -67,7 +75,7 @@
|
||||
v-for="f in friends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
@tap="goFriends"
|
||||
@tap="goChat(f)"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!friends.length"
|
||||
@@ -112,7 +120,7 @@ import FeedCard from '../../components/FeedCard.vue'
|
||||
import FriendItem from '../../components/FriendItem.vue'
|
||||
import EventCard from '../../components/EventCard.vue'
|
||||
import EmptyState from '../../components/EmptyState.vue'
|
||||
import { GetCircleFeeds, LikeFeed, UnlikeFeed, GetFriends, GetFriendRequests, GetEvents } from '../../common/api'
|
||||
import { GetCircleFeeds, LikeFeed, UnlikeFeed, GetFriends, GetFriendRequests, GetEvents, GetUnreadCount } from '../../common/api'
|
||||
import themeMixin from '../../common/theme-mixin'
|
||||
|
||||
export default {
|
||||
@@ -120,10 +128,12 @@ export default {
|
||||
components: { FeedCard, FriendItem, EventCard, EmptyState },
|
||||
data() {
|
||||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
return {
|
||||
tabs: ['动态', '酒友', '酒局'],
|
||||
currentTab: 0,
|
||||
headerPaddingTop: (menuBtn.top + 8) + 'px',
|
||||
headerPaddingRight: (sysInfo.windowWidth - menuBtn.left + 8) + 'px',
|
||||
// 动态
|
||||
feeds: [],
|
||||
page: 1,
|
||||
@@ -134,13 +144,16 @@ export default {
|
||||
friends: [],
|
||||
friendRequests: [],
|
||||
// 酒局
|
||||
events: []
|
||||
events: [],
|
||||
// 私信未读
|
||||
unreadTotal: 0
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadFeeds(true)
|
||||
this.loadFriends()
|
||||
this.loadEvents()
|
||||
this.loadUnread()
|
||||
},
|
||||
methods: {
|
||||
switchTab(i) {
|
||||
@@ -212,6 +225,22 @@ export default {
|
||||
goFriends() {
|
||||
uni.navigateTo({ url: '/pages/friends/friends' })
|
||||
},
|
||||
goChat(friend) {
|
||||
// 根据 friendId 查找对应会话 (f001 -> conv_001)
|
||||
const convId = 'conv_' + friend.id.replace('f', '')
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/chat?friendId=${friend.id}&nickname=${encodeURIComponent(friend.nickname)}&conversationId=${convId}`
|
||||
})
|
||||
},
|
||||
goChatList() {
|
||||
uni.navigateTo({ url: '/pages/chat-list/chat-list' })
|
||||
},
|
||||
async loadUnread() {
|
||||
try {
|
||||
const res = await GetUnreadCount()
|
||||
this.unreadTotal = res.data.total
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
// === 酒局 ===
|
||||
async loadEvents() {
|
||||
try {
|
||||
@@ -256,12 +285,57 @@ export default {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.circle-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $sp-lg;
|
||||
}
|
||||
|
||||
.circle-title {
|
||||
display: block;
|
||||
font-size: $fs-2xl;
|
||||
font-weight: $fw-black;
|
||||
color: $text-primary;
|
||||
margin-bottom: $sp-lg;
|
||||
}
|
||||
|
||||
.msg-entry {
|
||||
position: relative;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $bg-card;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-entry-icon {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.msg-badge {
|
||||
position: absolute;
|
||||
top: -4rpx;
|
||||
right: -4rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 16rpx;
|
||||
background: $coral;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.msg-badge-text {
|
||||
font-size: 18rpx;
|
||||
color: #fff;
|
||||
font-weight: $fw-bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.circle-tabs {
|
||||
|
||||
@@ -45,13 +45,31 @@
|
||||
<!-- 地点 -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">地点</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="form.location"
|
||||
placeholder="输入聚会地点"
|
||||
placeholder-class="form-placeholder"
|
||||
:maxlength="50"
|
||||
/>
|
||||
<!-- 已选地点:显示地图预览 -->
|
||||
<view v-if="form.latitude" class="location-preview">
|
||||
<map
|
||||
class="location-map"
|
||||
:latitude="form.latitude"
|
||||
:longitude="form.longitude"
|
||||
:markers="locationMarkers"
|
||||
:scale="15"
|
||||
:show-location="false"
|
||||
@click="chooseLocation"
|
||||
></map>
|
||||
<view class="location-info">
|
||||
<text class="location-name">{{ form.location }}</text>
|
||||
<text class="location-address">{{ form.address || '' }}</text>
|
||||
</view>
|
||||
<view class="location-change" @click="chooseLocation">
|
||||
<text class="location-change-text">重新选择</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 未选地点:点击选择 -->
|
||||
<view v-else class="location-picker" @click="chooseLocation">
|
||||
<text class="location-picker-icon">📍</text>
|
||||
<text class="location-picker-text">点击选择地点</text>
|
||||
<text class="location-picker-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 人数 -->
|
||||
@@ -104,6 +122,9 @@ export default {
|
||||
date: '',
|
||||
time: '',
|
||||
location: '',
|
||||
address: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
maxPeople: 6,
|
||||
note: ''
|
||||
},
|
||||
@@ -113,6 +134,17 @@ export default {
|
||||
computed: {
|
||||
canSubmit() {
|
||||
return this.form.title.trim() && this.form.date && this.form.time && this.form.location.trim() && !this.submitting
|
||||
},
|
||||
locationMarkers() {
|
||||
if (!this.form.latitude) return []
|
||||
return [{
|
||||
id: 1,
|
||||
latitude: this.form.latitude,
|
||||
longitude: this.form.longitude,
|
||||
title: this.form.location,
|
||||
width: 28,
|
||||
height: 38
|
||||
}]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -125,6 +157,19 @@ export default {
|
||||
onTimeChange(e) {
|
||||
this.form.time = e.detail.value
|
||||
},
|
||||
chooseLocation() {
|
||||
uni.chooseLocation({
|
||||
success: (res) => {
|
||||
this.form.location = res.name || res.address || ''
|
||||
this.form.address = res.address || ''
|
||||
this.form.latitude = res.latitude
|
||||
this.form.longitude = res.longitude
|
||||
},
|
||||
fail: () => {
|
||||
// 用户取消或无权限,不做处理
|
||||
}
|
||||
})
|
||||
},
|
||||
changePeople(delta) {
|
||||
const val = this.form.maxPeople + delta
|
||||
if (val >= 2 && val <= 50) {
|
||||
@@ -139,6 +184,9 @@ export default {
|
||||
title: this.form.title,
|
||||
time: `${this.form.date} ${this.form.time}`,
|
||||
location: this.form.location,
|
||||
address: this.form.address,
|
||||
latitude: this.form.latitude,
|
||||
longitude: this.form.longitude,
|
||||
maxPeople: this.form.maxPeople,
|
||||
note: this.form.note
|
||||
})
|
||||
@@ -279,6 +327,86 @@ export default {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 地点选择器 */
|
||||
.location-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-md;
|
||||
height: 96rpx;
|
||||
padding: 0 $sp-lg;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
border: 2rpx dashed var(--border-dashed, rgba(255,255,255,0.12));
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.location-picker-icon {
|
||||
font-size: $fs-lg;
|
||||
}
|
||||
|
||||
.location-picker-text {
|
||||
flex: 1;
|
||||
font-size: $fs-base;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.location-picker-arrow {
|
||||
font-size: $fs-xl;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
/* 地图预览 */
|
||||
.location-preview {
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.location-map {
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
padding: $sp-md $sp-lg;
|
||||
background: $bg-card;
|
||||
}
|
||||
|
||||
.location-name {
|
||||
font-size: $fs-base;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.location-address {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.location-change {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $sp-sm;
|
||||
background: $bg-card-alt;
|
||||
|
||||
&:active {
|
||||
background: $bg-elevated;
|
||||
}
|
||||
}
|
||||
|
||||
.location-change-text {
|
||||
font-size: $fs-sm;
|
||||
color: $amber;
|
||||
font-weight: $fw-medium;
|
||||
}
|
||||
|
||||
/* 人数步进器 */
|
||||
.form-stepper {
|
||||
display: flex;
|
||||
|
||||
@@ -53,6 +53,22 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 地图展示 -->
|
||||
<view class="evt-map-section" v-if="event.latitude">
|
||||
<map
|
||||
class="evt-map"
|
||||
:latitude="event.latitude"
|
||||
:longitude="event.longitude"
|
||||
:markers="eventMarkers"
|
||||
:scale="15"
|
||||
:show-location="true"
|
||||
></map>
|
||||
<view class="evt-map-nav-btn" @click="openNavigation">
|
||||
<text class="evt-map-nav-icon">🧭</text>
|
||||
<text class="evt-map-nav-text">导航到这里</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发起人 -->
|
||||
<view class="evt-organizer">
|
||||
<text class="evt-section-label">发起人</text>
|
||||
@@ -131,6 +147,17 @@ export default {
|
||||
if (!this.event) return ''
|
||||
const map = { open: '报名中', ongoing: '进行中', ended: '已结束' }
|
||||
return map[this.event.status] || '报名中'
|
||||
},
|
||||
eventMarkers() {
|
||||
if (!this.event || !this.event.latitude) return []
|
||||
return [{
|
||||
id: 1,
|
||||
latitude: this.event.latitude,
|
||||
longitude: this.event.longitude,
|
||||
title: this.event.location,
|
||||
width: 28,
|
||||
height: 38
|
||||
}]
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
@@ -186,6 +213,18 @@ export default {
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '签到失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
openNavigation() {
|
||||
if (!this.event || !this.event.latitude) return
|
||||
uni.openLocation({
|
||||
latitude: this.event.latitude,
|
||||
longitude: this.event.longitude,
|
||||
name: this.event.location,
|
||||
address: this.event.address || this.event.location,
|
||||
fail: () => {
|
||||
uni.showToast({ title: '无法打开地图', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +363,42 @@ export default {
|
||||
font-weight: $fw-medium;
|
||||
}
|
||||
|
||||
/* 地图区域 */
|
||||
.evt-map-section {
|
||||
margin-bottom: $sp-xl;
|
||||
border-radius: $radius-xl;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.evt-map {
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
}
|
||||
|
||||
.evt-map-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $sp-sm;
|
||||
padding: $sp-md;
|
||||
background: $bg-card;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.evt-map-nav-icon {
|
||||
font-size: $fs-base;
|
||||
}
|
||||
|
||||
.evt-map-nav-text {
|
||||
font-size: $fs-base;
|
||||
color: $amber;
|
||||
font-weight: $fw-bold;
|
||||
}
|
||||
|
||||
/* 发起人 & 参与者 */
|
||||
.evt-section-label {
|
||||
display: block;
|
||||
|
||||
+69
-15
@@ -166,10 +166,18 @@
|
||||
<view class="dice-cup" :class="{ 'cup-shaking': diceShaking }" @click="diceRoll">
|
||||
<view class="dice-pair">
|
||||
<view class="die" :class="{ 'die-rolling': diceShaking }">
|
||||
<text class="die-face">{{ diceShaking ? '?' : diceFaces[diceA] }}</text>
|
||||
<view class="die-grid">
|
||||
<view class="die-cell" v-for="p in 9" :key="p">
|
||||
<view class="die-pip" v-if="dicePips(diceA).indexOf(p) > -1"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="die die-2" :class="{ 'die-rolling': diceShaking }">
|
||||
<text class="die-face">{{ diceShaking ? '?' : diceFaces[diceB] }}</text>
|
||||
<view class="die-grid">
|
||||
<view class="die-cell" v-for="p in 9" :key="p">
|
||||
<view class="die-pip" v-if="dicePips(diceB).indexOf(p) > -1"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="dice-sum text-num" v-if="!diceShaking && diceRound > 0">{{ diceA + diceB }}</text>
|
||||
@@ -213,7 +221,9 @@
|
||||
:key="i"
|
||||
:class="{ 'slot-item-active': !wheelSpinning && wheelResult && i === slotTarget }"
|
||||
>
|
||||
<text class="slot-item-emoji">{{ s.emoji }}</text>
|
||||
<view class="slot-item-emoji-box">
|
||||
<text class="slot-item-emoji">{{ s.emoji }}</text>
|
||||
</view>
|
||||
<text class="slot-item-label">{{ s.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -299,7 +309,6 @@ export default {
|
||||
rpsLastPick: '',
|
||||
rpsTimer: null,
|
||||
// --- 骰子 ---
|
||||
diceFaces: ['', '⚀', '⚁', '⚂', '⚃', '⚄', '⚅'],
|
||||
diceA: 1,
|
||||
diceB: 1,
|
||||
diceShaking: false,
|
||||
@@ -452,6 +461,18 @@ export default {
|
||||
},
|
||||
|
||||
/* ========== 骰子大话 ========== */
|
||||
// 骰子点数对应的 pip 位置(3x3 九宫格,1~9 从左到右、从上到下)
|
||||
dicePips(val) {
|
||||
const map = {
|
||||
1: [5],
|
||||
2: [1, 9],
|
||||
3: [1, 5, 9],
|
||||
4: [1, 3, 7, 9],
|
||||
5: [1, 3, 5, 7, 9],
|
||||
6: [1, 3, 4, 6, 7, 9]
|
||||
}
|
||||
return map[val] || []
|
||||
},
|
||||
diceRoll() {
|
||||
if (this.diceShaking) return
|
||||
this.diceShaking = true
|
||||
@@ -716,14 +737,14 @@ export default {
|
||||
|
||||
.rule-title {
|
||||
display: block;
|
||||
font-size: $fs-sm;
|
||||
font-size: 30rpx;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-secondary;
|
||||
margin-bottom: $sp-sm;
|
||||
}
|
||||
|
||||
.rule-text {
|
||||
font-size: $fs-xs;
|
||||
font-size: 30rpx;
|
||||
color: $text-tertiary;
|
||||
line-height: $lh-loose;
|
||||
}
|
||||
@@ -784,7 +805,7 @@ export default {
|
||||
background: linear-gradient(135deg, var(--amber, #E8A838), var(--amber-deep, #C47F17));
|
||||
box-shadow: var(--shadow-amber, 0 8rpx 32rpx rgba(232,168,56,0.25));
|
||||
transition: transform $duration-fast $ease-out, opacity $duration-fast;
|
||||
text { color: var(--text-on-amber, #0B0B14); font-size: $fs-base; font-weight: $fw-bold; }
|
||||
text { color: var(--text-on-amber, #0B0B14); font-size:40rpx; font-weight: $fw-bold; }
|
||||
&:active { transform: scale(0.94); }
|
||||
&.btn-disabled { opacity: 0.5; pointer-events: none; }
|
||||
}
|
||||
@@ -1069,11 +1090,12 @@ export default {
|
||||
}
|
||||
|
||||
.die {
|
||||
width: 120rpx; height: 120rpx;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 132rpx; height: 132rpx;
|
||||
display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
border-radius: $radius-md;
|
||||
background: $bg-elevated;
|
||||
border: 2rpx solid var(--border-subtle, rgba(255,255,255,0.10));
|
||||
background: linear-gradient(160deg, #FBF7EF, #EAE2D3);
|
||||
box-shadow: inset 0 4rpx 8rpx rgba(255,255,255,0.7), inset 0 -6rpx 12rpx rgba(0,0,0,0.12), $shadow-sm;
|
||||
transition: transform $duration-base $ease-bounce;
|
||||
|
||||
&.die-rolling { animation: die-spin 0.3s linear infinite; }
|
||||
@@ -1085,7 +1107,28 @@ export default {
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
.die-face { font-size: 72rpx; color: $text-primary; }
|
||||
.die-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.die-cell {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.die-pip {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
border-radius: 50%;
|
||||
background: #241B10;
|
||||
box-shadow: inset 0 2rpx 3rpx rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.dice-sum {
|
||||
font-size: $fs-2xl;
|
||||
@@ -1199,6 +1242,7 @@ export default {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@@ -1206,7 +1250,7 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $sp-md;
|
||||
gap: $sp-sm;
|
||||
height: 120rpx;
|
||||
border-bottom: 1rpx solid var(--border-micro, rgba(255,255,255,0.05));
|
||||
transition: background $duration-base;
|
||||
@@ -1216,8 +1260,18 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.slot-item-emoji { font-size: 44rpx; }
|
||||
.slot-item-label { font-size: $fs-base; font-weight: $fw-bold; color: $text-primary; }
|
||||
.slot-item-emoji-box {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slot-item-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.slot-item-label { font-size: $fs-base; font-weight: $fw-bold; color: $text-primary; letter-spacing: 2rpx; }
|
||||
|
||||
.slot-mask-top, .slot-mask-bottom {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user