feat(chat): 添加私信功能和WebSocket实时通信
- 集成WebSocket管理器,支持心跳、自动重连、消息队列 - 实现私信相关API接口,包括会话列表、历史消息、已读标记等 - 添加聊天页面和会话列表页面,支持实时消息收发 - 配置地图权限和腾讯地图SDK用于酒局定位功能 - 修改应用名称为"喝酒了么"并更新应用描述 - 在App.vue中初始化WebSocket连接和私信功能 - 添加详细的私信模块接口文档和Mock数据 - 优化单图片动态高度计算和预加载逻辑
This commit is contained in:
+13
-5
@@ -177,11 +177,22 @@ export default {
|
||||
// 动态计算卡片高度
|
||||
const drinks = rec.drinks || []
|
||||
const photoCount = (rec.photos || []).length
|
||||
let photoAreaH = 0
|
||||
const gap = 12
|
||||
const colW = (maxW - gap) / 2
|
||||
|
||||
// 预加载图片(获取宽高用于动态布局)
|
||||
const photos = rec.photos || []
|
||||
const imgs = photos.length > 0 ? await Promise.all(photos.map(p => this.loadImg(p))) : []
|
||||
|
||||
// 单图动态高度:按图片真实比例,限制在 200~560 之间
|
||||
let heroH = 340
|
||||
if (photoCount === 1 && imgs[0] && imgs[0].width && imgs[0].height) {
|
||||
heroH = Math.round(Math.min(560, Math.max(200, maxW * imgs[0].height / imgs[0].width)))
|
||||
}
|
||||
|
||||
let photoAreaH = 0
|
||||
if (photoCount === 0) photoAreaH = 240
|
||||
else if (photoCount === 1) photoAreaH = 340
|
||||
else if (photoCount === 1) photoAreaH = heroH
|
||||
else if (photoCount === 2) photoAreaH = colW
|
||||
else if (photoCount === 3) photoAreaH = 240 + gap + colW
|
||||
else if (photoCount === 4) photoAreaH = 2 * colW + gap
|
||||
@@ -252,11 +263,8 @@ export default {
|
||||
y += 50
|
||||
|
||||
// === 照片/图标区域 ===
|
||||
const photos = rec.photos || []
|
||||
if (photos.length > 0) {
|
||||
const imgs = await Promise.all(photos.map(p => this.loadImg(p)))
|
||||
if (photos.length === 1) {
|
||||
const heroH = 340
|
||||
ctx.save()
|
||||
this.rr(ctx, P, y, maxW, heroH, 24, 'fill')
|
||||
ctx.clip()
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<view :class="themeClass" class="page-container chatlist-page">
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="chatlist-nav" :style="{ paddingTop: headerPaddingTop }">
|
||||
<view class="chatlist-nav-inner">
|
||||
<view class="nav-back" @click="goBack">
|
||||
<text class="nav-back-icon">‹</text>
|
||||
</view>
|
||||
<text class="nav-title">消息</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<scroll-view
|
||||
class="chatlist-scroll"
|
||||
scroll-y
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<view class="chatlist-content">
|
||||
<view
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
@click="goChat(conv)"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<view class="conv-avatar-wrap">
|
||||
<image v-if="conv.avatar" class="conv-avatar" :src="conv.avatar" mode="aspectFill"></image>
|
||||
<view v-else class="conv-avatar conv-avatar-ph">
|
||||
<text class="conv-avatar-text">{{ conv.nickname ? conv.nickname[0] : '酒' }}</text>
|
||||
</view>
|
||||
<view v-if="conv.online" class="conv-online"></view>
|
||||
</view>
|
||||
|
||||
<!-- 信息 -->
|
||||
<view class="conv-info">
|
||||
<view class="conv-top">
|
||||
<text class="conv-name">{{ conv.nickname }}</text>
|
||||
<text class="conv-time">{{ conv.lastTime }}</text>
|
||||
</view>
|
||||
<view class="conv-bottom">
|
||||
<text class="conv-last">{{ conv.lastMessage }}</text>
|
||||
<view v-if="conv.unread > 0" class="conv-badge">
|
||||
<text class="conv-badge-text">{{ conv.unread > 99 ? '99+' : conv.unread }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState
|
||||
v-if="!conversations.length && !loading"
|
||||
icon="💬"
|
||||
title="暂无私信"
|
||||
desc="去酒友圈找个酒友聊聊吧"
|
||||
actionText="去酒友圈"
|
||||
@action="goCircle"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EmptyState from '../../components/EmptyState.vue'
|
||||
import { GetConversations } from '../../common/api'
|
||||
import themeMixin from '../../common/theme-mixin'
|
||||
|
||||
export default {
|
||||
mixins: [themeMixin],
|
||||
components: { EmptyState },
|
||||
data() {
|
||||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||
return {
|
||||
headerPaddingTop: (menuBtn.top + 8) + 'px',
|
||||
conversations: [],
|
||||
loading: false,
|
||||
refreshing: false
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadConversations()
|
||||
},
|
||||
methods: {
|
||||
async loadConversations() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await GetConversations()
|
||||
this.conversations = res.data.list
|
||||
} catch (e) {
|
||||
console.warn('加载会话失败', e)
|
||||
}
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
},
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.loadConversations()
|
||||
},
|
||||
goChat(conv) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/chat?friendId=${conv.friendId}&nickname=${encodeURIComponent(conv.nickname)}&conversationId=${conv.id}`
|
||||
})
|
||||
},
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
goCircle() {
|
||||
uni.switchTab({ url: '/pages/circle/circle' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chatlist-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chatlist-nav {
|
||||
background: $bg-base;
|
||||
padding-left: $sp-lg;
|
||||
padding-right: $sp-lg;
|
||||
padding-bottom: $sp-md;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chatlist-nav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $bg-card;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 44rpx;
|
||||
color: $text-primary;
|
||||
font-weight: $fw-bold;
|
||||
margin-top: -4rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: $fs-lg;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.nav-placeholder {
|
||||
width: 64rpx;
|
||||
}
|
||||
|
||||
.chatlist-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.chatlist-content {
|
||||
padding: $sp-md $sp-lg;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 40rpx);
|
||||
}
|
||||
|
||||
/* 会话项 */
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-md;
|
||||
padding: $sp-lg;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
margin-bottom: $sp-sm;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.conv-avatar-ph {
|
||||
background: linear-gradient(135deg, rgba(232,168,56,0.15), rgba(139,133,184,0.15));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.conv-avatar-text {
|
||||
font-size: $fs-lg;
|
||||
font-weight: $fw-bold;
|
||||
color: $amber;
|
||||
}
|
||||
|
||||
.conv-online {
|
||||
position: absolute;
|
||||
bottom: 4rpx;
|
||||
right: 4rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: $mint;
|
||||
border: 3rpx solid $bg-card;
|
||||
}
|
||||
|
||||
.conv-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.conv-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-size: $fs-base;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $sp-sm;
|
||||
}
|
||||
|
||||
.conv-last {
|
||||
flex: 1;
|
||||
font-size: $fs-sm;
|
||||
color: $text-tertiary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 36rpx;
|
||||
height: 36rpx;
|
||||
padding: 0 10rpx;
|
||||
border-radius: 18rpx;
|
||||
background: $coral;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.conv-badge-text {
|
||||
font-size: 20rpx;
|
||||
color: #fff;
|
||||
font-weight: $fw-bold;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<view :class="themeClass" class="page-container chat-page">
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="chat-nav" :style="{ paddingTop: headerPaddingTop }">
|
||||
<view class="chat-nav-inner">
|
||||
<view class="nav-back" @click="goBack">
|
||||
<text class="nav-back-icon">‹</text>
|
||||
</view>
|
||||
<view class="nav-center">
|
||||
<text class="nav-title">{{ nickname }}</text>
|
||||
<text class="nav-status">{{ peerTyping ? '正在输入...' : (isOnline ? '在线' : '离线') }}</text>
|
||||
</view>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<scroll-view
|
||||
class="chat-scroll"
|
||||
scroll-y
|
||||
:scroll-into-view="scrollToId"
|
||||
:scroll-with-animation="true"
|
||||
>
|
||||
<view class="chat-messages">
|
||||
<template v-for="(msg, idx) in messages" :key="msg.id">
|
||||
<!-- 时间分隔线 -->
|
||||
<view v-if="showTimeDivider(idx)" class="time-divider">
|
||||
<text class="time-divider-text">{{ formatTime(msg.timestamp) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<view
|
||||
:id="'msg-' + msg.id"
|
||||
class="msg-row"
|
||||
:class="{ 'msg-row-self': isSelf(msg) }"
|
||||
>
|
||||
<!-- 对方头像 -->
|
||||
<view v-if="!isSelf(msg)" class="msg-avatar-wrap">
|
||||
<view class="msg-avatar msg-avatar-ph">
|
||||
<text class="msg-avatar-text">{{ nickname ? nickname[0] : '酒' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 气泡 -->
|
||||
<view class="msg-bubble" :class="isSelf(msg) ? 'msg-bubble-self' : 'msg-bubble-peer'">
|
||||
<image
|
||||
v-if="msg.type === 'image'"
|
||||
class="msg-image"
|
||||
:src="msg.content"
|
||||
mode="widthFix"
|
||||
@click="previewImage(msg.content)"
|
||||
></image>
|
||||
<text v-else class="msg-text">{{ msg.content }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 自己头像 -->
|
||||
<view v-if="isSelf(msg)" class="msg-avatar-wrap">
|
||||
<view class="msg-avatar msg-avatar-ph msg-avatar-self">
|
||||
<text class="msg-avatar-text">我</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息状态(仅自己发送的) -->
|
||||
<view v-if="isSelf(msg) && msg.status === 'sending'" class="msg-status">
|
||||
<text class="msg-status-text">发送中...</text>
|
||||
</view>
|
||||
<view v-if="isSelf(msg) && msg.status === 'failed'" class="msg-status msg-status-fail" @click="resend(msg)">
|
||||
<text class="msg-status-text">发送失败,点击重试</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 对方正在输入 -->
|
||||
<view v-if="peerTyping" class="msg-row">
|
||||
<view class="msg-avatar-wrap">
|
||||
<view class="msg-avatar msg-avatar-ph">
|
||||
<text class="msg-avatar-text">{{ nickname ? nickname[0] : '酒' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="msg-bubble msg-bubble-peer msg-typing">
|
||||
<text class="msg-typing-dot">·</text>
|
||||
<text class="msg-typing-dot">·</text>
|
||||
<text class="msg-typing-dot">·</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部锚点 -->
|
||||
<view id="msg-bottom" style="height: 20rpx;"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入栏 -->
|
||||
<view class="chat-input-bar">
|
||||
<view class="chat-input-wrap">
|
||||
<input
|
||||
class="chat-input"
|
||||
v-model="inputText"
|
||||
placeholder="说点什么..."
|
||||
placeholder-class="chat-input-ph"
|
||||
confirm-type="send"
|
||||
:adjust-position="true"
|
||||
@confirm="sendMessage"
|
||||
@input="onInput"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
class="chat-send-btn"
|
||||
:class="{ 'chat-send-active': inputText.trim() }"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<text class="chat-send-icon">➤</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { GetMessages, MarkRead } from '../../common/api'
|
||||
import wsManager from '../../common/websocket'
|
||||
import themeMixin from '../../common/theme-mixin'
|
||||
|
||||
export default {
|
||||
mixins: [themeMixin],
|
||||
data() {
|
||||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||
return {
|
||||
headerPaddingTop: (menuBtn.top + 8) + 'px',
|
||||
friendId: '',
|
||||
nickname: '',
|
||||
conversationId: '',
|
||||
messages: [],
|
||||
inputText: '',
|
||||
scrollToId: '',
|
||||
isOnline: false,
|
||||
peerTyping: false,
|
||||
typingTimer: null,
|
||||
page: 1,
|
||||
hasMore: true
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.friendId = options.friendId || ''
|
||||
this.nickname = decodeURIComponent(options.nickname || '')
|
||||
this.conversationId = options.conversationId || ''
|
||||
this.loadMessages()
|
||||
this.markRead()
|
||||
this.bindWsEvents()
|
||||
},
|
||||
onUnload() {
|
||||
this.unbindWsEvents()
|
||||
if (this.typingTimer) {
|
||||
clearTimeout(this.typingTimer)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// === 数据加载 ===
|
||||
async loadMessages() {
|
||||
try {
|
||||
const res = await GetMessages({ conversationId: this.conversationId, page: this.page })
|
||||
this.messages = res.data.list
|
||||
this.hasMore = res.data.hasMore
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom()
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('加载消息失败', e)
|
||||
}
|
||||
},
|
||||
|
||||
async markRead() {
|
||||
try {
|
||||
await MarkRead({ conversationId: this.conversationId })
|
||||
// 通过 WebSocket 发送已读回执
|
||||
if (this.messages.length) {
|
||||
const lastMsg = this.messages[this.messages.length - 1]
|
||||
wsManager.send('read', {
|
||||
conversationId: this.conversationId,
|
||||
lastMsgId: lastMsg.id
|
||||
})
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
// === WebSocket 事件 ===
|
||||
bindWsEvents() {
|
||||
wsManager.on('message', this.onWsMessage)
|
||||
wsManager.on('typing', this.onWsTyping)
|
||||
wsManager.on('ack', this.onWsAck)
|
||||
},
|
||||
unbindWsEvents() {
|
||||
wsManager.off('message', this.onWsMessage)
|
||||
wsManager.off('typing', this.onWsTyping)
|
||||
wsManager.off('ack', this.onWsAck)
|
||||
},
|
||||
|
||||
/** 收到新消息 */
|
||||
onWsMessage(data) {
|
||||
// 只处理当前会话的消息
|
||||
if (data.senderId !== this.friendId) return
|
||||
const msg = {
|
||||
id: data.msgId || 'msg_' + Date.now(),
|
||||
conversationId: this.conversationId,
|
||||
senderId: data.senderId,
|
||||
receiverId: 'user_001',
|
||||
type: data.msgType || 'text',
|
||||
content: data.content,
|
||||
timestamp: data.timestamp || Date.now(),
|
||||
status: 'sent'
|
||||
}
|
||||
this.messages.push(msg)
|
||||
this.peerTyping = false
|
||||
this.$nextTick(() => this.scrollToBottom())
|
||||
// 发送已读回执
|
||||
wsManager.send('read', {
|
||||
conversationId: this.conversationId,
|
||||
lastMsgId: msg.id
|
||||
})
|
||||
},
|
||||
|
||||
/** 对方正在输入 */
|
||||
onWsTyping(data) {
|
||||
if (data.senderId !== this.friendId) return
|
||||
this.peerTyping = true
|
||||
if (this.typingTimer) clearTimeout(this.typingTimer)
|
||||
this.typingTimer = setTimeout(() => {
|
||||
this.peerTyping = false
|
||||
}, 3000)
|
||||
},
|
||||
|
||||
/** 消息送达确认 */
|
||||
onWsAck(data) {
|
||||
const msg = this.messages.find(m => m.id === data.clientMsgId)
|
||||
if (msg) {
|
||||
msg.id = data.msgId || msg.id
|
||||
msg.status = data.status || 'sent'
|
||||
}
|
||||
},
|
||||
|
||||
// === 发送消息 ===
|
||||
sendMessage() {
|
||||
const text = this.inputText.trim()
|
||||
if (!text) return
|
||||
|
||||
const clientMsgId = 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
|
||||
const msg = {
|
||||
id: clientMsgId,
|
||||
conversationId: this.conversationId,
|
||||
senderId: 'user_001',
|
||||
receiverId: this.friendId,
|
||||
type: 'text',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
status: 'sending'
|
||||
}
|
||||
|
||||
// 乐观更新:立即显示
|
||||
this.messages.push(msg)
|
||||
this.inputText = ''
|
||||
this.$nextTick(() => this.scrollToBottom())
|
||||
|
||||
// 通过 WebSocket 发送
|
||||
wsManager.send('chat', {
|
||||
receiverId: this.friendId,
|
||||
content: text,
|
||||
msgType: 'text',
|
||||
clientMsgId
|
||||
})
|
||||
|
||||
// 模拟确认(Mock模式下直接标记为已发送)
|
||||
setTimeout(() => {
|
||||
msg.status = 'sent'
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 重发失败消息 */
|
||||
resend(msg) {
|
||||
msg.status = 'sending'
|
||||
wsManager.send('chat', {
|
||||
receiverId: this.friendId,
|
||||
content: msg.content,
|
||||
msgType: msg.type,
|
||||
clientMsgId: msg.id
|
||||
})
|
||||
setTimeout(() => {
|
||||
msg.status = 'sent'
|
||||
}, 500)
|
||||
},
|
||||
|
||||
/** 输入时通知对方 */
|
||||
onInput() {
|
||||
wsManager.send('typing', { receiverId: this.friendId })
|
||||
},
|
||||
|
||||
// === 辅助方法 ===
|
||||
isSelf(msg) {
|
||||
return msg.senderId === 'user_001'
|
||||
},
|
||||
|
||||
showTimeDivider(idx) {
|
||||
if (idx === 0) return true
|
||||
const prev = this.messages[idx - 1]
|
||||
const curr = this.messages[idx]
|
||||
return (curr.timestamp - prev.timestamp) > 5 * 60 * 1000
|
||||
},
|
||||
|
||||
formatTime(ts) {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
const isToday = d.toDateString() === now.toDateString()
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
if (isToday) return `${h}:${m}`
|
||||
const month = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
return `${month}月${day}日 ${h}:${m}`
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
this.scrollToId = ''
|
||||
this.$nextTick(() => {
|
||||
this.scrollToId = 'msg-bottom'
|
||||
})
|
||||
},
|
||||
|
||||
previewImage(url) {
|
||||
uni.previewImage({ urls: [url] })
|
||||
},
|
||||
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.chat-nav {
|
||||
background: $bg-base;
|
||||
padding-left: $sp-lg;
|
||||
padding-right: $sp-lg;
|
||||
padding-bottom: $sp-sm;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border-bottom: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.chat-nav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $bg-card;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 44rpx;
|
||||
color: $text-primary;
|
||||
font-weight: $fw-bold;
|
||||
margin-top: -4rpx;
|
||||
}
|
||||
|
||||
.nav-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: $fs-base;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.nav-status {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.nav-placeholder {
|
||||
width: 64rpx;
|
||||
}
|
||||
|
||||
/* 消息区域 */
|
||||
.chat-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
padding: $sp-lg;
|
||||
padding-bottom: $sp-md;
|
||||
}
|
||||
|
||||
/* 时间分隔 */
|
||||
.time-divider {
|
||||
text-align: center;
|
||||
padding: $sp-md 0;
|
||||
}
|
||||
|
||||
.time-divider-text {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
background: $bg-card;
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
/* 消息行 */
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $sp-sm;
|
||||
margin-bottom: $sp-md;
|
||||
}
|
||||
|
||||
.msg-row-self {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
.msg-avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.msg-avatar-ph {
|
||||
background: linear-gradient(135deg, rgba(232,168,56,0.15), rgba(139,133,184,0.15));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.msg-avatar-self {
|
||||
background: linear-gradient(135deg, rgba(232,168,56,0.25), rgba(232,168,56,0.1));
|
||||
}
|
||||
|
||||
.msg-avatar-text {
|
||||
font-size: $fs-sm;
|
||||
font-weight: $fw-bold;
|
||||
color: $amber;
|
||||
}
|
||||
|
||||
/* 气泡 */
|
||||
.msg-bubble {
|
||||
max-width: 65%;
|
||||
padding: $sp-md $sp-lg;
|
||||
border-radius: $radius-lg;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.msg-bubble-peer {
|
||||
background: $bg-card-alt;
|
||||
border-top-left-radius: 8rpx;
|
||||
}
|
||||
|
||||
.msg-bubble-self {
|
||||
background: $amber-glow;
|
||||
border: 1rpx solid rgba(232,168,56,0.2);
|
||||
border-top-right-radius: 8rpx;
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: $fs-base;
|
||||
color: $text-primary;
|
||||
line-height: $lh-normal;
|
||||
}
|
||||
|
||||
.msg-image {
|
||||
max-width: 360rpx;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
/* 消息状态 */
|
||||
.msg-status {
|
||||
text-align: right;
|
||||
padding-right: 100rpx;
|
||||
margin-top: -8rpx;
|
||||
margin-bottom: $sp-sm;
|
||||
}
|
||||
|
||||
.msg-status-text {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.msg-status-fail .msg-status-text {
|
||||
color: $coral;
|
||||
}
|
||||
|
||||
/* 正在输入动画 */
|
||||
.msg-typing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: $sp-md $sp-lg;
|
||||
}
|
||||
|
||||
.msg-typing-dot {
|
||||
font-size: $fs-xl;
|
||||
color: $text-tertiary;
|
||||
animation: typingBlink 1.4s infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typingBlink {
|
||||
0%, 60%, 100% { opacity: 0.3; }
|
||||
30% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 底部输入栏 */
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-md;
|
||||
padding: $sp-md $sp-lg;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-md});
|
||||
background: $bg-base;
|
||||
border-top: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.chat-input-wrap {
|
||||
flex: 1;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-full;
|
||||
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
padding: 0 $sp-lg;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
font-size: $fs-base;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.chat-input-ph {
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: $bg-card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all $duration-fast $ease-out;
|
||||
}
|
||||
|
||||
.chat-send-active {
|
||||
background: linear-gradient(135deg, $amber, $amber-deep);
|
||||
box-shadow: $shadow-amber;
|
||||
}
|
||||
|
||||
.chat-send-icon {
|
||||
font-size: 32rpx;
|
||||
color: $text-tertiary;
|
||||
|
||||
.chat-send-active & {
|
||||
color: $text-on-amber;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+80
-6
@@ -2,7 +2,15 @@
|
||||
<view :class="themeClass" class="page-container circle-page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="circle-header" :style="{ paddingTop: headerPaddingTop }">
|
||||
<text class="circle-title">酒友圈</text>
|
||||
<view class="circle-title-row" :style="{ paddingRight: headerPaddingRight }">
|
||||
<text class="circle-title">酒友圈</text>
|
||||
<view class="msg-entry" @click="goChatList">
|
||||
<text class="msg-entry-icon">💬</text>
|
||||
<view v-if="unreadTotal > 0" class="msg-badge">
|
||||
<text class="msg-badge-text">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- Tab 切换 -->
|
||||
<view class="circle-tabs">
|
||||
<view
|
||||
@@ -67,7 +75,7 @@
|
||||
v-for="f in friends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
@tap="goFriends"
|
||||
@tap="goChat(f)"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!friends.length"
|
||||
@@ -112,7 +120,7 @@ import FeedCard from '../../components/FeedCard.vue'
|
||||
import FriendItem from '../../components/FriendItem.vue'
|
||||
import EventCard from '../../components/EventCard.vue'
|
||||
import EmptyState from '../../components/EmptyState.vue'
|
||||
import { GetCircleFeeds, LikeFeed, UnlikeFeed, GetFriends, GetFriendRequests, GetEvents } from '../../common/api'
|
||||
import { GetCircleFeeds, LikeFeed, UnlikeFeed, GetFriends, GetFriendRequests, GetEvents, GetUnreadCount } from '../../common/api'
|
||||
import themeMixin from '../../common/theme-mixin'
|
||||
|
||||
export default {
|
||||
@@ -120,10 +128,12 @@ export default {
|
||||
components: { FeedCard, FriendItem, EventCard, EmptyState },
|
||||
data() {
|
||||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
return {
|
||||
tabs: ['动态', '酒友', '酒局'],
|
||||
currentTab: 0,
|
||||
headerPaddingTop: (menuBtn.top + 8) + 'px',
|
||||
headerPaddingRight: (sysInfo.windowWidth - menuBtn.left + 8) + 'px',
|
||||
// 动态
|
||||
feeds: [],
|
||||
page: 1,
|
||||
@@ -134,13 +144,16 @@ export default {
|
||||
friends: [],
|
||||
friendRequests: [],
|
||||
// 酒局
|
||||
events: []
|
||||
events: [],
|
||||
// 私信未读
|
||||
unreadTotal: 0
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadFeeds(true)
|
||||
this.loadFriends()
|
||||
this.loadEvents()
|
||||
this.loadUnread()
|
||||
},
|
||||
methods: {
|
||||
switchTab(i) {
|
||||
@@ -212,6 +225,22 @@ export default {
|
||||
goFriends() {
|
||||
uni.navigateTo({ url: '/pages/friends/friends' })
|
||||
},
|
||||
goChat(friend) {
|
||||
// 根据 friendId 查找对应会话 (f001 -> conv_001)
|
||||
const convId = 'conv_' + friend.id.replace('f', '')
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/chat?friendId=${friend.id}&nickname=${encodeURIComponent(friend.nickname)}&conversationId=${convId}`
|
||||
})
|
||||
},
|
||||
goChatList() {
|
||||
uni.navigateTo({ url: '/pages/chat-list/chat-list' })
|
||||
},
|
||||
async loadUnread() {
|
||||
try {
|
||||
const res = await GetUnreadCount()
|
||||
this.unreadTotal = res.data.total
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
// === 酒局 ===
|
||||
async loadEvents() {
|
||||
try {
|
||||
@@ -256,12 +285,57 @@ export default {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.circle-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $sp-lg;
|
||||
}
|
||||
|
||||
.circle-title {
|
||||
display: block;
|
||||
font-size: $fs-2xl;
|
||||
font-weight: $fw-black;
|
||||
color: $text-primary;
|
||||
margin-bottom: $sp-lg;
|
||||
}
|
||||
|
||||
.msg-entry {
|
||||
position: relative;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $bg-card;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-entry-icon {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.msg-badge {
|
||||
position: absolute;
|
||||
top: -4rpx;
|
||||
right: -4rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 16rpx;
|
||||
background: $coral;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.msg-badge-text {
|
||||
font-size: 18rpx;
|
||||
color: #fff;
|
||||
font-weight: $fw-bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.circle-tabs {
|
||||
|
||||
@@ -45,13 +45,31 @@
|
||||
<!-- 地点 -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">地点</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="form.location"
|
||||
placeholder="输入聚会地点"
|
||||
placeholder-class="form-placeholder"
|
||||
:maxlength="50"
|
||||
/>
|
||||
<!-- 已选地点:显示地图预览 -->
|
||||
<view v-if="form.latitude" class="location-preview">
|
||||
<map
|
||||
class="location-map"
|
||||
:latitude="form.latitude"
|
||||
:longitude="form.longitude"
|
||||
:markers="locationMarkers"
|
||||
:scale="15"
|
||||
:show-location="false"
|
||||
@click="chooseLocation"
|
||||
></map>
|
||||
<view class="location-info">
|
||||
<text class="location-name">{{ form.location }}</text>
|
||||
<text class="location-address">{{ form.address || '' }}</text>
|
||||
</view>
|
||||
<view class="location-change" @click="chooseLocation">
|
||||
<text class="location-change-text">重新选择</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 未选地点:点击选择 -->
|
||||
<view v-else class="location-picker" @click="chooseLocation">
|
||||
<text class="location-picker-icon">📍</text>
|
||||
<text class="location-picker-text">点击选择地点</text>
|
||||
<text class="location-picker-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 人数 -->
|
||||
@@ -104,6 +122,9 @@ export default {
|
||||
date: '',
|
||||
time: '',
|
||||
location: '',
|
||||
address: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
maxPeople: 6,
|
||||
note: ''
|
||||
},
|
||||
@@ -113,6 +134,17 @@ export default {
|
||||
computed: {
|
||||
canSubmit() {
|
||||
return this.form.title.trim() && this.form.date && this.form.time && this.form.location.trim() && !this.submitting
|
||||
},
|
||||
locationMarkers() {
|
||||
if (!this.form.latitude) return []
|
||||
return [{
|
||||
id: 1,
|
||||
latitude: this.form.latitude,
|
||||
longitude: this.form.longitude,
|
||||
title: this.form.location,
|
||||
width: 28,
|
||||
height: 38
|
||||
}]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -125,6 +157,19 @@ export default {
|
||||
onTimeChange(e) {
|
||||
this.form.time = e.detail.value
|
||||
},
|
||||
chooseLocation() {
|
||||
uni.chooseLocation({
|
||||
success: (res) => {
|
||||
this.form.location = res.name || res.address || ''
|
||||
this.form.address = res.address || ''
|
||||
this.form.latitude = res.latitude
|
||||
this.form.longitude = res.longitude
|
||||
},
|
||||
fail: () => {
|
||||
// 用户取消或无权限,不做处理
|
||||
}
|
||||
})
|
||||
},
|
||||
changePeople(delta) {
|
||||
const val = this.form.maxPeople + delta
|
||||
if (val >= 2 && val <= 50) {
|
||||
@@ -139,6 +184,9 @@ export default {
|
||||
title: this.form.title,
|
||||
time: `${this.form.date} ${this.form.time}`,
|
||||
location: this.form.location,
|
||||
address: this.form.address,
|
||||
latitude: this.form.latitude,
|
||||
longitude: this.form.longitude,
|
||||
maxPeople: this.form.maxPeople,
|
||||
note: this.form.note
|
||||
})
|
||||
@@ -279,6 +327,86 @@ export default {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 地点选择器 */
|
||||
.location-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-md;
|
||||
height: 96rpx;
|
||||
padding: 0 $sp-lg;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
border: 2rpx dashed var(--border-dashed, rgba(255,255,255,0.12));
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.location-picker-icon {
|
||||
font-size: $fs-lg;
|
||||
}
|
||||
|
||||
.location-picker-text {
|
||||
flex: 1;
|
||||
font-size: $fs-base;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.location-picker-arrow {
|
||||
font-size: $fs-xl;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
/* 地图预览 */
|
||||
.location-preview {
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.location-map {
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
padding: $sp-md $sp-lg;
|
||||
background: $bg-card;
|
||||
}
|
||||
|
||||
.location-name {
|
||||
font-size: $fs-base;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.location-address {
|
||||
font-size: $fs-xs;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.location-change {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $sp-sm;
|
||||
background: $bg-card-alt;
|
||||
|
||||
&:active {
|
||||
background: $bg-elevated;
|
||||
}
|
||||
}
|
||||
|
||||
.location-change-text {
|
||||
font-size: $fs-sm;
|
||||
color: $amber;
|
||||
font-weight: $fw-medium;
|
||||
}
|
||||
|
||||
/* 人数步进器 */
|
||||
.form-stepper {
|
||||
display: flex;
|
||||
|
||||
@@ -53,6 +53,22 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 地图展示 -->
|
||||
<view class="evt-map-section" v-if="event.latitude">
|
||||
<map
|
||||
class="evt-map"
|
||||
:latitude="event.latitude"
|
||||
:longitude="event.longitude"
|
||||
:markers="eventMarkers"
|
||||
:scale="15"
|
||||
:show-location="true"
|
||||
></map>
|
||||
<view class="evt-map-nav-btn" @click="openNavigation">
|
||||
<text class="evt-map-nav-icon">🧭</text>
|
||||
<text class="evt-map-nav-text">导航到这里</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发起人 -->
|
||||
<view class="evt-organizer">
|
||||
<text class="evt-section-label">发起人</text>
|
||||
@@ -131,6 +147,17 @@ export default {
|
||||
if (!this.event) return ''
|
||||
const map = { open: '报名中', ongoing: '进行中', ended: '已结束' }
|
||||
return map[this.event.status] || '报名中'
|
||||
},
|
||||
eventMarkers() {
|
||||
if (!this.event || !this.event.latitude) return []
|
||||
return [{
|
||||
id: 1,
|
||||
latitude: this.event.latitude,
|
||||
longitude: this.event.longitude,
|
||||
title: this.event.location,
|
||||
width: 28,
|
||||
height: 38
|
||||
}]
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
@@ -186,6 +213,18 @@ export default {
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '签到失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
openNavigation() {
|
||||
if (!this.event || !this.event.latitude) return
|
||||
uni.openLocation({
|
||||
latitude: this.event.latitude,
|
||||
longitude: this.event.longitude,
|
||||
name: this.event.location,
|
||||
address: this.event.address || this.event.location,
|
||||
fail: () => {
|
||||
uni.showToast({ title: '无法打开地图', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +363,42 @@ export default {
|
||||
font-weight: $fw-medium;
|
||||
}
|
||||
|
||||
/* 地图区域 */
|
||||
.evt-map-section {
|
||||
margin-bottom: $sp-xl;
|
||||
border-radius: $radius-xl;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.evt-map {
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
}
|
||||
|
||||
.evt-map-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $sp-sm;
|
||||
padding: $sp-md;
|
||||
background: $bg-card;
|
||||
|
||||
&:active {
|
||||
background: $bg-card-alt;
|
||||
}
|
||||
}
|
||||
|
||||
.evt-map-nav-icon {
|
||||
font-size: $fs-base;
|
||||
}
|
||||
|
||||
.evt-map-nav-text {
|
||||
font-size: $fs-base;
|
||||
color: $amber;
|
||||
font-weight: $fw-bold;
|
||||
}
|
||||
|
||||
/* 发起人 & 参与者 */
|
||||
.evt-section-label {
|
||||
display: block;
|
||||
|
||||
+69
-15
@@ -166,10 +166,18 @@
|
||||
<view class="dice-cup" :class="{ 'cup-shaking': diceShaking }" @click="diceRoll">
|
||||
<view class="dice-pair">
|
||||
<view class="die" :class="{ 'die-rolling': diceShaking }">
|
||||
<text class="die-face">{{ diceShaking ? '?' : diceFaces[diceA] }}</text>
|
||||
<view class="die-grid">
|
||||
<view class="die-cell" v-for="p in 9" :key="p">
|
||||
<view class="die-pip" v-if="dicePips(diceA).indexOf(p) > -1"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="die die-2" :class="{ 'die-rolling': diceShaking }">
|
||||
<text class="die-face">{{ diceShaking ? '?' : diceFaces[diceB] }}</text>
|
||||
<view class="die-grid">
|
||||
<view class="die-cell" v-for="p in 9" :key="p">
|
||||
<view class="die-pip" v-if="dicePips(diceB).indexOf(p) > -1"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="dice-sum text-num" v-if="!diceShaking && diceRound > 0">{{ diceA + diceB }}</text>
|
||||
@@ -213,7 +221,9 @@
|
||||
:key="i"
|
||||
:class="{ 'slot-item-active': !wheelSpinning && wheelResult && i === slotTarget }"
|
||||
>
|
||||
<text class="slot-item-emoji">{{ s.emoji }}</text>
|
||||
<view class="slot-item-emoji-box">
|
||||
<text class="slot-item-emoji">{{ s.emoji }}</text>
|
||||
</view>
|
||||
<text class="slot-item-label">{{ s.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -299,7 +309,6 @@ export default {
|
||||
rpsLastPick: '',
|
||||
rpsTimer: null,
|
||||
// --- 骰子 ---
|
||||
diceFaces: ['', '⚀', '⚁', '⚂', '⚃', '⚄', '⚅'],
|
||||
diceA: 1,
|
||||
diceB: 1,
|
||||
diceShaking: false,
|
||||
@@ -452,6 +461,18 @@ export default {
|
||||
},
|
||||
|
||||
/* ========== 骰子大话 ========== */
|
||||
// 骰子点数对应的 pip 位置(3x3 九宫格,1~9 从左到右、从上到下)
|
||||
dicePips(val) {
|
||||
const map = {
|
||||
1: [5],
|
||||
2: [1, 9],
|
||||
3: [1, 5, 9],
|
||||
4: [1, 3, 7, 9],
|
||||
5: [1, 3, 5, 7, 9],
|
||||
6: [1, 3, 4, 6, 7, 9]
|
||||
}
|
||||
return map[val] || []
|
||||
},
|
||||
diceRoll() {
|
||||
if (this.diceShaking) return
|
||||
this.diceShaking = true
|
||||
@@ -716,14 +737,14 @@ export default {
|
||||
|
||||
.rule-title {
|
||||
display: block;
|
||||
font-size: $fs-sm;
|
||||
font-size: 30rpx;
|
||||
font-weight: $fw-bold;
|
||||
color: $text-secondary;
|
||||
margin-bottom: $sp-sm;
|
||||
}
|
||||
|
||||
.rule-text {
|
||||
font-size: $fs-xs;
|
||||
font-size: 30rpx;
|
||||
color: $text-tertiary;
|
||||
line-height: $lh-loose;
|
||||
}
|
||||
@@ -784,7 +805,7 @@ export default {
|
||||
background: linear-gradient(135deg, var(--amber, #E8A838), var(--amber-deep, #C47F17));
|
||||
box-shadow: var(--shadow-amber, 0 8rpx 32rpx rgba(232,168,56,0.25));
|
||||
transition: transform $duration-fast $ease-out, opacity $duration-fast;
|
||||
text { color: var(--text-on-amber, #0B0B14); font-size: $fs-base; font-weight: $fw-bold; }
|
||||
text { color: var(--text-on-amber, #0B0B14); font-size:40rpx; font-weight: $fw-bold; }
|
||||
&:active { transform: scale(0.94); }
|
||||
&.btn-disabled { opacity: 0.5; pointer-events: none; }
|
||||
}
|
||||
@@ -1069,11 +1090,12 @@ export default {
|
||||
}
|
||||
|
||||
.die {
|
||||
width: 120rpx; height: 120rpx;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 132rpx; height: 132rpx;
|
||||
display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
border-radius: $radius-md;
|
||||
background: $bg-elevated;
|
||||
border: 2rpx solid var(--border-subtle, rgba(255,255,255,0.10));
|
||||
background: linear-gradient(160deg, #FBF7EF, #EAE2D3);
|
||||
box-shadow: inset 0 4rpx 8rpx rgba(255,255,255,0.7), inset 0 -6rpx 12rpx rgba(0,0,0,0.12), $shadow-sm;
|
||||
transition: transform $duration-base $ease-bounce;
|
||||
|
||||
&.die-rolling { animation: die-spin 0.3s linear infinite; }
|
||||
@@ -1085,7 +1107,28 @@ export default {
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
.die-face { font-size: 72rpx; color: $text-primary; }
|
||||
.die-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.die-cell {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.die-pip {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
border-radius: 50%;
|
||||
background: #241B10;
|
||||
box-shadow: inset 0 2rpx 3rpx rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.dice-sum {
|
||||
font-size: $fs-2xl;
|
||||
@@ -1199,6 +1242,7 @@ export default {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@@ -1206,7 +1250,7 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $sp-md;
|
||||
gap: $sp-sm;
|
||||
height: 120rpx;
|
||||
border-bottom: 1rpx solid var(--border-micro, rgba(255,255,255,0.05));
|
||||
transition: background $duration-base;
|
||||
@@ -1216,8 +1260,18 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.slot-item-emoji { font-size: 44rpx; }
|
||||
.slot-item-label { font-size: $fs-base; font-weight: $fw-bold; color: $text-primary; }
|
||||
.slot-item-emoji-box {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slot-item-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.slot-item-label { font-size: $fs-base; font-weight: $fw-bold; color: $text-primary; letter-spacing: 2rpx; }
|
||||
|
||||
.slot-mask-top, .slot-mask-bottom {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user