feat(chat): 添加私信功能和WebSocket实时通信

- 集成WebSocket管理器,支持心跳、自动重连、消息队列
- 实现私信相关API接口,包括会话列表、历史消息、已读标记等
- 添加聊天页面和会话列表页面,支持实时消息收发
- 配置地图权限和腾讯地图SDK用于酒局定位功能
- 修改应用名称为"喝酒了么"并更新应用描述
- 在App.vue中初始化WebSocket连接和私信功能
- 添加详细的私信模块接口文档和Mock数据
- 优化单图片动态高度计算和预加载逻辑
This commit is contained in:
cg
2026-07-21 21:27:01 +08:00
parent 0332aabba0
commit eaa0d7101f
14 changed files with 2112 additions and 38 deletions
+10
View File
@@ -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
View File
@@ -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 })
}
+52
View File
@@ -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: [],
+253
View File
@@ -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
+465
View File
@@ -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 替换为 msgIdstatus 更新为 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 '用户AID较小者)',
user_b_id VARCHAR(32) NOT NULL COMMENT '用户BID较大者)',
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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+294
View File
@@ -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>
+601
View File
@@ -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>
+79 -5
View File
@@ -2,7 +2,15 @@
<view :class="themeClass" class="page-container circle-page">
<!-- 顶部导航 -->
<view class="circle-header" :style="{ paddingTop: headerPaddingTop }">
<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 {
+135 -7
View File
@@ -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;
+75
View File
@@ -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;
+68 -14
View File
@@ -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 }"
>
<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;