|
|
@@ -1,5 +1,16 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { ref } from 'vue';
|
|
|
+import { ref, computed, onMounted } from 'vue';
|
|
|
+import axios from 'axios';
|
|
|
+import { useUserStore } from '../stores/user';
|
|
|
+
|
|
|
+interface FileAttachment {
|
|
|
+ file_id: string;
|
|
|
+ filename: string;
|
|
|
+ media_type: string;
|
|
|
+ size: number;
|
|
|
+ preview_url?: string;
|
|
|
+ uploading?: boolean;
|
|
|
+}
|
|
|
|
|
|
const props = defineProps<{
|
|
|
isLoading?: boolean;
|
|
|
@@ -8,36 +19,314 @@ const props = defineProps<{
|
|
|
const message = ref('');
|
|
|
const stream = ref(true);
|
|
|
const emit = defineEmits(['send']);
|
|
|
+const userStore = useUserStore();
|
|
|
+
|
|
|
+// ============ 附件相关 ============
|
|
|
+const attachments = ref<FileAttachment[]>([]);
|
|
|
+const fileInput = ref<HTMLInputElement | null>(null);
|
|
|
+const showMediaMenu = ref(false);
|
|
|
+
|
|
|
+// 文件类型配置(已移除音频)
|
|
|
+const MEDIA_TYPES = {
|
|
|
+ image: { accept: '.jpg,.jpeg,.png,.gif,.webp,.bmp', label: '📷 图片', maxSize: 20 },
|
|
|
+ video: { accept: '.mp4,.mov,.avi,.mkv,.webm', label: '🎬 视频', maxSize: 100 },
|
|
|
+};
|
|
|
+
|
|
|
+let currentAccept = '';
|
|
|
+
|
|
|
+const selectMediaType = (type: 'image' | 'video') => {
|
|
|
+ showMediaMenu.value = false;
|
|
|
+ currentAccept = MEDIA_TYPES[type].accept;
|
|
|
+ if (fileInput.value) {
|
|
|
+ fileInput.value.accept = currentAccept;
|
|
|
+ fileInput.value.click();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleFileSelect = async (event: Event) => {
|
|
|
+ const input = event.target as HTMLInputElement;
|
|
|
+ if (!input.files?.length) return;
|
|
|
+
|
|
|
+ for (const file of Array.from(input.files)) {
|
|
|
+ await uploadFile(file);
|
|
|
+ }
|
|
|
+ input.value = '';
|
|
|
+};
|
|
|
+
|
|
|
+const uploadFile = async (file: File) => {
|
|
|
+ let previewUrl: string | undefined;
|
|
|
+ if (file.type.startsWith('image/')) {
|
|
|
+ previewUrl = URL.createObjectURL(file);
|
|
|
+ }
|
|
|
+
|
|
|
+ const placeholder: FileAttachment = {
|
|
|
+ file_id: '',
|
|
|
+ filename: file.name,
|
|
|
+ media_type: detectMediaType(file.name),
|
|
|
+ size: file.size,
|
|
|
+ preview_url: previewUrl,
|
|
|
+ uploading: true,
|
|
|
+ };
|
|
|
+ attachments.value.push(placeholder);
|
|
|
+ const idx = attachments.value.length - 1;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const formData = new FormData();
|
|
|
+ formData.append('file', file);
|
|
|
|
|
|
-// 可用的模型选项
|
|
|
-// const models = [
|
|
|
-// { value: "qwen-turbo-2025-04-28", label: "Qwen Turbo" },
|
|
|
-// { value: "qwen-plus-2025-07-14", label: "Qwen Plus" },
|
|
|
-// { value: "qwen-flash", label: "Qwen Flash" }
|
|
|
-// ];
|
|
|
+ const { data } = await axios.post(
|
|
|
+ `${import.meta.env.VITE_API_URL}/chat/upload`,
|
|
|
+ formData,
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ 'Authorization': `Bearer ${userStore.userId}`,
|
|
|
+ 'Content-Type': 'multipart/form-data',
|
|
|
+ },
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ attachments.value[idx] = {
|
|
|
+ file_id: data.file_id,
|
|
|
+ filename: data.filename,
|
|
|
+ media_type: data.media_type,
|
|
|
+ size: data.size,
|
|
|
+ preview_url: previewUrl,
|
|
|
+ uploading: false,
|
|
|
+ };
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('文件上传失败:', error);
|
|
|
+ attachments.value.splice(idx, 1);
|
|
|
+ alert(error.response?.data?.detail || '文件上传失败');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const detectMediaType = (filename: string): string => {
|
|
|
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
|
|
|
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) return 'image';
|
|
|
+ if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(ext)) return 'video';
|
|
|
+ return 'image';
|
|
|
+};
|
|
|
+
|
|
|
+const removeAttachment = (index: number) => {
|
|
|
+ const att = attachments.value[index];
|
|
|
+ if (att.preview_url) {
|
|
|
+ URL.revokeObjectURL(att.preview_url);
|
|
|
+ }
|
|
|
+ attachments.value.splice(index, 1);
|
|
|
+};
|
|
|
+
|
|
|
+const hasUploadingFiles = () => attachments.value.some(a => a.uploading);
|
|
|
|
|
|
const sendMessage = () => {
|
|
|
- if (!message.value.trim() || props.isLoading) return;
|
|
|
- emit('send', message.value, stream.value);
|
|
|
+ if ((!message.value.trim() && attachments.value.length === 0) || props.isLoading || hasUploadingFiles()) return;
|
|
|
+ const files = attachments.value.map(({ file_id, filename, media_type, size }) => ({ file_id, filename, media_type, size }));
|
|
|
+ emit('send', message.value, stream.value, files.length > 0 ? files : undefined);
|
|
|
message.value = '';
|
|
|
+ attachments.value = [];
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 语音录音相关 ============
|
|
|
+const voiceSupported = ref(true);
|
|
|
+const isVoiceMode = ref(false);
|
|
|
+const isRecording = ref(false);
|
|
|
+const isCancelling = ref(false);
|
|
|
+const recordingDuration = ref(0);
|
|
|
+const isTranscribing = ref(false);
|
|
|
+
|
|
|
+let mediaRecorder: MediaRecorder | null = null;
|
|
|
+let audioChunks: Blob[] = [];
|
|
|
+let recordingTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
+let startY = 0;
|
|
|
+const CANCEL_THRESHOLD = 80; // 上移取消阈值(px)
|
|
|
+const MAX_DURATION = 60; // 最大录音时长(秒)
|
|
|
+const MIN_DURATION = 1; // 最短录音时长(秒)
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ // 检测浏览器是否支持录音
|
|
|
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
|
+ voiceSupported.value = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (typeof MediaRecorder === 'undefined') {
|
|
|
+ voiceSupported.value = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 检查 webm/opus 支持
|
|
|
+ if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
|
+ // 尝试回退到 audio/webm
|
|
|
+ if (!MediaRecorder.isTypeSupported('audio/webm')) {
|
|
|
+ voiceSupported.value = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+const toggleVoiceMode = () => {
|
|
|
+ if (isRecording.value || isTranscribing.value) return;
|
|
|
+ isVoiceMode.value = !isVoiceMode.value;
|
|
|
+};
|
|
|
+
|
|
|
+const recordingAreaClasses = computed(() => {
|
|
|
+ if (isTranscribing.value) return 'bg-blue-900/50 border-blue-500';
|
|
|
+ if (isCancelling.value) return 'bg-red-900/50 border-red-500';
|
|
|
+ if (isRecording.value) return 'bg-green-900/50 border-green-500 recording-pulse';
|
|
|
+ return 'bg-gray-700 border-gray-600';
|
|
|
+});
|
|
|
+
|
|
|
+const formatDuration = (seconds: number): string => {
|
|
|
+ const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
|
|
+ const s = (seconds % 60).toString().padStart(2, '0');
|
|
|
+ return `${m}:${s}`;
|
|
|
+};
|
|
|
+
|
|
|
+const startRecording = async (event: MouseEvent | TouchEvent) => {
|
|
|
+ if (isTranscribing.value || props.isLoading) return;
|
|
|
+
|
|
|
+ startY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
|
|
+ isCancelling.value = false;
|
|
|
+ audioChunks = [];
|
|
|
+ recordingDuration.value = 0;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
+
|
|
|
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
|
+ ? 'audio/webm;codecs=opus'
|
|
|
+ : 'audio/webm';
|
|
|
+
|
|
|
+ mediaRecorder = new MediaRecorder(audioStream, { mimeType });
|
|
|
+
|
|
|
+ mediaRecorder.ondataavailable = (e) => {
|
|
|
+ if (e.data.size > 0) audioChunks.push(e.data);
|
|
|
+ };
|
|
|
+
|
|
|
+ mediaRecorder.start(100); // 每 100ms 生成一个 chunk
|
|
|
+ isRecording.value = true;
|
|
|
+
|
|
|
+ recordingTimer = setInterval(() => {
|
|
|
+ recordingDuration.value++;
|
|
|
+ if (recordingDuration.value >= MAX_DURATION) {
|
|
|
+ stopRecording();
|
|
|
+ }
|
|
|
+ }, 1000);
|
|
|
+ } catch (err: any) {
|
|
|
+ console.error('无法获取麦克风权限:', err);
|
|
|
+ alert('请允许麦克风权限以使用语音功能');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const stopRecording = async () => {
|
|
|
+ if (!isRecording.value || !mediaRecorder) return;
|
|
|
+
|
|
|
+ if (isCancelling.value) {
|
|
|
+ cancelRecording();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 停止计时器
|
|
|
+ if (recordingTimer) {
|
|
|
+ clearInterval(recordingTimer);
|
|
|
+ recordingTimer = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查录音时长
|
|
|
+ const duration = recordingDuration.value;
|
|
|
+
|
|
|
+ // 停止录音
|
|
|
+ mediaRecorder.stop();
|
|
|
+ mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
|
|
+ isRecording.value = false;
|
|
|
+
|
|
|
+ // 等待最后的 dataavailable
|
|
|
+ await new Promise<void>(resolve => {
|
|
|
+ if (mediaRecorder) {
|
|
|
+ mediaRecorder.onstop = () => resolve();
|
|
|
+ } else {
|
|
|
+ resolve();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (duration < MIN_DURATION) {
|
|
|
+ alert('录音太短,请至少录制 1 秒');
|
|
|
+ audioChunks = [];
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (audioChunks.length === 0) return;
|
|
|
+
|
|
|
+ const audioBlob = new Blob(audioChunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
|
|
|
+ audioChunks = [];
|
|
|
+ await sendVoice(audioBlob);
|
|
|
+};
|
|
|
+
|
|
|
+const handleMouseMove = (event: MouseEvent) => {
|
|
|
+ if (!isRecording.value) return;
|
|
|
+ const deltaY = startY - event.clientY;
|
|
|
+ isCancelling.value = deltaY > CANCEL_THRESHOLD;
|
|
|
+};
|
|
|
+
|
|
|
+const handleTouchMove = (event: TouchEvent) => {
|
|
|
+ if (!isRecording.value) return;
|
|
|
+ const deltaY = startY - event.touches[0].clientY;
|
|
|
+ isCancelling.value = deltaY > CANCEL_THRESHOLD;
|
|
|
+};
|
|
|
+
|
|
|
+const cancelRecording = () => {
|
|
|
+ if (!mediaRecorder) return;
|
|
|
+ if (recordingTimer) {
|
|
|
+ clearInterval(recordingTimer);
|
|
|
+ recordingTimer = null;
|
|
|
+ }
|
|
|
+ mediaRecorder.stop();
|
|
|
+ mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
|
|
+ isRecording.value = false;
|
|
|
+ isCancelling.value = false;
|
|
|
+ audioChunks = [];
|
|
|
+};
|
|
|
+
|
|
|
+const handleMouseLeave = () => {
|
|
|
+ // 鼠标离开区域时如果正在录音且已标记取消,则取消
|
|
|
+ if (isRecording.value && isCancelling.value) {
|
|
|
+ cancelRecording();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const sendVoice = async (audioBlob: Blob) => {
|
|
|
+ isTranscribing.value = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const formData = new FormData();
|
|
|
+ formData.append('file', audioBlob, 'voice.webm');
|
|
|
+
|
|
|
+ const { data } = await axios.post(
|
|
|
+ `${import.meta.env.VITE_API_URL}/chat/voice`,
|
|
|
+ formData,
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ 'Authorization': `Bearer ${userStore.userId}`,
|
|
|
+ 'Content-Type': 'multipart/form-data',
|
|
|
+ },
|
|
|
+ timeout: 30000,
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ if (data.text && data.text.trim()) {
|
|
|
+ emit('send', data.text, stream.value, undefined);
|
|
|
+ } else {
|
|
|
+ alert('未能识别语音内容,请重试');
|
|
|
+ }
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('语音转写失败:', error);
|
|
|
+ alert(error.response?.data?.detail || '语音转写失败,请重试');
|
|
|
+ } finally {
|
|
|
+ isTranscribing.value = false;
|
|
|
+ }
|
|
|
};
|
|
|
</script>
|
|
|
-<!-- bg-gray-800 -->
|
|
|
+
|
|
|
<template>
|
|
|
<div class="p-2 sm:p-4 space-y-2 sm:space-y-4">
|
|
|
- <!-- 第一行:流式输出、状态指示器、模型选择 -->
|
|
|
+ <!-- 第一行:状态指示器 -->
|
|
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
|
|
- <!-- 流式输出开关 -->
|
|
|
-<!-- <label class="flex items-center space-x-2 text-white cursor-pointer">-->
|
|
|
-<!-- <input-->
|
|
|
-<!-- v-model="stream"-->
|
|
|
-<!-- type="checkbox"-->
|
|
|
-<!-- class="rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500"-->
|
|
|
-<!-- />-->
|
|
|
-<!-- <span class="text-sm">流式输出</span>-->
|
|
|
-<!-- </label>-->
|
|
|
-
|
|
|
- <!-- 状态指示器 -->
|
|
|
<div class="flex items-center space-x-1">
|
|
|
<div
|
|
|
:class="[
|
|
|
@@ -54,101 +343,140 @@ const sendMessage = () => {
|
|
|
{{ stream ? '流式模式' : '完整模式' }}
|
|
|
</span>
|
|
|
</div>
|
|
|
-
|
|
|
- <!-- 模型选择下拉框 -->
|
|
|
-<!-- <div class="flex items-center space-x-2">
|
|
|
- <label class="text-sm text-gray-300">模型:</label>
|
|
|
- <select
|
|
|
- v-model="model"
|
|
|
- class="bg-gray-700 text-white text-sm rounded-lg border border-gray-600 px-3 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-[140px]"
|
|
|
- >
|
|
|
- <option
|
|
|
- v-for="model in models"
|
|
|
- :key="model.value"
|
|
|
- :value="model.value"
|
|
|
- class="bg-gray-700"
|
|
|
- >
|
|
|
- {{ model.label }}
|
|
|
- </option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!– 模型显示 –>
|
|
|
- <div class="flex items-center space-x-1 text-xs text-gray-400">
|
|
|
- <span>当前:</span>
|
|
|
- <span class="text-blue-400 font-medium">
|
|
|
- {{ models.find(m => m.value === model)?.label }}
|
|
|
- </span>
|
|
|
- </div>-->
|
|
|
</div>
|
|
|
|
|
|
- <!-- 第二行:温度和Token控制滑块 -->
|
|
|
-<!-- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
- <!– 温度控制 –>
|
|
|
- <div class="space-y-2">
|
|
|
- <div class="flex items-center justify-between">
|
|
|
- <label class="text-sm text-gray-300">温度 (Temperature)</label>
|
|
|
- <span class="text-xs text-blue-400 font-mono bg-gray-700 px-2 py-1 rounded">
|
|
|
- {{ temperature.toFixed(1) }}
|
|
|
- </span>
|
|
|
+ <!-- 附件预览区 -->
|
|
|
+ <div v-if="attachments.length > 0" class="flex flex-wrap gap-2 px-1">
|
|
|
+ <div
|
|
|
+ v-for="(att, idx) in attachments"
|
|
|
+ :key="idx"
|
|
|
+ class="relative group rounded-lg border border-gray-600 bg-gray-700/50 p-1.5 flex items-center gap-2"
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ v-if="att.media_type === 'image' && att.preview_url"
|
|
|
+ :src="att.preview_url"
|
|
|
+ class="w-10 h-10 rounded object-cover"
|
|
|
+ :alt="att.filename"
|
|
|
+ />
|
|
|
+ <div v-else class="w-10 h-10 rounded bg-gray-600 flex items-center justify-center text-lg">
|
|
|
+ 🎬
|
|
|
</div>
|
|
|
- <div class="relative">
|
|
|
- <input
|
|
|
- v-model.number="temperature"
|
|
|
- type="range"
|
|
|
- min="0"
|
|
|
- max="2"
|
|
|
- step="0.1"
|
|
|
- class="w-full h-2 bg-gray-600 rounded-lg appearance-none cursor-pointer slider-thumb"
|
|
|
- />
|
|
|
- <div class="flex justify-between text-xs text-gray-500 mt-1">
|
|
|
- <span>保守 (0.0)</span>
|
|
|
- <span>平衡 (1.0)</span>
|
|
|
- <span>创造 (2.0)</span>
|
|
|
- </div>
|
|
|
+ <div class="flex flex-col max-w-[100px]">
|
|
|
+ <span class="text-xs text-gray-300 truncate">{{ att.filename }}</span>
|
|
|
+ <span v-if="att.uploading" class="text-xs text-yellow-400">上传中...</span>
|
|
|
+ <span v-else class="text-xs text-green-400">✓</span>
|
|
|
</div>
|
|
|
+ <button
|
|
|
+ @click="removeAttachment(idx)"
|
|
|
+ class="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 text-white rounded-full text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
|
+ >
|
|
|
+ ×
|
|
|
+ </button>
|
|
|
</div>
|
|
|
+ </div>
|
|
|
|
|
|
- <!– 最大Token控制 –>
|
|
|
- <div class="space-y-2">
|
|
|
- <div class="flex items-center justify-between">
|
|
|
- <label class="text-sm text-gray-300">最大Tokens</label>
|
|
|
- <span class="text-xs text-green-400 font-mono bg-gray-700 px-2 py-1 rounded">
|
|
|
- {{ maxTokens.toLocaleString() }}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <div class="relative">
|
|
|
- <input
|
|
|
- v-model.number="maxTokens"
|
|
|
- type="range"
|
|
|
- min="100"
|
|
|
- max="8000"
|
|
|
- step="100"
|
|
|
- class="w-full h-2 bg-gray-600 rounded-lg appearance-none cursor-pointer slider-thumb"
|
|
|
- />
|
|
|
- <div class="flex justify-between text-xs text-gray-500 mt-1">
|
|
|
- <span>短 (100)</span>
|
|
|
- <span>中 (4000)</span>
|
|
|
- <span>长 (8000)</span>
|
|
|
- </div>
|
|
|
+ <!-- 输入框和发送按钮 -->
|
|
|
+ <div class="flex space-x-2">
|
|
|
+ <!-- 附件按钮 -->
|
|
|
+ <div class="relative">
|
|
|
+ <button
|
|
|
+ @click="showMediaMenu = !showMediaMenu"
|
|
|
+ :disabled="isLoading || isVoiceMode"
|
|
|
+ class="p-2 sm:p-3 rounded-lg bg-gray-700 border border-gray-600 text-gray-300 hover:text-white hover:bg-gray-600 transition-colors flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ title="添加附件"
|
|
|
+ >
|
|
|
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ <!-- 媒体类型菜单 -->
|
|
|
+ <div
|
|
|
+ v-if="showMediaMenu"
|
|
|
+ class="absolute bottom-full left-0 mb-2 bg-gray-800 border border-gray-600 rounded-lg shadow-xl py-1 min-w-[120px] z-50"
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ v-for="(cfg, type) in MEDIA_TYPES"
|
|
|
+ :key="type"
|
|
|
+ @click="selectMediaType(type as 'image' | 'video')"
|
|
|
+ class="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
|
|
+ >
|
|
|
+ {{ cfg.label }}
|
|
|
+ </button>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>-->
|
|
|
|
|
|
- <!-- 输入框和发送按钮 -->
|
|
|
- <div class="flex space-x-2">
|
|
|
+ <!-- 隐藏的文件输入框 -->
|
|
|
+ <input
|
|
|
+ ref="fileInput"
|
|
|
+ type="file"
|
|
|
+ class="hidden"
|
|
|
+ @change="handleFileSelect"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 文字输入模式 -->
|
|
|
<input
|
|
|
+ v-if="!isVoiceMode"
|
|
|
v-model="message"
|
|
|
placeholder="输入你的消息..."
|
|
|
type="text"
|
|
|
class="flex-1 p-2 sm:p-3 rounded-lg bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 border border-gray-600 text-sm sm:text-base"
|
|
|
+ @keydown.enter.prevent="sendMessage"
|
|
|
/>
|
|
|
+
|
|
|
+ <!-- 录音模式 -->
|
|
|
+ <div
|
|
|
+ v-else
|
|
|
+ class="flex-1 p-2 sm:p-3 rounded-lg border select-none flex items-center justify-center cursor-pointer transition-all duration-200 text-sm sm:text-base"
|
|
|
+ :class="recordingAreaClasses"
|
|
|
+ @mousedown.prevent="startRecording"
|
|
|
+ @mouseup.prevent="stopRecording"
|
|
|
+ @mousemove="handleMouseMove"
|
|
|
+ @mouseleave="handleMouseLeave"
|
|
|
+ @touchstart.prevent="startRecording"
|
|
|
+ @touchend.prevent="stopRecording"
|
|
|
+ @touchmove.prevent="handleTouchMove"
|
|
|
+ >
|
|
|
+ <span v-if="!isRecording && !isTranscribing" class="text-gray-400">按住说话</span>
|
|
|
+ <span v-else-if="isTranscribing" class="text-blue-400 flex items-center gap-2">
|
|
|
+ <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
|
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
|
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
|
+ </svg>
|
|
|
+ 转写中...
|
|
|
+ </span>
|
|
|
+ <span v-else-if="isCancelling" class="text-red-400">↑ 松开取消</span>
|
|
|
+ <span v-else class="text-green-400">
|
|
|
+ 🎙️ {{ formatDuration(recordingDuration) }} · 松开发送
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 麦克风按钮 -->
|
|
|
+ <button
|
|
|
+ v-if="voiceSupported"
|
|
|
+ @click="toggleVoiceMode"
|
|
|
+ :disabled="isTranscribing"
|
|
|
+ :class="[
|
|
|
+ 'p-2 sm:p-3 rounded-lg border transition-colors flex-shrink-0',
|
|
|
+ isVoiceMode
|
|
|
+ ? 'bg-green-600 border-green-500 text-white'
|
|
|
+ : 'bg-gray-700 border-gray-600 text-gray-300 hover:text-white hover:bg-gray-600'
|
|
|
+ ]"
|
|
|
+ :title="isVoiceMode ? '切换文字输入' : '语音输入'"
|
|
|
+ >
|
|
|
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
+ d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4M12 15a3 3 0 003-3V5a3 3 0 00-6 0v7a3 3 0 003 3z" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 发送按钮(仅文字模式显示) -->
|
|
|
<button
|
|
|
+ v-if="!isVoiceMode"
|
|
|
@click="sendMessage"
|
|
|
- :disabled="!message.trim() || isLoading"
|
|
|
+ :disabled="(!message.trim() && attachments.length === 0) || isLoading || hasUploadingFiles()"
|
|
|
:class="[
|
|
|
'px-3 sm:px-6 py-2 sm:py-3 rounded-lg font-medium transition-all duration-200 flex-shrink-0',
|
|
|
- !message.trim() || isLoading
|
|
|
+ (!message.trim() && attachments.length === 0) || isLoading || hasUploadingFiles()
|
|
|
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
|
|
: 'bg-blue-500 hover:bg-blue-600 text-white cursor-pointer transform hover:scale-105'
|
|
|
]"
|
|
|
@@ -165,52 +493,13 @@ const sendMessage = () => {
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
-/* 自定义滑块样式 */
|
|
|
-.slider-thumb::-webkit-slider-thumb {
|
|
|
- appearance: none;
|
|
|
- height: 20px;
|
|
|
- width: 20px;
|
|
|
- border-radius: 50%;
|
|
|
- background: #3b82f6;
|
|
|
- cursor: pointer;
|
|
|
- border: 2px solid #1f2937;
|
|
|
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
|
- transition: all 0.2s ease;
|
|
|
+/* 录音脉冲动画 */
|
|
|
+@keyframes pulse-ring {
|
|
|
+ 0% { transform: scale(0.98); opacity: 0.8; }
|
|
|
+ 50% { transform: scale(1); opacity: 1; }
|
|
|
+ 100% { transform: scale(0.98); opacity: 0.8; }
|
|
|
}
|
|
|
-
|
|
|
-.slider-thumb::-webkit-slider-thumb:hover {
|
|
|
- background: #2563eb;
|
|
|
- transform: scale(1.1);
|
|
|
- box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
|
-}
|
|
|
-
|
|
|
-.slider-thumb::-moz-range-thumb {
|
|
|
- height: 20px;
|
|
|
- width: 20px;
|
|
|
- border-radius: 50%;
|
|
|
- background: #3b82f6;
|
|
|
- cursor: pointer;
|
|
|
- border: 2px solid #1f2937;
|
|
|
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
|
- transition: all 0.2s ease;
|
|
|
-}
|
|
|
-
|
|
|
-.slider-thumb::-moz-range-thumb:hover {
|
|
|
- background: #2563eb;
|
|
|
- transform: scale(1.1);
|
|
|
- box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
|
-}
|
|
|
-
|
|
|
-/* 滑块轨道样式 */
|
|
|
-.slider-thumb::-webkit-slider-runnable-track {
|
|
|
- height: 8px;
|
|
|
- background: linear-gradient(to right, #374151 0%, #3b82f6 50%, #374151 100%);
|
|
|
- border-radius: 4px;
|
|
|
-}
|
|
|
-
|
|
|
-.slider-thumb::-moz-range-track {
|
|
|
- height: 8px;
|
|
|
- background: linear-gradient(to right, #374151 0%, #3b82f6 50%, #374151 100%);
|
|
|
- border-radius: 4px;
|
|
|
+.recording-pulse {
|
|
|
+ animation: pulse-ring 1.5s ease-in-out infinite;
|
|
|
}
|
|
|
</style>
|