This commit is contained in:
2025-12-11 15:50:34 +08:00
parent 1cca159427
commit 2479772d8b
4 changed files with 306 additions and 34 deletions

1
components.d.ts vendored
View File

@@ -16,6 +16,7 @@ declare module 'vue' {
AppNavBar: typeof import('./src/components/common/AppNavBar.vue')['default']
AppTabBar: typeof import('./src/components/common/AppTabBar.vue')['default']
CallWindow: typeof import('./src/components/call/CallWindow.vue')['default']
EmojiPicker: typeof import('./src/components/chat/EmojiPicker.vue')['default']
GlobalCallProvider: typeof import('./src/components/call/GlobalCallProvider.vue')['default']
GroupCallBanner: typeof import('./src/components/call/GroupCallBanner.vue')['default']
GroupCallIncoming: typeof import('./src/components/call/GroupCallIncoming.vue')['default']

View File

@@ -0,0 +1,247 @@
<template>
<view v-if="show" class="emoji-picker" :class="{ dark: isDark }">
<!-- 分类标签 -->
<view class="emoji-tabs">
<view
v-for="(cat, idx) in categories"
:key="cat.key"
class="tab-item"
:class="{ active: activeTab === idx }"
@click="activeTab = idx"
>
<text>{{ cat.icon }}</text>
</view>
</view>
<!-- 表情列表 -->
<scroll-view scroll-y class="emoji-grid-container">
<view class="emoji-grid">
<view
v-for="emoji in currentEmojis"
:key="emoji"
class="emoji-item"
@click="selectEmoji(emoji)"
>
{{ emoji }}
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: 'select', emoji: string): void
(e: 'close'): void
}>()
const { isDark } = useTheme()
const activeTab = ref(0)
// 表情分类
const categories = [
{
key: 'face',
icon: '😀',
emojis: [
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
'😘', '😗', '☺️', '😚', '😙', '🥲', '😋', '😛',
'😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔',
'🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄',
'😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
'🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴',
'😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓', '🧐'
]
},
{
key: 'gesture',
icon: '👋',
emojis: [
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏',
'✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
'🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛',
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
'💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃'
]
},
{
key: 'heart',
icon: '❤️',
emojis: [
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
'🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖',
'💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️',
'✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈'
]
},
{
key: 'animal',
icon: '🐶',
emojis: [
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
'🐻‍❄️', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵',
'🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤',
'🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗'
]
},
{
key: 'food',
icon: '🍔',
emojis: [
'🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓',
'🫐', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝',
'🍔', '🍟', '🍕', '🌭', '🥪', '🌮', '🌯', '🥗',
'🍜', '🍝', '🍲', '🍱', '🍣', '🍤', '🍿', '🧁'
]
},
{
key: 'activity',
icon: '⚽',
emojis: [
'⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉',
'🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍',
'🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿',
'🥊', '🥋', '🎽', '🛹', '🛼', '🛷', '⛸️', '🥌'
]
},
{
key: 'object',
icon: '💡',
emojis: [
'⌚', '📱', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️',
'💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹',
'🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺',
'📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰'
]
},
{
key: 'symbol',
icon: '🔥',
emojis: [
'🔥', '💥', '✨', '🌟', '⭐', '🌈', '☀️', '🌤️',
'⛅', '🌥️', '☁️', '🌦️', '🌧️', '⛈️', '🌩️', '🌨️',
'❄️', '☃️', '⛄', '🌬️', '💨', '💧', '💦', '☔',
'🎵', '🎶', '🎼', '🎹', '🥁', '🎷', '🎺', '🎸'
]
}
]
const currentEmojis = computed(() => categories[activeTab.value]?.emojis || [])
function selectEmoji(emoji: string) {
emit('select', emoji)
}
</script>
<style lang="scss" scoped>
.emoji-picker {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: var(--bg-card, #fff);
border-radius: 24rpx 24rpx 0 0;
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.1);
z-index: 100;
animation: slideUp 0.25s ease-out;
&.dark {
background: #292524;
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.3);
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.emoji-tabs {
display: flex;
padding: 16rpx 24rpx;
gap: 8rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
overflow-x: auto;
.dark & {
border-bottom-color: #44403c;
}
&::-webkit-scrollbar {
display: none;
}
}
.tab-item {
min-width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16rpx;
font-size: 40rpx;
transition: all 0.2s;
&:active {
transform: scale(0.95);
}
&.active {
background: rgba(79, 70, 229, 0.1);
.dark & {
background: rgba(249, 115, 22, 0.15);
}
}
}
.emoji-grid-container {
height: 400rpx;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8rpx;
padding: 16rpx 24rpx;
}
.emoji-item {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
border-radius: 16rpx;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&:active {
transform: scale(0.9);
background: rgba(0, 0, 0, 0.1);
}
.dark &:hover {
background: rgba(255, 255, 255, 0.1);
}
}
</style>

View File

@@ -197,6 +197,13 @@
@close="showMentionPicker = false"
/>
<!-- 表情选择器 -->
<emoji-picker
:show="showEmoji"
@select="handleEmojiSelect"
@close="showEmoji = false"
/>
<view class="input-bar-capsule">
<!-- 左侧: 语音按钮 -->
<view
@@ -261,7 +268,7 @@
</view>
<!-- 更多功能面板 -->
<wd-popup v-model="showMore" position="bottom" safe-area-inset-bottom custom-style="background: var(--bg-card); border-radius: 32rpx 32rpx 0 0;">
<wd-popup v-model="showMore" position="bottom" safe-area-inset-bottom :z-index="10000" custom-style="background: var(--bg-card); border-radius: 32rpx 32rpx 0 0;">
<view class="tools-grid">
<view class="tool-item" @click="chooseImage">
<view class="tool-icon album">
@@ -358,6 +365,7 @@ import { useTheme } from '@/composables/useTheme'
import { useWebRTC } from '@/composables/useWebRTC'
import AppAvatar from '@/components/common/AppAvatar.vue'
import MentionPicker from '@/components/chat/MentionPicker.vue'
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
import type { MentionUser } from '@/components/chat/MentionPicker.vue'
import * as roomApi from '@/api/modules/room'
import type { ChatMessage, Attachment, GroupMember } from '@/types/api'
@@ -420,8 +428,24 @@ function getMediaTypeClass(type: number) { if (type === 1) return 'media-bubble
function getMediaUrl(msg: ChatMessage): string { let url = ''; if (typeof msg.extra === 'string') { try { const extra = JSON.parse(msg.extra); url = extra.url || msg.content } catch { url = msg.content } } else { url = msg.extra?.url || msg.content } return resolveImageUrl(url) }
function getFileName(msg: ChatMessage): string { if (typeof msg.extra === 'string') { try { const extra = JSON.parse(msg.extra); return extra.name || '未知文件' } catch { return '未知文件' } } return msg.extra?.name || '未知文件' }
function getFileSize(msg: ChatMessage): number { if (typeof msg.extra === 'string') { try { const extra = JSON.parse(msg.extra); return extra.size || 0 } catch { return 0 } } return msg.extra?.size || 0 }
function getMessageSenderAvatar(msg: ChatMessage): string { return targetUser.value?.avatar || '' }
function getMessageSenderName(msg: ChatMessage): string { return targetUser.value?.name || chatName.value || '未知' }
function getMessageSenderAvatar(msg: ChatMessage): string {
// 群聊时从群成员列表获取头像
if (isGroupChat.value && msg.sender_user_id) {
const member = groupMembers.value.find(m => m.user_id === msg.sender_user_id)
if (member?.user?.avatar) return member.user.avatar
}
// 单聊时使用对方头像
return targetUser.value?.avatar || ''
}
function getMessageSenderName(msg: ChatMessage): string {
// 群聊时从群成员列表获取名称
if (isGroupChat.value && msg.sender_user_id) {
const member = groupMembers.value.find(m => m.user_id === msg.sender_user_id)
if (member) return member.nickname || member.user?.name || '群成员'
}
// 单聊时使用对方名称
return targetUser.value?.name || chatName.value || '未知'
}
function onAvatarClick(msg: ChatMessage) { const userId = msg.sender_user_id; if (!userId) return; uni.navigateTo({ url: `/pages/contact/detail?userId=${userId}` }) }
function handleMessageLongPress(msg: ChatMessage) { selectedMessage.value = msg; showMsgActions.value = true }
async function onMsgActionSelect(action: { value: string }) { if (!selectedMessage.value) return; const msg = selectedMessage.value; showMsgActions.value = false; switch (action.value) { case 'copy': copyMessage(msg); break; case 'recall': await recallMessage(msg); break; case 'delete': deleteMessage(msg); break } }
@@ -431,6 +455,7 @@ function deleteMessage(msg: ChatMessage) { const index = messages.value.findInde
function onInputChange(e: any) { const value = e.detail?.value || inputText.value; if (value.endsWith('@') && isGroupChat.value) { showMentionPicker.value = true } }
function triggerMention() { inputText.value += '@'; showMentionPicker.value = true }
function handleMentionSelect(user: MentionUser) { if (inputText.value.endsWith('@')) { inputText.value = inputText.value.slice(0, -1) + `@${user.name} ` } else { inputText.value += `@${user.name} ` } showMentionPicker.value = false }
function handleEmojiSelect(emoji: string) { inputText.value += emoji }
function previewImage(msg: ChatMessage) { const url = getMediaUrl(msg); if (url) { uni.previewImage({ urls: [url], current: url }) } }
// ========== 语音录制处理 ==========
@@ -624,20 +649,20 @@ async function sendVoiceMessage(filePath: string, duration: number) { toast.load
.end-text { color: var(--text-tertiary); font-size: 24rpx; }
}
// 消息项容器
// 消息项容器 - 与设计稿一致
.message-item {
margin-bottom: 40rpx;
margin-bottom: 48rpx;
.timestamp {
display: flex;
justify-content: center;
margin-bottom: 30rpx;
margin-bottom: 32rpx;
text {
background: rgba(0, 0, 0, 0.04);
padding: 6rpx 18rpx;
padding: 8rpx 20rpx;
border-radius: 20rpx;
font-size: 22rpx;
font-size: 24rpx;
color: var(--text-tertiary);
.theme-dark & { background: rgba(255, 255, 255, 0.08); }
@@ -646,44 +671,44 @@ async function sendVoiceMessage(filePath: string, duration: number) { toast.load
.system-notice {
text-align: center;
margin: 20rpx 0;
margin: 24rpx 0;
text { font-size: 24rpx; color: var(--text-tertiary); }
}
}
// 核心布局
// 核心布局 - 与设计稿一致
.bubble-group {
display: flex;
align-items: flex-start;
gap: 16rpx;
gap: 24rpx;
&.is-me {
justify-content: flex-end;
flex-direction: row-reverse;
}
}
// 头像容器
// 头像容器 - 与设计稿一致40px=80rpx
.avatar-box {
width: 80rpx;
height: 80rpx;
flex-shrink: 0;
&.left { order: 1; }
&.right { order: 3; }
:deep(.app-avatar) {
border-radius: 24rpx;
}
}
// 内容容器
// 内容容器 - 与设计稿一致
.content-box {
order: 2;
max-width: 75%;
display: flex;
flex-direction: column;
.sender-label {
font-size: 22rpx;
font-size: 20rpx;
color: var(--text-tertiary);
margin-bottom: 8rpx;
margin-left: 12rpx;
margin-left: 4rpx;
}
.is-me & {
@@ -698,16 +723,17 @@ async function sendVoiceMessage(filePath: string, duration: number) { toast.load
padding: 24rpx 28rpx;
border-radius: 32rpx;
font-size: 30rpx;
line-height: 1.6;
line-height: 1.5;
position: relative;
word-break: break-all;
max-width: 100%;
// 对方气泡 - 白色背景
&.bubble-other {
background: var(--bubble-other-bg);
color: var(--text-msg-other);
border-bottom-left-radius: 8rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
.theme-dark & {
border: 1rpx solid var(--bubble-other-border);
@@ -732,7 +758,7 @@ async function sendVoiceMessage(filePath: string, duration: number) { toast.load
padding: 24rpx 28rpx;
background: var(--bubble-other-bg) !important;
border-radius: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05) !important;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.05) !important;
.theme-dark & {
border: 1rpx solid var(--bubble-other-border) !important;
@@ -992,7 +1018,7 @@ async function sendVoiceMessage(filePath: string, duration: number) { toast.load
bottom: 0;
left: 0;
right: 0;
z-index: 99;
z-index: 200;
background: var(--bg-card);
border-top: 1rpx solid var(--border-subtle);
padding: 24rpx 32rpx;
@@ -1039,7 +1065,7 @@ async function sendVoiceMessage(filePath: string, duration: number) { toast.load
flex: 1;
min-height: 88rpx;
background: var(--input-bg);
border-radius: 40rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
padding: 20rpx 28rpx;
@@ -1052,9 +1078,9 @@ async function sendVoiceMessage(filePath: string, duration: number) { toast.load
.text-input {
width: 100%;
min-height: 48rpx;
min-height: 40rpx;
max-height: 200rpx;
font-size: 30rpx;
font-size: 28rpx;
line-height: 1.5;
color: var(--text-main);
background: transparent;

View File

@@ -87,15 +87,12 @@
>
<!-- 头像 -->
<view class="avatar-wrap">
<image
v-if="item.avatar"
class="conv-avatar"
:src="resolveImageUrl(item.avatar)"
mode="aspectFill"
<app-avatar
:src="item.avatar"
:name="item.name"
:size="100"
radius="20rpx"
/>
<view v-else class="conv-avatar avatar-placeholder">
{{ (item.name || '?').charAt(0) }}
</view>
<!-- 在线状态点 (单聊时显示) -->
<view v-if="item.is_online && !item.is_group" class="online-indicator"></view>
<!-- 未读红点/数字 -->
@@ -185,6 +182,7 @@ import * as conversationApi from '@/api/modules/conversation'
import AppTabBar from '@/components/common/AppTabBar.vue'
import AppDrawer from '@/components/common/AppDrawer.vue'
import PlusMenu from '@/components/common/PlusMenu.vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
import type { Conversation } from '@/types/conversation'
const authStore = useAuthStore()