Browse Source

feat:客服语音功能开启保护机制

zhangwl 3 weeks ago
parent
commit
30d953d18a

File diff suppressed because it is too large
+ 4954 - 1888
chat-ai-ui-main/package-lock.json


+ 3 - 0
chat-ai-ui-main/package.json

@@ -19,9 +19,12 @@
     "vue-router": "^4.5.0"
   },
   "devDependencies": {
+    "@tailwindcss/postcss": "^4.3.0",
     "@tailwindcss/vite": "^4.0.17",
     "@vitejs/plugin-vue": "^5.2.1",
     "@vue/tsconfig": "^0.7.0",
+    "postcss": "^8.5.15",
+    "postcss-preset-env": "^11.3.0",
     "tailwindcss": "^4.0.17",
     "typescript": "~5.7.2",
     "vite": "^6.2.0",

+ 14 - 0
chat-ai-ui-main/postcss.config.js

@@ -0,0 +1,14 @@
+export default {
+  plugins: {
+    '@tailwindcss/postcss': {},
+    'postcss-preset-env': {
+      // 将 @layer 展平、oklch() 转 rgb() 等现代 CSS 降级
+      features: {
+        'cascade-layers': true,       // 展平 @layer(Chrome 99 以下不支持)
+        'oklab-function': true,       // oklch/oklab → rgb
+        'custom-properties': false,   // 保留 CSS 变量(不内联,运行时解析)
+      },
+      browsers: ['chrome >= 64', 'safari >= 12', 'firefox >= 78'],
+    },
+  },
+};

+ 37 - 4
chat-ai-ui-main/src/components/ChatInput.vue

@@ -132,6 +132,7 @@ const isRecording = ref(false);
 const isCancelling = ref(false);
 const recordingDuration = ref(0);
 const isTranscribing = ref(false);
+const isRequestingMic = ref(false);
 
 let mediaRecorder: MediaRecorder | null = null;
 let audioChunks: Blob[] = [];
@@ -142,8 +143,19 @@ let isStartingRecording = false; // 防止重复启动锁
 const CANCEL_THRESHOLD = 80;
 const MAX_DURATION = 60;
 const MIN_DURATION = 1;
+const MEDIA_TIMEOUT = 60000; // getUserMedia 超时保护(60秒,兼容 iOS 权限弹窗操作时间)
 
 onMounted(() => {
+  // 旧 API 回退:部分旧版 WebView 使用 webkitGetUserMedia
+  if (!navigator.mediaDevices && (navigator as any).webkitGetUserMedia) {
+    (navigator as any).mediaDevices = {
+      getUserMedia: (constraints: any) =>
+        new Promise((resolve, reject) => {
+          (navigator as any).webkitGetUserMedia(constraints, resolve, reject);
+        })
+    };
+  }
+
   // 检测浏览器是否支持录音
   if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
     voiceSupported.value = false;
@@ -184,6 +196,7 @@ const startRecording = async (event: MouseEvent | TouchEvent) => {
   // 防止重复启动(mousedown + touchstart 同时触发 / 快速重复点击)
   if (isStartingRecording || isRecording.value || isTranscribing.value || props.isLoading) return;
   isStartingRecording = true;
+  isRequestingMic.value = true; // 立即给出视觉反馈,不等待 getUserMedia
 
   startY = 'touches' in event ? event.touches[0].clientY : event.clientY;
   isCancelling.value = false;
@@ -197,10 +210,17 @@ const startRecording = async (event: MouseEvent | TouchEvent) => {
   }
 
   try {
-    const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+    // 用 Promise.race 包装,防止 getUserMedia 永远 pending(Android WebView 未配置 onPermissionRequest 时)
+    const getUserMediaPromise = navigator.mediaDevices.getUserMedia({ audio: true });
+    const timeoutPromise = new Promise<MediaStream>((_, reject) =>
+      setTimeout(() => reject(new Error('MEDIA_TIMEOUT')), MEDIA_TIMEOUT)
+    );
+    const audioStream = await Promise.race([getUserMediaPromise, timeoutPromise]);
+    isRequestingMic.value = false;
 
     // getUserMedia 是异步的,等待期间用户可能已松手,此时应放弃录音
     if (!isStartingRecording) {
+      isRequestingMic.value = false;
       audioStream.getTracks().forEach(track => track.stop());
       return;
     }
@@ -228,8 +248,14 @@ const startRecording = async (event: MouseEvent | TouchEvent) => {
       }
     }, 200);
   } catch (err: any) {
-    console.error('无法获取麦克风权限:', err);
-    alert('请允许麦克风权限以使用语音功能');
+    isRequestingMic.value = false;
+    isStartingRecording = false;
+    if (err.message === 'MEDIA_TIMEOUT') {
+      alert('麦克风权限请求超时,请检查系统设置中是否已授权麦克风权限');
+    } else {
+      console.error('无法获取麦克风权限:', err);
+      alert('请允许麦克风权限以使用语音功能');
+    }
   } finally {
     isStartingRecording = false;
   }
@@ -466,7 +492,14 @@ const sendVoice = async (audioBlob: Blob) => {
         @touchend.prevent="stopRecording"
         @touchmove.prevent="handleTouchMove"
       >
-        <span v-if="!isRecording && !isTranscribing" class="text-gray-400">按住说话</span>
+        <span v-if="isRequestingMic" class="text-yellow-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="!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>

+ 81 - 0
chat-ai-ui-main/src/views/LoadingView.vue

@@ -0,0 +1,81 @@
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import axios from 'axios';
+import { useRouter } from 'vue-router';
+import { useUserStore } from '../stores/user';
+
+const router = useRouter();
+const userStore = useUserStore();
+const loading = ref(true);
+const error = ref('');
+
+onMounted(async () => {
+  const params = new URLSearchParams(window.location.search);
+  const token = params.get('token');
+  const source = params.get('source');
+
+  console.log('[LoadingView] mounted - Token:', token, 'Source:', source, 'User:', userStore.userId);
+
+  // 防WebView循环:如果已登录且没有token参数,直接跳转到chat
+  if (!token && userStore.userId) {
+    console.log('[LoadingView] User already logged in, redirecting to /chat');
+    await router.replace('/chat');
+    return;
+  }
+
+  // 没有token参数且用户未登录
+  if (!token) {
+    error.value = '无效的访问链接,缺少 token 参数';
+    loading.value = false;
+    return;
+  }
+
+  try {
+    console.log('[LoadingView] Verifying token...');
+    const { data } = await axios.post(
+      `${import.meta.env.VITE_API_URL}/users/token/third-party`,
+      { token, source }
+    );
+
+    console.log('[LoadingView] Token verified successfully');
+    userStore.setUser({
+      userId: data.access_token,
+      name: data.username,
+      refreshToken: data.refresh_token,
+    });
+
+    // 使用 router.replace 而不是硬导航,避免 WebView 页面重新加载
+    console.log('[LoadingView] Navigating to /chat');
+    await router.replace('/chat');
+  } catch (err: any) {
+    console.error('[LoadingView] Token verification failed:', err);
+    error.value = err.response?.data?.detail || '验证失败,请检查链接后重试';
+    loading.value = false;
+  }
+});
+
+const goBack = () => {
+  console.log('[LoadingView] User clicked back button');
+  window.history.back();
+};
+</script>
+
+<template>
+  <div class="flex items-center justify-center h-screen bg-gray-900 text-white">
+    <div class="text-center">
+      <div v-if="loading" class="space-y-4">
+        <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
+        <p>验证中...</p>
+      </div>
+      <div v-else class="space-y-4">
+        <p class="text-red-400 max-w-sm">{{ error }}</p>
+        <button
+          @click="goBack"
+          class="px-4 py-2 bg-blue-500 rounded-lg hover:bg-blue-600"
+        >
+          返回
+        </button>
+      </div>
+    </div>
+  </div>
+</template>

+ 1 - 2
chat-ai-ui-main/vite.config.ts

@@ -1,10 +1,9 @@
 import { defineConfig } from 'vite';
 import vue from '@vitejs/plugin-vue';
-import tailwindcss from '@tailwindcss/vite';
 
 // https://vite.dev/config/
 export default defineConfig({
-  plugins: [vue(), tailwindcss()],
+  plugins: [vue()],
   build: {
     // 兼容低版本手机 WebView(Android 7+ / iOS 12+)
     target: ['es2015', 'chrome64', 'safari12'],