朋友圈
This commit is contained in:
@@ -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文件
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
<template>
|
||||
<div>
|
||||
<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"
|
||||
: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="previewImage"
|
||||
@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>
|
||||
|
||||
395
src/components/common/ImagePreview.vue
Normal file
395
src/components/common/ImagePreview.vue
Normal 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>
|
||||
173
src/components/common/MentionPicker.vue
Normal file
173
src/components/common/MentionPicker.vue
Normal 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>
|
||||
@@ -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() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -125,3 +125,7 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user