文件无法发送
已知问题:
This commit is contained in:
2025-12-03 18:30:55 +08:00
parent 6ddea9d988
commit a9f0ac1217
7 changed files with 528 additions and 475 deletions

View File

@@ -1,35 +1,74 @@
<template>
<div
class="px-3 py-2.5 rounded-2xl text-sm shadow-md transition-all relative group-hover:shadow-lg"
:class="message.isSelf ? 'bg-msg-self text-white bubble-self' : 'bg-msg-other text-gray-200 bubble-other'"
>
<!-- 文本消息 -->
<TextBubble v-if="message.message_type === 0" :message="message" />
<!-- 图片消息 -->
<ImageBubble v-else-if="message.message_type === 1" :message="message" />
<!-- 语音消息 -->
<AudioBubble v-else-if="message.message_type === 2" :message="message" />
<!-- 视频消息 -->
<VideoBubble v-else-if="message.message_type === 3" :message="message" />
<!-- 文件消息 -->
<FileBubble v-else-if="message.message_type === 8" :message="message" />
<div class="flex flex-col" :class="message.isSelf ? 'items-end' : 'items-start'">
<!-- 气泡主体 -->
<div
class="relative text-sm shadow-md transition-all duration-300 hover:shadow-lg group/bubble overflow-hidden"
:class="[
bubbleShapeClass,
message.isSelf
? 'bg-gradient-to-br from-primary to-indigo-600 text-white'
: 'bg-white/10 text-gray-200 border border-white/5 hover:bg-white/15'
]"
>
<!-- 文本消息 -->
<TextBubble v-if="message.message_type === 0" :message="message" class="px-4 py-3" />
<!-- 图片消息 (无Padding) -->
<ImageBubble v-else-if="message.message_type === 1" :message="message" />
<!-- 语音消息 -->
<AudioBubble v-else-if="message.message_type === 2" :message="message" class="px-4 py-3" />
<!-- 视频消息 (无Padding) -->
<VideoBubble v-else-if="message.message_type === 3" :message="message" />
<!-- 文件消息 -->
<FileBubble v-else-if="message.message_type === 8" :message="message" class="px-3 py-2" />
</div>
<!-- 底部时间显示 (格式化) -->
<div
class="text-[10px] text-gray-500 mt-1 px-1 transition-opacity opacity-60 group-hover:opacity-100"
:class="message.isSelf ? 'text-right' : 'text-left'"
>
{{ formattedTime }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
import { formatTime } from '@/utils/format'
import TextBubble from './bubble/Text.vue'
import ImageBubble from './bubble/Image.vue'
import AudioBubble from './bubble/Audio.vue'
import VideoBubble from './bubble/Video.vue'
import FileBubble from './bubble/File.vue'
defineProps<{
const props = defineProps<{
message: ChatMessage
}>()
</script>
// 更现代的圆角逻辑
const bubbleShapeClass = computed(() => {
if (props.message.isSelf) {
return 'rounded-2xl rounded-tr-sm' // 自己发的消息,右上角微尖
} else {
return 'rounded-2xl rounded-tl-sm' // 对方发的消息,左上角微尖
}
})
// 使用 format.ts 中的逻辑,但可以根据需要微调为 HH:mm
const formattedTime = computed(() => {
const date = new Date(props.message.created_at)
// 如果是当天,只显示时间,否则显示日期+时间
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
return formatTime(props.message.created_at)
})
</script>

View File

@@ -1,68 +1,78 @@
<template>
<div class="p-4 bg-panel border-t border-gray-800 shrink-0 z-20">
<div class="flex gap-5 text-gray-400 text-lg mb-3 px-1 items-center">
<div class="flex gap-6 text-gray-400 text-lg mb-3 px-2 items-center">
<i
class="far fa-smile hover:text-primary cursor-pointer transition transform hover:scale-110"
title="表情"
@click="insertEmoji('😊')"
class="far fa-smile hover:text-primary cursor-pointer transition-all transform hover:scale-125 hover:rotate-12"
title="表情"
@click="insertEmoji('😊')"
></i>
<i
class="far fa-image hover:text-primary cursor-pointer transition transform hover:scale-110"
@click="triggerFile(1)"
title="发送图片"
class="far fa-image hover:text-green-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(1)"
title="发送图片"
></i>
<!-- 新增:视频上传按钮 -->
<i
class="fas fa-folder-plus hover:text-primary cursor-pointer transition transform hover:scale-110"
@click="triggerFile(8)"
title="发送文件"
class="fas fa-video hover:text-purple-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(3)"
title="发送视频"
></i>
<i
class="fas fa-folder-plus hover:text-yellow-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(8)"
title="发送文件"
></i>
<!-- 长按录音 -->
<div
class="relative flex items-center group cursor-pointer select-none"
@mousedown="handleRecordStart"
@touchstart.passive="handleRecordStart"
@mouseup="handleRecordStop"
@touchend.prevent="handleRecordStop"
@mouseleave="handleRecordCancel"
class="relative flex items-center group cursor-pointer select-none ml-auto mr-2"
@mousedown="handleRecordStart"
@touchstart.passive="handleRecordStart"
@mouseup="handleRecordStop"
@touchend.prevent="handleRecordStop"
@mouseleave="handleRecordCancel"
>
<div
class="w-8 h-8 rounded-full flex items-center justify-center transition-all bg-gray-700 hover:bg-gray-600"
:class="{ 'bg-red-500 text-white recording-btn': isRecording }"
class="w-8 h-8 rounded-full flex items-center justify-center transition-all shadow-md"
:class="isRecording ? 'bg-red-500 text-white scale-110 shadow-red-500/50' : 'bg-gray-700 hover:bg-gray-600 text-gray-300'"
>
<i class="fas" :class="isRecording ? 'fa-microphone-lines' : 'fa-microphone'"></i>
<i class="fas" :class="isRecording ? 'fa-microphone-lines animate-pulse' : 'fa-microphone'"></i>
</div>
<span
v-if="isRecording"
class="ml-2 text-xs text-red-500 font-bold animate-pulse absolute -top-8 bg-black/80 px-2 py-1 rounded text-white whitespace-nowrap"
<div
v-if="isRecording"
class="absolute -top-10 right-0 bg-red-500 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg whitespace-nowrap animate-bounce font-bold"
>
松开发送...
</span>
松开 发送
<div class="absolute bottom-[-4px] right-3 w-2 h-2 bg-red-500 rotate-45"></div>
</div>
</div>
</div>
<div class="bg-input rounded-xl p-2 flex items-end ring-1 ring-white/5 focus-within:ring-primary/50 transition shadow-inner">
<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">
<textarea
:value="modelValue"
class="w-full bg-transparent border-none text-white resize-none h-10 max-h-32 py-2 px-2 text-sm focus:outline-none scrollbar-thin placeholder-gray-500"
placeholder="输入消息 (Enter 发送,Ctrl+V 粘贴)..."
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="$emit('send')"
@paste="handlePaste"
:value="modelValue"
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"
placeholder="输入消息 (Enter 发送)..."
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="$emit('send')"
@paste="handlePaste"
></textarea>
<div
class="bg-primary hover:bg-indigo-500 text-white w-9 h-9 rounded-lg flex items-center justify-center ml-2 transition shadow-lg shadow-indigo-500/30 active:scale-95 cursor-pointer"
@click="$emit('send')"
class="w-10 h-10 rounded-2xl flex items-center justify-center ml-2 transition-all duration-300 cursor-pointer shadow-lg"
:class="modelValue.trim() ? 'bg-primary hover:bg-indigo-500 text-white scale-100 rotate-0' : 'bg-gray-700 text-gray-500 scale-90 rotate-90 opacity-50 cursor-not-allowed'"
@click="$emit('send')"
>
<i class="fas fa-paper-plane text-xs"></i>
<i class="fas fa-paper-plane text-sm transform -ml-0.5 mt-0.5"></i>
</div>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
@change="handleFileChange"
ref="fileInput"
type="file"
class="hidden"
@change="handleFileChange"
/>
</div>
</template>
@@ -99,8 +109,12 @@ function insertEmoji(emoji: string) {
function triggerFile(type: number) {
if (!fileInput.value) return
// 类型映射:1图片,3视频,8文件
fileInput.value.accept = type === 1 ? 'image/*' : type === 3 ? 'video/*' : '*/*'
fileInput.value.value = ''
// 保存当前触发的类型到 input 元素上,或者利用闭包,这里简单利用 data-type 属性不太方便,
// 我们直接利用闭包变量 capturing
// 更好的方式是创建一个 reactive state 存储当前上传类型,但这里为了简单,我们通过 accept 判断
fileInput.value.click()
}
@@ -108,7 +122,10 @@ function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
const type = file.type.startsWith('image/') ? 1 : file.type.startsWith('video/') ? 3 : 8
let type = 8
if (file.type.startsWith('image/')) type = 1
else if (file.type.startsWith('video/')) type = 3
emit('file', type, file)
}
}
@@ -128,6 +145,7 @@ function handlePaste(e: ClipboardEvent) {
}
}
// ... (录音逻辑保持不变)
async function handleRecordStart() {
if (props.isRecording) return
@@ -181,4 +199,3 @@ function handleRecordCancel() {
}
}
</script>

View File

@@ -1,38 +1,101 @@
<template>
<div
class="flex items-center gap-3 cursor-pointer select-none py-1 pr-4 min-w-[100px]"
@click="playAudio"
class="flex items-center gap-3 cursor-pointer select-none min-w-[120px] group"
@click="toggleAudio"
>
<i class="fas fa-play-circle text-2xl opacity-90 hover:opacity-100"></i>
<div class="flex flex-col">
<span class="text-xs font-bold">语音消息</span>
<div
class="w-8 h-8 rounded-full flex items-center justify-center transition-all shadow-sm"
:class="isPlaying ? 'bg-white text-primary scale-110' : 'bg-white/20 group-hover:bg-white/30'"
>
<i class="fas text-sm" :class="isPlaying ? 'fa-pause' : 'fa-play'"></i>
</div>
<div class="flex flex-col flex-1">
<!-- 模拟声波动画 -->
<div class="flex items-center gap-1 h-3 mb-1" v-if="isPlaying">
<div class="w-1 bg-white animate-wave h-full" style="animation-delay: 0s"></div>
<div class="w-1 bg-white animate-wave h-2/3" style="animation-delay: 0.1s"></div>
<div class="w-1 bg-white animate-wave h-full" style="animation-delay: 0.2s"></div>
<div class="w-1 bg-white animate-wave h-1/2" style="animation-delay: 0.3s"></div>
</div>
<span class="text-xs font-bold" v-else>语音消息</span>
<span class="text-[10px] opacity-70">{{ duration }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, onUnmounted } from 'vue'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
message: ChatMessage
}>()
const isPlaying = ref(false)
let audio: HTMLAudioElement | null = null
const extra = computed(() => {
return typeof props.message.extra === 'object' ? props.message.extra : {}
})
const duration = computed(() => {
return extra.value.duration || `00:${Math.ceil(props.message.duration || 10)}`
// 如果有 duration 字段(数字),格式化它
if (typeof props.message.duration === 'number' && props.message.duration > 0) {
return `${props.message.duration}s`
}
return extra.value.duration || `00:${Math.ceil(10)}`
})
const audioUrl = computed(() => {
return extra.value.url || props.message.content
})
function playAudio() {
new Audio(audioUrl.value).play()
function toggleAudio() {
if (isPlaying.value && audio) {
audio.pause()
isPlaying.value = false
} else {
// 停止其他可能正在播放的(这里是一个简化的局部处理,全局互斥需要 Store)
playAudio()
}
}
function playAudio() {
if (!audio) {
// 补充 Base URL 处理
const src = audioUrl.value.startsWith('http') ? audioUrl.value : `http://127.0.0.1:12080${audioUrl.value}`
audio = new Audio(src)
audio.onended = () => {
isPlaying.value = false
}
audio.onpause = () => {
isPlaying.value = false
}
}
audio.play().catch(e => {
console.error("播放失败", e)
isPlaying.value = false
})
isPlaying.value = true
}
onUnmounted(() => {
if (audio) {
audio.pause()
audio = null
}
})
</script>
<style scoped>
@keyframes wave {
0%, 100% { height: 30%; opacity: 0.5; }
50% { height: 100%; opacity: 1; }
}
.animate-wave {
animation: wave 0.8s infinite ease-in-out;
}
</style>

View File

@@ -1,8 +1,9 @@
<template>
<img
:src="`http://127.0.0.1:12080${imageUrl}`"
class="rounded-lg max-w-full cursor-zoom-in hover:brightness-90 transition border border-white/10"
@click="previewImage"
: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"
/>
</template>
@@ -22,8 +23,11 @@ const imageUrl = computed(() => {
function previewImage() {
const w = window.open('')
if (w) {
w.document.write(`<img src="http://127.0.0.1:12080${imageUrl.value}" style="max-width:100%">`)
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>
`)
}
}
</script>