feat(chat): 添加私信功能和WebSocket实时通信
- 集成WebSocket管理器,支持心跳、自动重连、消息队列 - 实现私信相关API接口,包括会话列表、历史消息、已读标记等 - 添加聊天页面和会话列表页面,支持实时消息收发 - 配置地图权限和腾讯地图SDK用于酒局定位功能 - 修改应用名称为"喝酒了么"并更新应用描述 - 在App.vue中初始化WebSocket连接和私信功能 - 添加详细的私信模块接口文档和Mock数据 - 优化单图片动态高度计算和预加载逻辑
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getCurrentTheme, applyTheme } from './common/utils'
|
import { getCurrentTheme, applyTheme } from './common/utils'
|
||||||
import client, { refreshAuthToken } from './common/api'
|
import client, { refreshAuthToken } from './common/api'
|
||||||
|
import wsManager from './common/websocket'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
@@ -9,6 +10,8 @@ export default {
|
|||||||
this.initTheme()
|
this.initTheme()
|
||||||
// 尝试刷新 token
|
// 尝试刷新 token
|
||||||
this.restoreAuth()
|
this.restoreAuth()
|
||||||
|
// 初始化 WebSocket 连接(私信功能)
|
||||||
|
this.initWebSocket()
|
||||||
// 数据迁移:清除旧版 logo.png 头像引用
|
// 数据迁移:清除旧版 logo.png 头像引用
|
||||||
this.migrateUserData()
|
this.migrateUserData()
|
||||||
// 启动路由守卫
|
// 启动路由守卫
|
||||||
@@ -46,6 +49,13 @@ export default {
|
|||||||
refreshAuthToken().catch(() => {})
|
refreshAuthToken().catch(() => {})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 初始化 WebSocket 连接(私信实时通信)
|
||||||
|
initWebSocket() {
|
||||||
|
const token = uni.getStorageSync('auth_token')
|
||||||
|
if (token) {
|
||||||
|
wsManager.connect(token)
|
||||||
|
}
|
||||||
|
},
|
||||||
// 数据迁移:清除旧版 logo.png 头像引用
|
// 数据迁移:清除旧版 logo.png 头像引用
|
||||||
migrateUserData() {
|
migrateUserData() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+33
-1
@@ -158,7 +158,7 @@ export async function refreshAuthToken() {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// 酒友圈 API(当前返回 Mock 数据,后端就绪后切换为 SDK 调用)
|
// 酒友圈 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) {
|
function mockDelay(data, ms = 300) {
|
||||||
@@ -258,3 +258,35 @@ export function QuitEvent({ eventId }) {
|
|||||||
export function CheckInEvent({ eventId }) {
|
export function CheckInEvent({ eventId }) {
|
||||||
return mockDelay({ success: true })
|
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 = [
|
export const MOCK_EVENTS = [
|
||||||
{
|
{
|
||||||
@@ -268,6 +308,9 @@ export const MOCK_EVENTS = [
|
|||||||
organizer: { id: 'f001', nickname: '老张', avatar: '' },
|
organizer: { id: 'f001', nickname: '老张', avatar: '' },
|
||||||
time: '2026-07-25 19:00',
|
time: '2026-07-25 19:00',
|
||||||
location: '渝味晓宇火锅(解放碑店)',
|
location: '渝味晓宇火锅(解放碑店)',
|
||||||
|
address: '重庆市渝中区解放碑步行街88号',
|
||||||
|
latitude: 29.5580,
|
||||||
|
longitude: 106.5780,
|
||||||
maxPeople: 8,
|
maxPeople: 8,
|
||||||
joined: 5,
|
joined: 5,
|
||||||
participants: [
|
participants: [
|
||||||
@@ -288,6 +331,9 @@ export const MOCK_EVENTS = [
|
|||||||
organizer: { id: 'f005', nickname: '品酒师Tony', avatar: '' },
|
organizer: { id: 'f005', nickname: '品酒师Tony', avatar: '' },
|
||||||
time: '2026-07-27 20:00',
|
time: '2026-07-27 20:00',
|
||||||
location: 'Malt Bar(国贸店)',
|
location: 'Malt Bar(国贸店)',
|
||||||
|
address: '北京市朝阳区建国门外大街1号国贸商城B1',
|
||||||
|
latitude: 39.9087,
|
||||||
|
longitude: 116.4605,
|
||||||
maxPeople: 6,
|
maxPeople: 6,
|
||||||
joined: 3,
|
joined: 3,
|
||||||
participants: [
|
participants: [
|
||||||
@@ -306,6 +352,9 @@ export const MOCK_EVENTS = [
|
|||||||
organizer: { id: 'f002', nickname: '酒仙李白', avatar: '' },
|
organizer: { id: 'f002', nickname: '酒仙李白', avatar: '' },
|
||||||
time: '2026-07-20 18:00',
|
time: '2026-07-20 18:00',
|
||||||
location: '望京啤酒花园',
|
location: '望京啤酒花园',
|
||||||
|
address: '北京市朝阳区望京街9号',
|
||||||
|
latitude: 39.9959,
|
||||||
|
longitude: 116.4767,
|
||||||
maxPeople: 12,
|
maxPeople: 12,
|
||||||
joined: 10,
|
joined: 10,
|
||||||
participants: [],
|
participants: [],
|
||||||
@@ -320,6 +369,9 @@ export const MOCK_EVENTS = [
|
|||||||
organizer: { id: 'f003', nickname: '小红', avatar: '' },
|
organizer: { id: 'f003', nickname: '小红', avatar: '' },
|
||||||
time: '2026-07-15 19:30',
|
time: '2026-07-15 19:30',
|
||||||
location: 'The Grill(三里屯)',
|
location: 'The Grill(三里屯)',
|
||||||
|
address: '北京市朝阳区三里屯路19号',
|
||||||
|
latitude: 39.9325,
|
||||||
|
longitude: 116.4536,
|
||||||
maxPeople: 4,
|
maxPeople: 4,
|
||||||
joined: 4,
|
joined: 4,
|
||||||
participants: [],
|
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",
|
"appid" : "__UNI__871D23D",
|
||||||
"description" : "让每一杯酒都有迹可循 - 干杯日记",
|
"description" : "让每一杯酒都有迹可循",
|
||||||
"versionName" : "1.0.0",
|
"versionName" : "1.0.0",
|
||||||
"versionCode" : "100",
|
"versionCode" : "100",
|
||||||
"transformPx" : false,
|
"transformPx" : false,
|
||||||
@@ -41,7 +41,12 @@
|
|||||||
"minified" : true
|
"minified" : true
|
||||||
},
|
},
|
||||||
"usingComponents" : true,
|
"usingComponents" : true,
|
||||||
"permission" : {},
|
"requiredPrivateInfos" : ["chooseLocation", "getLocation"],
|
||||||
|
"permission" : {
|
||||||
|
"scope.userLocation" : {
|
||||||
|
"desc" : "用于选择酒局地点和导航"
|
||||||
|
}
|
||||||
|
},
|
||||||
"optimization" : {
|
"optimization" : {
|
||||||
"subPackages" : true
|
"subPackages" : true
|
||||||
}
|
}
|
||||||
@@ -55,5 +60,14 @@
|
|||||||
"mp-toutiao" : {
|
"mp-toutiao" : {
|
||||||
"usingComponents" : true
|
"usingComponents" : true
|
||||||
},
|
},
|
||||||
"vueVersion" : "3"
|
"vueVersion" : "3",
|
||||||
|
"h5" : {
|
||||||
|
"sdkConfigs" : {
|
||||||
|
"maps" : {
|
||||||
|
"tencent" : {
|
||||||
|
"key" : "P54BZ-HDSYL-L7YPL-MJ2J7-FBNEQ-LPBEO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -97,6 +97,20 @@
|
|||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": ""
|
"navigationBarTitleText": ""
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/chat-list/chat-list",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/chat/chat",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
+13
-5
@@ -177,11 +177,22 @@ export default {
|
|||||||
// 动态计算卡片高度
|
// 动态计算卡片高度
|
||||||
const drinks = rec.drinks || []
|
const drinks = rec.drinks || []
|
||||||
const photoCount = (rec.photos || []).length
|
const photoCount = (rec.photos || []).length
|
||||||
let photoAreaH = 0
|
|
||||||
const gap = 12
|
const gap = 12
|
||||||
const colW = (maxW - gap) / 2
|
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
|
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 === 2) photoAreaH = colW
|
||||||
else if (photoCount === 3) photoAreaH = 240 + gap + colW
|
else if (photoCount === 3) photoAreaH = 240 + gap + colW
|
||||||
else if (photoCount === 4) photoAreaH = 2 * colW + gap
|
else if (photoCount === 4) photoAreaH = 2 * colW + gap
|
||||||
@@ -252,11 +263,8 @@ export default {
|
|||||||
y += 50
|
y += 50
|
||||||
|
|
||||||
// === 照片/图标区域 ===
|
// === 照片/图标区域 ===
|
||||||
const photos = rec.photos || []
|
|
||||||
if (photos.length > 0) {
|
if (photos.length > 0) {
|
||||||
const imgs = await Promise.all(photos.map(p => this.loadImg(p)))
|
|
||||||
if (photos.length === 1) {
|
if (photos.length === 1) {
|
||||||
const heroH = 340
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
this.rr(ctx, P, y, maxW, heroH, 24, 'fill')
|
this.rr(ctx, P, y, maxW, heroH, 24, 'fill')
|
||||||
ctx.clip()
|
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="themeClass" class="page-container circle-page">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<view class="circle-header" :style="{ paddingTop: headerPaddingTop }">
|
<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 切换 -->
|
<!-- Tab 切换 -->
|
||||||
<view class="circle-tabs">
|
<view class="circle-tabs">
|
||||||
<view
|
<view
|
||||||
@@ -67,7 +75,7 @@
|
|||||||
v-for="f in friends"
|
v-for="f in friends"
|
||||||
:key="f.id"
|
:key="f.id"
|
||||||
:friend="f"
|
:friend="f"
|
||||||
@tap="goFriends"
|
@tap="goChat(f)"
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-if="!friends.length"
|
v-if="!friends.length"
|
||||||
@@ -112,7 +120,7 @@ import FeedCard from '../../components/FeedCard.vue'
|
|||||||
import FriendItem from '../../components/FriendItem.vue'
|
import FriendItem from '../../components/FriendItem.vue'
|
||||||
import EventCard from '../../components/EventCard.vue'
|
import EventCard from '../../components/EventCard.vue'
|
||||||
import EmptyState from '../../components/EmptyState.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'
|
import themeMixin from '../../common/theme-mixin'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -120,10 +128,12 @@ export default {
|
|||||||
components: { FeedCard, FriendItem, EventCard, EmptyState },
|
components: { FeedCard, FriendItem, EventCard, EmptyState },
|
||||||
data() {
|
data() {
|
||||||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||||
|
const sysInfo = uni.getSystemInfoSync()
|
||||||
return {
|
return {
|
||||||
tabs: ['动态', '酒友', '酒局'],
|
tabs: ['动态', '酒友', '酒局'],
|
||||||
currentTab: 0,
|
currentTab: 0,
|
||||||
headerPaddingTop: (menuBtn.top + 8) + 'px',
|
headerPaddingTop: (menuBtn.top + 8) + 'px',
|
||||||
|
headerPaddingRight: (sysInfo.windowWidth - menuBtn.left + 8) + 'px',
|
||||||
// 动态
|
// 动态
|
||||||
feeds: [],
|
feeds: [],
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -134,13 +144,16 @@ export default {
|
|||||||
friends: [],
|
friends: [],
|
||||||
friendRequests: [],
|
friendRequests: [],
|
||||||
// 酒局
|
// 酒局
|
||||||
events: []
|
events: [],
|
||||||
|
// 私信未读
|
||||||
|
unreadTotal: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
this.loadFeeds(true)
|
this.loadFeeds(true)
|
||||||
this.loadFriends()
|
this.loadFriends()
|
||||||
this.loadEvents()
|
this.loadEvents()
|
||||||
|
this.loadUnread()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
switchTab(i) {
|
switchTab(i) {
|
||||||
@@ -212,6 +225,22 @@ export default {
|
|||||||
goFriends() {
|
goFriends() {
|
||||||
uni.navigateTo({ url: '/pages/friends/friends' })
|
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() {
|
async loadEvents() {
|
||||||
try {
|
try {
|
||||||
@@ -256,12 +285,57 @@ export default {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.circle-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $sp-lg;
|
||||||
|
}
|
||||||
|
|
||||||
.circle-title {
|
.circle-title {
|
||||||
display: block;
|
|
||||||
font-size: $fs-2xl;
|
font-size: $fs-2xl;
|
||||||
font-weight: $fw-black;
|
font-weight: $fw-black;
|
||||||
color: $text-primary;
|
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 {
|
.circle-tabs {
|
||||||
|
|||||||
@@ -45,13 +45,31 @@
|
|||||||
<!-- 地点 -->
|
<!-- 地点 -->
|
||||||
<view class="form-group">
|
<view class="form-group">
|
||||||
<text class="form-label">地点</text>
|
<text class="form-label">地点</text>
|
||||||
<input
|
<!-- 已选地点:显示地图预览 -->
|
||||||
class="form-input"
|
<view v-if="form.latitude" class="location-preview">
|
||||||
v-model="form.location"
|
<map
|
||||||
placeholder="输入聚会地点"
|
class="location-map"
|
||||||
placeholder-class="form-placeholder"
|
:latitude="form.latitude"
|
||||||
:maxlength="50"
|
: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>
|
</view>
|
||||||
|
|
||||||
<!-- 人数 -->
|
<!-- 人数 -->
|
||||||
@@ -104,6 +122,9 @@ export default {
|
|||||||
date: '',
|
date: '',
|
||||||
time: '',
|
time: '',
|
||||||
location: '',
|
location: '',
|
||||||
|
address: '',
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
maxPeople: 6,
|
maxPeople: 6,
|
||||||
note: ''
|
note: ''
|
||||||
},
|
},
|
||||||
@@ -113,6 +134,17 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
canSubmit() {
|
canSubmit() {
|
||||||
return this.form.title.trim() && this.form.date && this.form.time && this.form.location.trim() && !this.submitting
|
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: {
|
methods: {
|
||||||
@@ -125,6 +157,19 @@ export default {
|
|||||||
onTimeChange(e) {
|
onTimeChange(e) {
|
||||||
this.form.time = e.detail.value
|
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) {
|
changePeople(delta) {
|
||||||
const val = this.form.maxPeople + delta
|
const val = this.form.maxPeople + delta
|
||||||
if (val >= 2 && val <= 50) {
|
if (val >= 2 && val <= 50) {
|
||||||
@@ -139,6 +184,9 @@ export default {
|
|||||||
title: this.form.title,
|
title: this.form.title,
|
||||||
time: `${this.form.date} ${this.form.time}`,
|
time: `${this.form.date} ${this.form.time}`,
|
||||||
location: this.form.location,
|
location: this.form.location,
|
||||||
|
address: this.form.address,
|
||||||
|
latitude: this.form.latitude,
|
||||||
|
longitude: this.form.longitude,
|
||||||
maxPeople: this.form.maxPeople,
|
maxPeople: this.form.maxPeople,
|
||||||
note: this.form.note
|
note: this.form.note
|
||||||
})
|
})
|
||||||
@@ -279,6 +327,86 @@ export default {
|
|||||||
box-sizing: border-box;
|
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 {
|
.form-stepper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -53,6 +53,22 @@
|
|||||||
</view>
|
</view>
|
||||||
</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">
|
<view class="evt-organizer">
|
||||||
<text class="evt-section-label">发起人</text>
|
<text class="evt-section-label">发起人</text>
|
||||||
@@ -131,6 +147,17 @@ export default {
|
|||||||
if (!this.event) return ''
|
if (!this.event) return ''
|
||||||
const map = { open: '报名中', ongoing: '进行中', ended: '已结束' }
|
const map = { open: '报名中', ongoing: '进行中', ended: '已结束' }
|
||||||
return map[this.event.status] || '报名中'
|
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) {
|
onLoad(options) {
|
||||||
@@ -186,6 +213,18 @@ export default {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
uni.showToast({ title: '签到失败', icon: 'none' })
|
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;
|
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 {
|
.evt-section-label {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
+69
-15
@@ -166,10 +166,18 @@
|
|||||||
<view class="dice-cup" :class="{ 'cup-shaking': diceShaking }" @click="diceRoll">
|
<view class="dice-cup" :class="{ 'cup-shaking': diceShaking }" @click="diceRoll">
|
||||||
<view class="dice-pair">
|
<view class="dice-pair">
|
||||||
<view class="die" :class="{ 'die-rolling': diceShaking }">
|
<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>
|
||||||
<view class="die die-2" :class="{ 'die-rolling': diceShaking }">
|
<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>
|
||||||
</view>
|
</view>
|
||||||
<text class="dice-sum text-num" v-if="!diceShaking && diceRound > 0">{{ diceA + diceB }}</text>
|
<text class="dice-sum text-num" v-if="!diceShaking && diceRound > 0">{{ diceA + diceB }}</text>
|
||||||
@@ -213,7 +221,9 @@
|
|||||||
:key="i"
|
:key="i"
|
||||||
:class="{ 'slot-item-active': !wheelSpinning && wheelResult && i === slotTarget }"
|
: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>
|
<text class="slot-item-label">{{ s.label }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -299,7 +309,6 @@ export default {
|
|||||||
rpsLastPick: '',
|
rpsLastPick: '',
|
||||||
rpsTimer: null,
|
rpsTimer: null,
|
||||||
// --- 骰子 ---
|
// --- 骰子 ---
|
||||||
diceFaces: ['', '⚀', '⚁', '⚂', '⚃', '⚄', '⚅'],
|
|
||||||
diceA: 1,
|
diceA: 1,
|
||||||
diceB: 1,
|
diceB: 1,
|
||||||
diceShaking: false,
|
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() {
|
diceRoll() {
|
||||||
if (this.diceShaking) return
|
if (this.diceShaking) return
|
||||||
this.diceShaking = true
|
this.diceShaking = true
|
||||||
@@ -716,14 +737,14 @@ export default {
|
|||||||
|
|
||||||
.rule-title {
|
.rule-title {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: $fs-sm;
|
font-size: 30rpx;
|
||||||
font-weight: $fw-bold;
|
font-weight: $fw-bold;
|
||||||
color: $text-secondary;
|
color: $text-secondary;
|
||||||
margin-bottom: $sp-sm;
|
margin-bottom: $sp-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-text {
|
.rule-text {
|
||||||
font-size: $fs-xs;
|
font-size: 30rpx;
|
||||||
color: $text-tertiary;
|
color: $text-tertiary;
|
||||||
line-height: $lh-loose;
|
line-height: $lh-loose;
|
||||||
}
|
}
|
||||||
@@ -784,7 +805,7 @@ export default {
|
|||||||
background: linear-gradient(135deg, var(--amber, #E8A838), var(--amber-deep, #C47F17));
|
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));
|
box-shadow: var(--shadow-amber, 0 8rpx 32rpx rgba(232,168,56,0.25));
|
||||||
transition: transform $duration-fast $ease-out, opacity $duration-fast;
|
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); }
|
&:active { transform: scale(0.94); }
|
||||||
&.btn-disabled { opacity: 0.5; pointer-events: none; }
|
&.btn-disabled { opacity: 0.5; pointer-events: none; }
|
||||||
}
|
}
|
||||||
@@ -1069,11 +1090,12 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.die {
|
.die {
|
||||||
width: 120rpx; height: 120rpx;
|
width: 132rpx; height: 132rpx;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
background: $bg-elevated;
|
background: linear-gradient(160deg, #FBF7EF, #EAE2D3);
|
||||||
border: 2rpx solid var(--border-subtle, rgba(255,255,255,0.10));
|
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;
|
transition: transform $duration-base $ease-bounce;
|
||||||
|
|
||||||
&.die-rolling { animation: die-spin 0.3s linear infinite; }
|
&.die-rolling { animation: die-spin 0.3s linear infinite; }
|
||||||
@@ -1085,7 +1107,28 @@ export default {
|
|||||||
100% { transform: rotate(360deg) scale(1); }
|
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 {
|
.dice-sum {
|
||||||
font-size: $fs-2xl;
|
font-size: $fs-2xl;
|
||||||
@@ -1199,6 +1242,7 @@ export default {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0; right: 0;
|
left: 0; right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,7 +1250,7 @@ export default {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: $sp-md;
|
gap: $sp-sm;
|
||||||
height: 120rpx;
|
height: 120rpx;
|
||||||
border-bottom: 1rpx solid var(--border-micro, rgba(255,255,255,0.05));
|
border-bottom: 1rpx solid var(--border-micro, rgba(255,255,255,0.05));
|
||||||
transition: background $duration-base;
|
transition: background $duration-base;
|
||||||
@@ -1216,8 +1260,18 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-item-emoji { font-size: 44rpx; }
|
.slot-item-emoji-box {
|
||||||
.slot-item-label { font-size: $fs-base; font-weight: $fw-bold; color: $text-primary; }
|
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 {
|
.slot-mask-top, .slot-mask-bottom {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in New Issue
Block a user