朋友圈

This commit is contained in:
2025-12-08 11:31:57 +08:00
parent a9dfc56342
commit bc2d4dd466
11 changed files with 1179 additions and 31 deletions

View File

@@ -44,6 +44,15 @@
title="发送文件"
></i>
<!-- @功能按钮仅群聊显示 -->
<i
v-if="isGroupChat && mentionUsers.length > 0"
class="fas fa-at hover:text-blue-400 transition-all transform hover:scale-125"
:class="isBlocked ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 cursor-pointer'"
@click="!isBlocked && showMentionPickerManual()"
title="@群成员"
></i>
<!-- 长按录音 -->
<div
class="relative flex items-center group select-none ml-auto mr-2"
@@ -70,14 +79,15 @@
</div>
</div>
<div class="bg-input rounded-3xl p-2 pl-4 flex items-end ring-1 ring-white/5 focus-within:ring-primary/50 transition-all shadow-inner hover:shadow-lg hover:shadow-black/20">
<div class="bg-input rounded-3xl p-2 pl-4 flex items-end ring-1 ring-white/5 focus-within:ring-primary/50 transition-all shadow-inner hover:shadow-lg hover:shadow-black/20 relative">
<textarea
ref="textareaRef"
:value="modelValue"
:disabled="isBlocked"
class="w-full bg-transparent border-none text-white resize-none h-10 max-h-32 py-2 px-1 text-sm focus:outline-none scrollbar-thin placeholder-gray-500 leading-6 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="输入消息 (Enter 发送)..."
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="!isBlocked && $emit('send')"
@input="handleInput"
@keydown="handleKeydown"
@paste="handlePaste"
></textarea>
<div
@@ -85,10 +95,31 @@
:class="isBlocked
? 'bg-gray-700 text-gray-500 scale-90 rotate-90 opacity-50 cursor-not-allowed'
: (modelValue.trim() ? 'bg-primary hover:bg-indigo-500 text-white scale-100 rotate-0 cursor-pointer' : 'bg-gray-700 text-gray-500 scale-90 rotate-90 opacity-50 cursor-not-allowed')"
@click="!isBlocked && $emit('send')"
@click="handleSend"
>
<i class="fas fa-paper-plane text-sm transform -ml-0.5 mt-0.5"></i>
</div>
<!-- @选择器 -->
<MentionPicker
:show="showMentionPicker"
:users="mentionUsers"
:position="mentionPickerPosition"
@select="handleMentionSelect"
@close="showMentionPicker = false"
/>
</div>
<!-- @用户显示 -->
<div v-if="mentionedUsers.length > 0" class="mt-2 flex flex-wrap gap-1 px-2">
<span
v-for="user in mentionedUsers"
:key="user.id"
class="inline-flex items-center gap-1 px-2 py-0.5 bg-primary/20 text-primary text-xs rounded-full"
>
@{{ user.name }}
<i class="fas fa-times cursor-pointer hover:text-white" @click="removeMentionedUser(user.id)"></i>
</span>
</div>
<input
@@ -101,8 +132,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useToastStore } from '@/stores/toast'
import MentionPicker from '@/components/common/MentionPicker.vue'
import type { MentionUser } from '@/components/common/MentionPicker.vue'
const toastStore = useToastStore()
@@ -110,13 +143,15 @@ interface Props {
modelValue: string
isRecording: boolean
isBlocked?: boolean // 是否被禁言(被移除或退出群聊)
isGroupChat?: boolean // 是否是群聊
roomMembers?: Record<string, { name: string; avatar?: string }> // 群成员映射
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
send: []
send: [mentionUserIds?: string[]]
file: [type: number, file: File]
'record-start': []
'record-stop': [blob: Blob, duration: number]
@@ -124,13 +159,162 @@ const emit = defineEmits<{
}>()
const fileInput = ref<HTMLInputElement>()
const textareaRef = ref<HTMLTextAreaElement>()
let mediaRecorder: MediaRecorder | null = null
let recordStartTime = 0
// @功能相关状态
const showMentionPicker = ref(false)
const mentionPickerPosition = ref({ x: 0, y: 60 })
const mentionedUsers = ref<MentionUser[]>([])
const atStartIndex = ref(-1) // 记录@符号的位置
// 将 roomMembers 转换为 MentionUser 数组
const mentionUsers = computed<MentionUser[]>(() => {
if (!props.roomMembers) return []
return Object.entries(props.roomMembers).map(([id, info]) => ({
id,
name: info.name,
avatar: info.avatar
}))
})
function insertEmoji(emoji: string) {
emit('update:modelValue', props.modelValue + emoji)
}
// 处理输入事件
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
const value = target.value
emit('update:modelValue', value)
// 检测@符号(仅群聊时)
if (props.isGroupChat && mentionUsers.value.length > 0) {
const cursorPos = target.selectionStart || 0
const textBeforeCursor = value.substring(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
// 检查@后面是否还没有空格(正在输入中)
if (lastAtIndex !== -1) {
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1)
// 如果@后面没有空格,说明正在输入@用户名
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
atStartIndex.value = lastAtIndex
showMentionPicker.value = true
return
}
}
}
showMentionPicker.value = false
}
// 处理键盘事件
function handleKeydown(e: KeyboardEvent) {
// 如果@选择器显示,阻止 Enter 发送消息
if (showMentionPicker.value) {
if (e.key === 'Escape') {
showMentionPicker.value = false
e.preventDefault()
}
// 让 MentionPicker 处理上下键和 Enter
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
return
}
}
// Enter 发送消息
if (e.key === 'Enter' && !e.shiftKey && !showMentionPicker.value) {
e.preventDefault()
if (!props.isBlocked) {
handleSend()
}
}
}
// 处理发送
function handleSend() {
if (props.isBlocked || !props.modelValue.trim()) return
// 提取@的用户ID列表
const mentionUserIds = mentionedUsers.value.map(u => u.id)
emit('send', mentionUserIds.length > 0 ? mentionUserIds : undefined)
// 清空@列表
mentionedUsers.value = []
}
// 手动显示@选择器
function showMentionPickerManual() {
if (!textareaRef.value) return
// 在当前光标位置插入@
const cursorPos = textareaRef.value.selectionStart || props.modelValue.length
const newValue = props.modelValue.substring(0, cursorPos) + '@' + props.modelValue.substring(cursorPos)
emit('update:modelValue', newValue)
atStartIndex.value = cursorPos
showMentionPicker.value = true
// 聚焦输入框
textareaRef.value.focus()
// 设置光标位置到@后面
setTimeout(() => {
if (textareaRef.value) {
textareaRef.value.setSelectionRange(cursorPos + 1, cursorPos + 1)
}
}, 0)
}
// 处理@选择
function handleMentionSelect(user: MentionUser) {
if (!textareaRef.value) return
const value = props.modelValue
const cursorPos = textareaRef.value.selectionStart || value.length
// 找到@符号的位置
const textBeforeCursor = value.substring(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
if (lastAtIndex !== -1) {
// 替换@及其后面的部分文字为 @用户名
const newValue = value.substring(0, lastAtIndex) + `@${user.name} ` + value.substring(cursorPos)
emit('update:modelValue', newValue)
// 添加到已@列表(避免重复)
if (!mentionedUsers.value.find(u => u.id === user.id)) {
mentionedUsers.value.push(user)
}
// 设置光标位置
const newCursorPos = lastAtIndex + user.name.length + 2
setTimeout(() => {
if (textareaRef.value) {
textareaRef.value.focus()
textareaRef.value.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
showMentionPicker.value = false
}
// 移除已@的用户
function removeMentionedUser(userId: string) {
const index = mentionedUsers.value.findIndex(u => u.id === userId)
if (index > -1) {
const user = mentionedUsers.value[index]
mentionedUsers.value.splice(index, 1)
// 同时从文本中移除 @用户名
const pattern = new RegExp(`@${user.name}\\s?`, 'g')
const newValue = props.modelValue.replace(pattern, '')
emit('update:modelValue', newValue)
}
}
function triggerFile(type: number) {
if (!fileInput.value) return
// 类型映射1图片3视频8文件

View File

@@ -53,7 +53,7 @@
size="sm"
rounded="lg"
class="cursor-pointer hover:opacity-80 transition self-start mt-1 shadow-md"
@click="$emit('avatar-click', msg.isSelf ? currentUser : target)"
@click="handleAvatarClick(msg)"
/>
<!-- 消息内容区 -->
@@ -166,6 +166,39 @@ function getMemberColor(userId: string): string | undefined {
return undefined
}
// 处理头像点击
function handleAvatarClick(msg: ChatMessage) {
// 如果是自己的消息,返回当前用户
if (msg.isSelf) {
emit('avatar-click', props.currentUser)
return
}
// 如果是群聊,返回发送者的用户信息
if (isGroupChat.value && msg.sender_user_id) {
const memberInfo = props.roomMembers?.[msg.sender_user_id]
if (memberInfo) {
// 构造一个 User 对象
const senderUser: User = {
id: msg.sender_user_id,
name: memberInfo.name,
avatar: memberInfo.avatar || '',
email: '',
phone: '',
desc: '',
region: '',
created_at: '',
updated_at: '',
}
emit('avatar-click', senderUser)
return
}
}
// 单聊或找不到群成员信息时,返回 target
emit('avatar-click', props.target)
}
// 节流函数
function throttle<T extends (...args: any[]) => any>(func: T, delay: number): T {
let lastCall = 0

View File

@@ -1,33 +1,44 @@
<template>
<img
:src="`http://127.0.0.1:12080${imageUrl}`"
class="max-w-full cursor-zoom-in hover:brightness-90 transition duration-300 block max-h-[300px] object-cover"
loading="lazy"
@click="previewImage"
/>
<div>
<img
:src="fullImageUrl"
class="max-w-full cursor-zoom-in hover:brightness-90 transition duration-300 block max-h-[300px] object-cover rounded-lg"
loading="lazy"
@click="showPreview = true"
/>
<!-- 图片预览 -->
<ImagePreview
:show="showPreview"
:images="[fullImageUrl]"
@close="showPreview = false"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import ImagePreview from '@/components/common/ImagePreview.vue'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
message: ChatMessage
}>()
const showPreview = ref(false)
const imageUrl = computed(() => {
const extra = typeof props.message.extra === 'object' ? props.message.extra : {}
return extra.url || props.message.content
})
function previewImage() {
const w = window.open('')
if (w) {
w.document.write(`
<body style="margin:0;display:flex;align-items:center;justify-content:center;background:#000;height:100vh;">
<img src="http://127.0.0.1:12080${imageUrl.value}" style="max-width:100%;max-height:100vh;box-shadow:0 0 20px rgba(0,0,0,0.5)">
</body>
`)
const fullImageUrl = computed(() => {
const url = imageUrl.value
// 如果已经是完整URL直接返回
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
return url
}
}
// 否则添加服务器前缀
return `http://127.0.0.1:12080${url}`
})
</script>

View File

@@ -0,0 +1,395 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="show"
class="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center"
@click.self="handleClose"
@wheel="handleWheel"
>
<!-- 顶部工具栏 -->
<div class="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-black/80 to-transparent flex items-center justify-between px-6 z-10">
<!-- 左侧图片计数 -->
<div class="text-white/80 text-sm">
<span v-if="images.length > 1">{{ currentIndex + 1 }} / {{ images.length }}</span>
</div>
<!-- 右侧操作按钮 -->
<div class="flex items-center gap-2">
<!-- 缩小 -->
<button
class="w-10 h-10 rounded-full hover:bg-white/10 text-white/80 hover:text-white flex items-center justify-center transition"
@click="zoomOut"
title="缩小 (滚轮下)"
>
<i class="fas fa-search-minus"></i>
</button>
<!-- 缩放比例 -->
<span class="text-white/60 text-sm min-w-[60px] text-center">{{ Math.round(scale * 100) }}%</span>
<!-- 放大 -->
<button
class="w-10 h-10 rounded-full hover:bg-white/10 text-white/80 hover:text-white flex items-center justify-center transition"
@click="zoomIn"
title="放大 (滚轮上)"
>
<i class="fas fa-search-plus"></i>
</button>
<div class="w-px h-6 bg-white/20 mx-2"></div>
<!-- 1:1 原始大小 -->
<button
class="w-10 h-10 rounded-full hover:bg-white/10 text-white/80 hover:text-white flex items-center justify-center transition"
@click="resetScale"
title="原始大小"
>
<i class="fas fa-expand-arrows-alt"></i>
</button>
<!-- 适应屏幕 -->
<button
class="w-10 h-10 rounded-full hover:bg-white/10 text-white/80 hover:text-white flex items-center justify-center transition"
@click="fitToScreen"
title="适应屏幕"
>
<i class="fas fa-compress-arrows-alt"></i>
</button>
<div class="w-px h-6 bg-white/20 mx-2"></div>
<!-- 左旋转 -->
<button
class="w-10 h-10 rounded-full hover:bg-white/10 text-white/80 hover:text-white flex items-center justify-center transition"
@click="rotateLeft"
title="向左旋转"
>
<i class="fas fa-undo"></i>
</button>
<!-- 右旋转 -->
<button
class="w-10 h-10 rounded-full hover:bg-white/10 text-white/80 hover:text-white flex items-center justify-center transition"
@click="rotateRight"
title="向右旋转"
>
<i class="fas fa-redo"></i>
</button>
<div class="w-px h-6 bg-white/20 mx-2"></div>
<!-- 关闭 -->
<button
class="w-10 h-10 rounded-full hover:bg-white/10 text-white/80 hover:text-white flex items-center justify-center transition"
@click="handleClose"
title="关闭 (Esc)"
>
<i class="fas fa-times text-lg"></i>
</button>
</div>
</div>
<!-- 图片容器 -->
<div
class="relative overflow-hidden flex items-center justify-center"
style="width: calc(100% - 120px); height: calc(100% - 120px);"
@mousedown="startDrag"
@touchstart.passive="startDrag"
>
<img
ref="imageRef"
:src="currentImage"
:style="imageStyle"
class="max-w-none transition-transform duration-200 cursor-move select-none"
draggable="false"
@load="handleImageLoad"
/>
</div>
<!-- 左箭头 -->
<button
v-if="images.length > 1"
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-black/50 hover:bg-black/70 text-white flex items-center justify-center transition z-10"
:class="{ 'opacity-30 cursor-not-allowed': currentIndex === 0 }"
:disabled="currentIndex === 0"
@click="prev"
>
<i class="fas fa-chevron-left text-xl"></i>
</button>
<!-- 右箭头 -->
<button
v-if="images.length > 1"
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-black/50 hover:bg-black/70 text-white flex items-center justify-center transition z-10"
:class="{ 'opacity-30 cursor-not-allowed': currentIndex === images.length - 1 }"
:disabled="currentIndex === images.length - 1"
@click="next"
>
<i class="fas fa-chevron-right text-xl"></i>
</button>
<!-- 底部缩略图多图时显示 -->
<div
v-if="images.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-black/60 rounded-xl z-10"
>
<div
v-for="(img, index) in images"
:key="index"
@click="goTo(index)"
class="w-12 h-12 rounded-lg overflow-hidden cursor-pointer transition-all border-2"
:class="index === currentIndex ? 'border-primary scale-110' : 'border-transparent opacity-60 hover:opacity-100'"
>
<img :src="img" class="w-full h-full object-cover" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
interface Props {
show: boolean
images: string[]
initialIndex?: number
}
const props = withDefaults(defineProps<Props>(), {
initialIndex: 0
})
const emit = defineEmits<{
close: []
}>()
// 状态
const currentIndex = ref(props.initialIndex)
const scale = ref(1)
const rotation = ref(0)
const translateX = ref(0)
const translateY = ref(0)
const imageRef = ref<HTMLImageElement | null>(null)
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const dragStartTranslateX = ref(0)
const dragStartTranslateY = ref(0)
// 当前显示的图片
const currentImage = computed(() => props.images[currentIndex.value] || '')
// 图片样式
const imageStyle = computed(() => ({
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value}) rotate(${rotation.value}deg)`
}))
// 监听显示状态变化
watch(() => props.show, (show) => {
if (show) {
currentIndex.value = props.initialIndex
resetTransform()
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleKeydown)
document.addEventListener('mousemove', handleDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', handleDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
} else {
document.body.style.overflow = ''
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', handleDrag)
document.removeEventListener('touchend', stopDrag)
}
})
// 监听索引变化重置变换
watch(currentIndex, () => {
resetTransform()
})
// 重置变换
function resetTransform() {
scale.value = 1
rotation.value = 0
translateX.value = 0
translateY.value = 0
}
// 图片加载完成
function handleImageLoad() {
fitToScreen()
}
// 关闭
function handleClose() {
emit('close')
}
// 上一张
function prev() {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
// 下一张
function next() {
if (currentIndex.value < props.images.length - 1) {
currentIndex.value++
}
}
// 跳转到指定图片
function goTo(index: number) {
currentIndex.value = index
}
// 放大
function zoomIn() {
scale.value = Math.min(scale.value * 1.2, 5)
}
// 缩小
function zoomOut() {
scale.value = Math.max(scale.value / 1.2, 0.1)
}
// 重置为原始大小
function resetScale() {
scale.value = 1
translateX.value = 0
translateY.value = 0
}
// 适应屏幕
function fitToScreen() {
if (!imageRef.value) return
const img = imageRef.value
const containerWidth = window.innerWidth - 120
const containerHeight = window.innerHeight - 120
const imgRatio = img.naturalWidth / img.naturalHeight
const containerRatio = containerWidth / containerHeight
if (imgRatio > containerRatio) {
scale.value = containerWidth / img.naturalWidth
} else {
scale.value = containerHeight / img.naturalHeight
}
// 限制最大为原始大小
scale.value = Math.min(scale.value, 1)
translateX.value = 0
translateY.value = 0
}
// 左旋转
function rotateLeft() {
rotation.value -= 90
}
// 右旋转
function rotateRight() {
rotation.value += 90
}
// 滚轮缩放
function handleWheel(e: WheelEvent) {
e.preventDefault()
if (e.deltaY < 0) {
zoomIn()
} else {
zoomOut()
}
}
// 开始拖动
function startDrag(e: MouseEvent | TouchEvent) {
isDragging.value = true
if (e instanceof MouseEvent) {
dragStartX.value = e.clientX
dragStartY.value = e.clientY
} else {
dragStartX.value = e.touches[0].clientX
dragStartY.value = e.touches[0].clientY
}
dragStartTranslateX.value = translateX.value
dragStartTranslateY.value = translateY.value
}
// 拖动中
function handleDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value) return
e.preventDefault()
let clientX: number, clientY: number
if (e instanceof MouseEvent) {
clientX = e.clientX
clientY = e.clientY
} else {
clientX = e.touches[0].clientX
clientY = e.touches[0].clientY
}
translateX.value = dragStartTranslateX.value + (clientX - dragStartX.value)
translateY.value = dragStartTranslateY.value + (clientY - dragStartY.value)
}
// 停止拖动
function stopDrag() {
isDragging.value = false
}
// 键盘事件
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'Escape':
handleClose()
break
case 'ArrowLeft':
prev()
break
case 'ArrowRight':
next()
break
case '+':
case '=':
zoomIn()
break
case '-':
zoomOut()
break
}
}
onUnmounted(() => {
document.body.style.overflow = ''
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', handleDrag)
document.removeEventListener('touchend', stopDrag)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<div
v-if="show"
class="mention-picker absolute z-50 bg-panel border border-gray-700 rounded-xl shadow-2xl overflow-hidden max-h-64 w-64"
:style="positionStyle"
>
<!-- 搜索框 -->
<div class="p-2 border-b border-gray-700">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="w-full bg-input text-white text-sm px-3 py-2 rounded-lg outline-none focus:ring-1 ring-primary placeholder-gray-500"
placeholder="搜索用户..."
@keydown.down.prevent="moveSelection(1)"
@keydown.up.prevent="moveSelection(-1)"
@keydown.enter.prevent="confirmSelection"
@keydown.esc="$emit('close')"
/>
</div>
<!-- 用户列表 -->
<div class="overflow-y-auto max-h-48 custom-scrollbar">
<div v-if="filteredUsers.length === 0" class="p-4 text-center text-gray-500 text-sm">
暂无匹配用户
</div>
<div
v-for="(user, index) in filteredUsers"
:key="user.id"
class="flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors"
:class="index === selectedIndex ? 'bg-primary/20 text-white' : 'hover:bg-white/5 text-gray-300'"
@click="selectUser(user)"
@mouseenter="selectedIndex = index"
>
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
:style="{ background: generateColor(user.name || user.id) }"
>
<img
v-if="user.avatar && isImageUrl(user.avatar)"
:src="user.avatar"
:alt="user.name"
class="w-full h-full object-cover"
/>
<span v-else>{{ (user.name || '?').charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ user.name || user.id }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { generateColor } from '@/utils/format'
export interface MentionUser {
id: string
name: string
avatar?: string
}
interface Props {
show: boolean
users: MentionUser[]
position?: { x: number; y: number }
}
const props = withDefaults(defineProps<Props>(), {
position: () => ({ x: 0, y: 0 })
})
const emit = defineEmits<{
select: [user: MentionUser]
close: []
}>()
const searchQuery = ref('')
const selectedIndex = ref(0)
const searchInputRef = ref<HTMLInputElement | null>(null)
// 计算过滤后的用户列表
const filteredUsers = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
if (!query) return props.users.slice(0, 20)
return props.users.filter(user =>
user.name?.toLowerCase().includes(query) ||
user.id?.toLowerCase().includes(query)
).slice(0, 20)
})
// 计算位置样式
const positionStyle = computed(() => {
return {
left: `${props.position.x}px`,
bottom: `${props.position.y}px`
}
})
// 判断是否为图片URL
function isImageUrl(url: string): boolean {
if (!url) return false
if (url.startsWith('data:image/')) return true
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return true
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']
return imageExtensions.some(ext => url.toLowerCase().endsWith(ext))
}
// 移动选择
function moveSelection(delta: number) {
const newIndex = selectedIndex.value + delta
if (newIndex >= 0 && newIndex < filteredUsers.value.length) {
selectedIndex.value = newIndex
}
}
// 确认选择
function confirmSelection() {
if (filteredUsers.value.length > 0) {
selectUser(filteredUsers.value[selectedIndex.value])
}
}
// 选择用户
function selectUser(user: MentionUser) {
emit('select', user)
searchQuery.value = ''
selectedIndex.value = 0
}
// 监听显示状态,自动聚焦
watch(() => props.show, (show) => {
if (show) {
searchQuery.value = ''
selectedIndex.value = 0
nextTick(() => {
searchInputRef.value?.focus()
})
}
})
// 监听搜索变化重置选择
watch(searchQuery, () => {
selectedIndex.value = 0
})
// 点击外部关闭
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.mention-picker')) {
emit('close')
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-700 rounded-full;
}
</style>

View File

@@ -109,6 +109,76 @@
</div>
</div>
<!-- 朋友圈预览区 -->
<div class="px-8 py-4 max-w-xl mx-auto w-full">
<div class="bg-white/5 rounded-2xl border border-white/5 overflow-hidden">
<div class="flex items-center justify-between p-4 border-b border-white/5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 shrink-0">
<i class="fas fa-images"></i>
</div>
<span class="text-sm text-gray-400 font-medium">朋友圈</span>
</div>
<button
@click="$emit('view-moments')"
class="text-xs text-primary hover:text-primary-hover transition flex items-center gap-1"
>
查看更多 <i class="fas fa-chevron-right"></i>
</button>
</div>
<!-- 加载状态 -->
<div v-if="loadingMoments" class="p-6 flex justify-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
<!-- 朋友圈内容 -->
<div v-else-if="userMoments.length > 0" class="p-4">
<!-- 图片网格 -->
<div class="grid grid-cols-3 gap-2">
<div
v-for="(moment, index) in userMoments.slice(0, 3)"
:key="moment.id"
class="relative aspect-square rounded-xl overflow-hidden cursor-pointer group"
@click="openMomentPreview(index)"
>
<img
v-if="getMomentImage(moment)"
:src="getMomentImage(moment)"
class="w-full h-full object-cover transition-transform group-hover:scale-110"
loading="lazy"
/>
<div v-else class="w-full h-full bg-gray-800 flex items-center justify-center">
<i class="fas fa-file-alt text-gray-600 text-xl"></i>
</div>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors"></div>
</div>
</div>
<!-- 最新动态文字预览 -->
<div v-if="userMoments[0]?.content" class="mt-3 text-xs text-gray-400 line-clamp-2">
{{ userMoments[0].content }}
</div>
</div>
<!-- 空状态 -->
<div v-else class="p-6 text-center">
<div class="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-3">
<i class="fas fa-camera-retro text-gray-600 text-lg"></i>
</div>
<p class="text-gray-500 text-xs">暂无朋友圈动态</p>
</div>
</div>
</div>
<!-- 图片预览 -->
<ImagePreview
:show="showMomentPreview"
:images="momentPreviewImages"
:initial-index="momentPreviewIndex"
@close="showMomentPreview = false"
/>
<!-- 底部快捷操作栏 -->
<div class="mt-auto p-6 bg-gradient-to-t from-[#0f172a] via-[#0f172a]/80 to-transparent">
<div class="grid grid-cols-3 gap-4 max-w-lg mx-auto w-full">
@@ -143,26 +213,90 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useToastStore } from '@/stores/toast'
import Avatar from '@/components/common/Avatar.vue'
import ImagePreview from '@/components/common/ImagePreview.vue'
import * as momentApi from '@/api/modules/moment'
import { parseMediaUrls } from '@/types/moment'
import type { User } from '@/types/api'
import type { Moment } from '@/types/moment'
interface Props {
show: boolean
user: User | null
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
'send-message': []
'audio-call': []
'video-call': []
'view-moments': []
}>()
const toastStore = useToastStore()
// 朋友圈相关状态
const userMoments = ref<Moment[]>([])
const loadingMoments = ref(false)
const showMomentPreview = ref(false)
const momentPreviewImages = ref<string[]>([])
const momentPreviewIndex = ref(0)
// 监听显示状态和用户变化,加载朋友圈
watch(() => [props.show, props.user], async ([show, user]) => {
if (show && user && user.id) {
await loadUserMoments(user.id)
} else {
userMoments.value = []
}
}, { immediate: true })
// 加载用户朋友圈
async function loadUserMoments(userId: string) {
loadingMoments.value = true
try {
const response = await momentApi.getUserMoments(userId, 1, 3)
// 处理分页响应
userMoments.value = response.data || []
} catch (e) {
console.error('Failed to load user moments:', e)
userMoments.value = []
} finally {
loadingMoments.value = false
}
}
// 获取动态的第一张图片
function getMomentImage(moment: Moment): string | null {
if (moment.media_type === 1 && moment.media_urls) {
const urls = parseMediaUrls(moment.media_urls)
return urls[0] || null
}
return null
}
// 打开朋友圈图片预览
function openMomentPreview(index: number) {
// 收集所有有图片的动态的图片
const images: string[] = []
userMoments.value.slice(0, 3).forEach(moment => {
if (moment.media_type === 1 && moment.media_urls) {
const urls = parseMediaUrls(moment.media_urls)
images.push(...urls)
}
})
if (images.length > 0) {
momentPreviewImages.value = images
momentPreviewIndex.value = Math.min(index, images.length - 1)
showMomentPreview.value = true
}
}
function copyText(text?: string) {
if (!text) return
navigator.clipboard.writeText(text).then(() => {
@@ -217,3 +351,7 @@ function viewAvatar() {

View File

@@ -96,6 +96,7 @@
v-for="(url, index) in getMediaUrls(moment)"
:key="index"
class="relative aspect-square bg-gray-800 rounded-xl overflow-hidden cursor-pointer group"
@click="openImagePreview(getMediaUrls(moment), index)"
>
<img :src="url" class="w-full h-full object-cover transition-transform group-hover:scale-105" loading="lazy" />
</div>
@@ -245,6 +246,14 @@
</div>
</div>
</div>
<!-- 图片预览 -->
<ImagePreview
:show="showImagePreview"
:images="previewImages"
:initial-index="previewIndex"
@close="showImagePreview = false"
/>
</div>
</template>
@@ -253,6 +262,7 @@ import { ref, reactive, onMounted, nextTick } from 'vue'
import { useMomentStore } from '@/stores/moment'
import { useAuthStore } from '@/stores/auth'
import Avatar from '@/components/common/Avatar.vue'
import ImagePreview from '@/components/common/ImagePreview.vue'
import MomentPublisherDark from './MomentPublisherDark.vue'
import MomentNotificationPanel from './MomentNotificationPanel.vue'
import { parseMediaUrls } from '@/types/moment'
@@ -274,6 +284,18 @@ const commenting = ref(false)
const replyTo = ref<MomentComment | null>(null)
const commentInputRef = ref<HTMLInputElement | null>(null)
// 图片预览相关
const showImagePreview = ref(false)
const previewImages = ref<string[]>([])
const previewIndex = ref(0)
// 打开图片预览
function openImagePreview(images: string[], index: number) {
previewImages.value = images
previewIndex.value = index
showImagePreview.value = true
}
// 初始化
onMounted(() => {
momentStore.fetchMoments()

View File

@@ -141,6 +141,15 @@
<i class="fas fa-hashtag text-lg"></i>
</button>
<!-- @好友 -->
<button
@click="showMentionPicker = true"
class="text-gray-400 hover:text-primary transition p-2 rounded-xl hover:bg-white/5"
title="@好友"
>
<i class="fas fa-at text-lg"></i>
</button>
<div class="flex-1"></div>
<!-- 可见性选择 -->
@@ -217,15 +226,93 @@
</div>
</div>
</div>
<!-- @好友选择弹窗 -->
<div v-if="showMentionPicker" class="fixed inset-0 z-[70] bg-black/60 flex items-center justify-center p-4" @click.self="showMentionPicker = false">
<div class="bg-panel w-96 rounded-2xl border border-gray-700 shadow-2xl animate-scale-in overflow-hidden max-h-[70vh] flex flex-col">
<div class="px-5 py-4 border-b border-gray-700 flex items-center justify-between shrink-0">
<h3 class="text-lg font-bold text-white">@好友</h3>
<button @click="showMentionPicker = false" class="text-gray-400 hover:text-white transition">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 搜索框 -->
<div class="p-3 border-b border-gray-700 shrink-0">
<input
v-model="mentionSearchQuery"
type="text"
placeholder="搜索好友..."
class="w-full bg-input text-white text-sm px-4 py-2.5 rounded-xl outline-none focus:ring-1 ring-primary placeholder-gray-500"
/>
</div>
<!-- 已选择的好友 -->
<div v-if="mentionUserIds.length > 0" class="px-4 py-2 border-b border-gray-700 flex flex-wrap gap-2 shrink-0">
<span
v-for="userId in mentionUserIds"
:key="userId"
class="inline-flex items-center gap-1 px-2 py-1 bg-primary/20 text-primary text-xs rounded-full"
>
@{{ getFriendName(userId) }}
<i class="fas fa-times cursor-pointer hover:text-white" @click="removeMentionUser(userId)"></i>
</span>
</div>
<!-- 好友列表 -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<div v-if="filteredFriends.length === 0" class="p-8 text-center text-gray-500">
暂无好友
</div>
<div
v-for="friend in filteredFriends"
:key="friend.id"
@click="toggleMentionUser(friend)"
class="flex items-center gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition"
:class="{ 'bg-primary/10': mentionUserIds.includes(friend.user_id || friend.id) }"
>
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
:style="{ background: generateColor(friend.user?.name || friend.remark_name || '') }"
>
<img
v-if="friend.user?.avatar && isImageUrl(friend.user.avatar)"
:src="friend.user.avatar"
class="w-full h-full object-cover"
/>
<span v-else>{{ (friend.user?.name || friend.remark_name || '?').charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium truncate">{{ friend.remark_name || friend.user?.name }}</p>
</div>
<i v-if="mentionUserIds.includes(friend.user_id || friend.id)" class="fas fa-check text-primary"></i>
</div>
</div>
<!-- 确认按钮 -->
<div class="p-4 border-t border-gray-700 shrink-0">
<button
@click="confirmMentionSelection"
class="w-full py-2.5 bg-primary text-white rounded-xl font-medium hover:bg-indigo-600 transition"
>
确定 ({{ mentionUserIds.length }})
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useMomentStore } from '@/stores/moment'
import { useToastStore } from '@/stores/toast'
import { useChatStore } from '@/stores/chat'
import * as attachmentApi from '@/api/modules/attachment'
import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import type { CreateMomentRequest } from '@/types/moment'
import type { Contact } from '@/types/api'
const emit = defineEmits<{
(e: 'close'): void
@@ -234,6 +321,7 @@ const emit = defineEmits<{
const momentStore = useMomentStore()
const toast = useToastStore()
const chatStore = useChatStore()
// 状态
const content = ref('')
@@ -253,6 +341,91 @@ const showVisibilitySelector = ref(false)
const locationInput = ref('')
const tagInput = ref('')
// @好友相关状态
const showMentionPicker = ref(false)
const mentionSearchQuery = ref('')
const mentionUserIds = ref<string[]>([])
const friends = ref<Contact[]>([])
// 加载好友列表
onMounted(async () => {
try {
// 优先使用 chatStore 中的联系人
if (chatStore.contacts.length > 0) {
friends.value = chatStore.contacts
} else {
const data = await contactApi.getContacts()
friends.value = data
}
} catch (e) {
console.error('Failed to load friends:', e)
}
})
// 过滤好友列表
const filteredFriends = computed(() => {
const query = mentionSearchQuery.value.toLowerCase().trim()
if (!query) return friends.value
return friends.value.filter(f => {
const name = f.remark_name || f.user?.name || ''
return name.toLowerCase().includes(query)
})
})
// 判断是否为图片URL
function isImageUrl(url: string): boolean {
if (!url) return false
if (url.startsWith('data:image/')) return true
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return true
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']
return imageExtensions.some(ext => url.toLowerCase().endsWith(ext))
}
// 获取好友名称
function getFriendName(userId: string): string {
const friend = friends.value.find(f => (f.user_id || f.id) === userId)
return friend?.remark_name || friend?.user?.name || userId
}
// 切换@用户
function toggleMentionUser(friend: Contact) {
const userId = friend.user_id || friend.id
const index = mentionUserIds.value.indexOf(userId)
if (index > -1) {
mentionUserIds.value.splice(index, 1)
} else {
mentionUserIds.value.push(userId)
}
}
// 移除@用户
function removeMentionUser(userId: string) {
const index = mentionUserIds.value.indexOf(userId)
if (index > -1) {
mentionUserIds.value.splice(index, 1)
// 同时从内容中移除@用户名
const name = getFriendName(userId)
const pattern = new RegExp(`@${name}\\s?`, 'g')
content.value = content.value.replace(pattern, '')
}
}
// 确认@选择
function confirmMentionSelection() {
// 在内容末尾添加@用户名
const newMentions = mentionUserIds.value
.map(id => `@${getFriendName(id)}`)
.filter(mention => !content.value.includes(mention))
if (newMentions.length > 0) {
const separator = content.value && !content.value.endsWith(' ') ? ' ' : ''
content.value += separator + newMentions.join(' ') + ' '
}
showMentionPicker.value = false
mentionSearchQuery.value = ''
}
// 可见性选项
const visibilityOptions = [
{ value: 0, label: '公开', desc: '所有人可见', icon: 'fas fa-globe' },
@@ -305,6 +478,7 @@ async function handlePublish() {
location: location.value || undefined,
visibility: visibility.value,
topic_tags: tags.value.length > 0 ? tags.value : undefined,
mention_user_ids: mentionUserIds.value.length > 0 ? mentionUserIds.value : undefined,
}
await momentStore.publishMoment(req)

View File

@@ -396,6 +396,16 @@ export const useMomentStore = defineStore('moment', () => {
break
}
// 如果当前正在朋友圈tab自动刷新数据
const currentTab = storage.getCurrentTab()
if (currentTab === 'moment') {
fetchMoments()
// 如果当前正在查看相关动态,刷新评论
if (currentMoment.value?.id === payload.moment_id) {
refreshComments(payload.moment_id)
}
}
// 显示带操作按钮的 Toast
toast.showToast(message, 'info', 5000, undefined, {
label: '立即查看',
@@ -410,11 +420,6 @@ export const useMomentStore = defineStore('moment', () => {
fetchMoments()
}
})
// 如果当前正在查看相关动态,刷新评论
if (currentMoment.value?.id === payload.moment_id) {
refreshComments(payload.moment_id)
}
}
// ==========================================

View File

@@ -242,6 +242,8 @@
v-model="inputText"
:is-recording="isRecording"
:is-blocked="isBlockedFromGroup"
:is-group-chat="isGroupChat"
:room-members="currentRoomMembers"
@send="handleSendText"
@file="handleFileSelect"
@record-start="handleRecordStart"
@@ -1600,7 +1602,12 @@ function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, menuItems)
}
function handleSendText() { if (!inputText.value.trim() || !chatStore.currentTarget) return; sendMessage(0, inputText.value); inputText.value = '' }
function handleSendText(mentionUserIds?: string[]) {
if (!inputText.value.trim() || !chatStore.currentTarget) return
const extra = mentionUserIds && mentionUserIds.length > 0 ? { mention_user_ids: mentionUserIds } : {}
sendMessage(0, inputText.value, extra)
inputText.value = ''
}
function handleFileSelect(type: number, file: File) {
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
if (type === 1) { const reader = new FileReader(); reader.onload = (e) => { fileModal.value.preview = e.target?.result as string }; reader.readAsDataURL(file) }
@@ -1963,6 +1970,7 @@ onMounted(async () => {
await wsManager.connect(authStore.user.id)
wsManager.onMessage(handleWebSocketMessage)
wsManager.onSignal(webrtc.handleSignaling)
wsManager.onMomentNotification(momentStore.handleWsNotification)
}
await loadContacts()
await conversationStore.loadConversations()
@@ -1995,6 +2003,7 @@ onMounted(async () => {
onUnmounted(() => {
wsManager.offMessage(handleWebSocketMessage)
wsManager.offSignal(webrtc.handleSignaling)
wsManager.offMomentNotification(momentStore.handleWsNotification)
})
</script>

View File

@@ -125,3 +125,7 @@