UI优化
文件无法发送 已知问题:
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user