dangdang 1 місяць тому
коміт
b3fb46fd59
49 змінених файлів з 3679 додано та 0 видалено
  1. 1 0
      .gitignore
  2. 7 0
      Dockerfile
  3. 4 0
      README.md
  4. 66 0
      default.conf
  5. 7 0
      depoly.sh
  6. 17 0
      index.html
  7. 31 0
      package.json
  8. BIN
      public/image0.jpg
  9. BIN
      public/image0.webp
  10. BIN
      public/image1.webp
  11. BIN
      public/image2.webp
  12. 1 0
      public/vite.svg
  13. 9 0
      src/App.vue
  14. BIN
      src/assets/banner1.jpg
  15. BIN
      src/assets/banner2.jpg
  16. BIN
      src/assets/banner3.jpg
  17. BIN
      src/assets/banner4.jpg
  18. BIN
      src/assets/head.jpg
  19. BIN
      src/assets/robot.jpg
  20. BIN
      src/assets/role.jpg
  21. BIN
      src/assets/role1.jpg
  22. BIN
      src/assets/role2.jpg
  23. BIN
      src/assets/role3.jpg
  24. 1 0
      src/assets/vue.svg
  25. 43 0
      src/components/HelloWorld.vue
  26. 51 0
      src/components/NavBarPage.vue
  27. 36 0
      src/hooks/useScroll.js
  28. 20 0
      src/main.js
  29. 66 0
      src/router/index.js
  30. 0 0
      src/style.css
  31. 683 0
      src/views/ChatTts.vue
  32. 281 0
      src/views/DetailView.vue
  33. 255 0
      src/views/HomeView.vue
  34. 301 0
      src/views/ResultView.vue
  35. 48 0
      src/views/components/BG.vue
  36. 353 0
      src/views/components/BottomArea.vue
  37. 126 0
      src/views/components/ChatList.vue
  38. 105 0
      src/views/demo/Asr.vue
  39. 457 0
      src/views/demo/AsrDemo.vue
  40. 55 0
      src/views/demo/Home.vue
  41. 51 0
      src/views/demo/Pic.vue
  42. 53 0
      src/views/demo/Sse.vue
  43. 151 0
      src/views/demo/Steam.vue
  44. 57 0
      src/views/demo/Tts.vue
  45. 92 0
      src/views/result/randarPage.vue
  46. 133 0
      src/views/result/selectPage.vue
  47. 25 0
      src/views/utils/api.js
  48. 46 0
      src/views/utils/request.js
  49. 47 0
      vite.config.js

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+node_modules/

+ 7 - 0
Dockerfile

@@ -0,0 +1,7 @@
+FROM nginx
+
+COPY ./default.conf /etc/nginx/conf.d/default.conf
+
+COPY ./dist /usr/share/nginx/html
+
+EXPOSE 80

+ 4 - 0
README.md

@@ -0,0 +1,4 @@
+# fls-asp-qr-intelligent-tutoring
+
+
+# 汽融市场

+ 66 - 0
default.conf

@@ -0,0 +1,66 @@
+server {
+    listen       80;
+    listen  [::]:80;
+    server_name  localhost;
+
+    resolver 8.8.8.8;
+
+    #access_log  /var/log/nginx/host.access.log  main;
+
+    location / {
+        root   /usr/share/nginx/html;
+        index  index.html index.htm;
+    }
+
+    set $stg_token "C1qziFGlIv3tnCQxcFaStrLuZOO2ZZXjN7FB_G0WlrOLjclfObbSaXAKzl4RWwQBf_0Zhsm0CoVvdVsYMD18iM_LJrxtn7LHJJQuF9UoUuF3fvqOwrG4EF6Z4GahtxtQ2oeaPQBBNKlgVW1xUW7tkhEdXWqzDHPA_I_91Lczk0PI4guhx1c88Hst4-HI8pdMbiUdEJzj3d3a2W06Fa0XA9Q0taAwaRd1k9jUrDVyj9GfS84_SIgJF4SPjWVfsraV79ieb_StgRcUwZjbscGPMlifnJD6F00wwNbxG7AuCHbl3EtMfSed1vuVx3AsizIckwzIVSVRpOGw72cdAMui-I6es9Ozj2ITzSa5KgyXEpX4qCHF1VcCM1wlHLQ_5hLnJIi4r8NsnJPsxMYrTw";
+
+    set $prd_token "2g4pgi27kWjPBjaKoHSfZAyfOJxuuEthWY1mk9tUbyPSrr6sn_6aPGaTxT7_ldXoKIJuMt8C7V636_jIq6fzaSfqIj8OQyhUPKPMa2eZjLlblT77ySqBt_lYM6iEAhrj7-raGmySMmkLS4Rqh651Ak2tqmUbjS64cqv5ofMsuadOCg1J-CtLFt7NeSoU4N3Kpm5MJ_4sOFBhQGfBym88dcwxosFl9LbvhpyleXFf6fOZkkOj0l2X8Nr2pfNjYs3_VOmCQxrxXh1XZ_a1v9qj5_rA9k9wGNNQfmr2JwJTUT4V9NwtNq94gNFt8C0J6MWKRHvYyb2XRkaCnEKu8QiNElcIwU9l9azouu5Lc3uafG1dd98yFm9I784oNWvIpVjUrsTkFiJxNGTz";
+    
+    location /openapi-stg/ {
+        rewrite ^/openapi-stg/(.*)$ /$1 break;
+        proxy_pass https://fls-ai-stg-sit.pingan.com.cn/openapi$uri?token=$stg_token&channelId=ASP-TEST&sceneId=ASP-TEST&$args;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    }
+
+    location /openapi-prd/ {
+        rewrite ^/openapi-prd/(.*)$ /$1 break;
+        proxy_pass https://fls-ai.pingan.com.cn/openapi$uri?token=$prd_token&channelId=ASP-TEST&sceneId=ASP-TEST&$args;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    }
+
+    #error_page  404              /404.html;
+
+    # redirect server error pages to the static page /50x.html
+    #
+    error_page   500 502 503 504  /50x.html;
+    location = /50x.html {
+        root   /usr/share/nginx/html;
+    }
+
+    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
+    #
+    #location ~ \.php$ {
+    #    proxy_pass   http://127.0.0.1;
+    #}
+
+    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
+    #
+    #location ~ \.php$ {
+    #    root           html;
+    #    fastcgi_pass   127.0.0.1:9000;
+    #    fastcgi_index  index.php;
+    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
+    #    include        fastcgi_params;
+    #}
+
+    # deny access to .htaccess files, if Apache's document root
+    # concurs with nginx's one
+    #
+    #location ~ /\.ht {
+    #    deny  all;
+    #}
+}

+ 7 - 0
depoly.sh

@@ -0,0 +1,7 @@
+#/bin/bash
+
+npm run build
+
+docker build -t registry.chuckchen.top/test:video-test .
+
+docker push registry.chuckchen.top/test:video-test

+ 17 - 0
index.html

@@ -0,0 +1,17 @@
+<!doctype html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8" />
+  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+  <meta name="viewport"
+    content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no viewport-fit=cover" />
+  <title>Vite + Vue</title>
+</head>
+
+<body>
+  <div id="app"></div>
+  <script type="module" src="/src/main.js"></script>
+</body>
+
+</html>

+ 31 - 0
package.json

@@ -0,0 +1,31 @@
+{
+  "name": "video-test",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@microsoft/fetch-event-source": "^2.0.1",
+    "axios": "^1.7.9",
+    "delay": "^6.0.0",
+    "echarts": "^5.5.1",
+    "fs": "^0.0.1-security",
+    "less": "^4.2.1",
+    "p-queue": "^8.0.1",
+    "recorder-core": "^1.3.24102001",
+    "vant": "^4.9.10",
+    "vconsole": "^3.15.1",
+    "vue": "^3.5.13",
+    "vue-router": "^4.0.13"
+  },
+  "devDependencies": {
+    "@vicons/material": "^0.13.0",
+    "@vicons/utils": "^0.1.4",
+    "@vitejs/plugin-vue": "^5.2.1",
+    "vite": "^6.0.1"
+  }
+}

BIN
public/image0.jpg


BIN
public/image0.webp


BIN
public/image1.webp


BIN
public/image2.webp


+ 1 - 0
public/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 9 - 0
src/App.vue

@@ -0,0 +1,9 @@
+<template>
+  <router-view></router-view>
+</template>
+<style>
+p{
+  margin: 0;
+  padding: 0;
+}
+</style>

BIN
src/assets/banner1.jpg


BIN
src/assets/banner2.jpg


BIN
src/assets/banner3.jpg


BIN
src/assets/banner4.jpg


BIN
src/assets/head.jpg


BIN
src/assets/robot.jpg


BIN
src/assets/role.jpg


BIN
src/assets/role1.jpg


BIN
src/assets/role2.jpg


BIN
src/assets/role3.jpg


+ 1 - 0
src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 43 - 0
src/components/HelloWorld.vue

@@ -0,0 +1,43 @@
+<script setup>
+import { ref } from 'vue'
+
+defineProps({
+  msg: String,
+})
+
+const count = ref(0)
+</script>
+
+<template>
+  <h1>{{ msg }}</h1>
+
+  <div class="card">
+    <button type="button" @click="count++">count is {{ count }}</button>
+    <p>
+      Edit
+      <code>components/HelloWorld.vue</code> to test HMR
+    </p>
+  </div>
+
+  <p>
+    Check out
+    <a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
+      >create-vue</a
+    >, the official Vue + Vite starter
+  </p>
+  <p>
+    Learn more about IDE Support for Vue in the
+    <a
+      href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
+      target="_blank"
+      >Vue Docs Scaling up Guide</a
+    >.
+  </p>
+  <p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
+</template>
+
+<style scoped>
+.read-the-docs {
+  color: #888;
+}
+</style>

+ 51 - 0
src/components/NavBarPage.vue

@@ -0,0 +1,51 @@
+<script setup>
+import { ref, reactive, toRefs, onMounted} from 'vue'
+const onClickLeft = () => {
+  if(props.type=='other'){
+    history.back();
+  }else{
+    window.location.href = '/';
+  }
+  
+}
+const props = defineProps({
+  title: {
+    type: String,
+    default: '标题'
+  },
+  type:{
+    type: String,
+    default:'other'
+  }
+})
+</script>
+<template>
+  <div>
+    <van-nav-bar :title="title" left-arrow @click-left="onClickLeft" class="custom-navbar" />
+  </div>
+</template>
+
+<style scoped>
+
+/* 自定义导航栏样式 */
+.custom-navbar {
+    background-color: #fff;
+    color: black;
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    z-index: 999;
+}
+
+/* 更改左侧箭头颜色 */
+.custom-navbar :deep(.van-nav-bar__arrow) {
+    color: black;
+}
+
+.custom-navbar::before,
+.custom-navbar::after {
+    display: none !important;
+    /* 直接隐藏伪元素 */
+}
+</style>

+ 36 - 0
src/hooks/useScroll.js

@@ -0,0 +1,36 @@
+import { nextTick } from 'vue'
+
+function useScroll() {
+  let scrollRef = null
+
+  const scrollToBottom = async () => {
+    await nextTick()
+    if (scrollRef)
+      scrollRef.scrollTop = scrollRef.scrollHeight
+  }
+
+  const scrollToTop = async () => {
+    await nextTick()
+    if (scrollRef)
+      scrollRef.scrollTop = 0
+  }
+
+  const scrollToBottomIfAtBottom = async () => {
+    await nextTick()
+    if (scrollRef) {
+      const threshold = 100 // 阈值,表示滚动条到底部的距离阈值
+      const distanceToBottom = scrollRef.scrollHeight - scrollRef.scrollTop - scrollRef.clientHeight
+      if (distanceToBottom <= threshold)
+        scrollRef.scrollTop = scrollRef.scrollHeight
+    }
+  }
+
+  return {
+    scrollRef,
+    scrollToBottom,
+    scrollToTop,
+    scrollToBottomIfAtBottom,
+  }
+}
+
+export default useScroll

+ 20 - 0
src/main.js

@@ -0,0 +1,20 @@
+import { createApp } from 'vue'
+import './style.css'
+import App from './App.vue'
+import router from './router';
+import Vant from 'vant';
+import VConsole from 'vconsole'
+// 2. 引入组件样式
+import 'vant/lib/index.css';
+
+const app = createApp(App);
+
+// 初始化 VConsole
+const vConsole = new VConsole()
+
+// 将封装后的 Vue Router 注入应用
+app.use(router);
+
+app.use(Vant);
+
+app.mount('#app');

+ 66 - 0
src/router/index.js

@@ -0,0 +1,66 @@
+import { createRouter, createWebHashHistory } from 'vue-router';
+
+const routes = [
+  // 定义你的路由规则
+  {
+    path: '/',
+    redirect: '/home',
+  },
+  {
+    path: '/demo',
+    name: 'DemoHome',
+    component: () => import('../views/demo/Home.vue'),
+  },
+  {
+    path: '/home',
+    name: 'home',
+    component: () => import('../views/HomeView.vue'),
+  },
+  {
+    path:'/detail',
+    name:'detail',
+    component:()=>import('../views/DetailView.vue')
+  },
+  {
+    path:'/result',
+    name:'result',
+    component:()=>import('../views/ResultView.vue')
+  },
+  {
+    path: '/demo/pic',
+    name: 'DemoPic',
+    component: () => import('../views/demo/Pic.vue'),
+  },
+  {
+    path: '/demo/asr',
+    name: 'DemoAsr',
+    component: () => import('../views/demo/Asr.vue'),
+  },
+  {
+    path: '/demo/asrDemo',
+    name: 'DemoAsrDemo',
+    component: () => import('../views/demo/AsrDemo.vue'),
+  },
+  {
+    path: '/demo/tts',
+    name: 'DemoTts',
+    component: () => import('../views/demo/Tts.vue'),
+  },
+  {
+    path: '/demo/steam',
+    name: 'DemoSteam',
+    component: () => import('../views/demo/Steam.vue'),
+  },
+  {
+    path: '/chatTts',
+    name: 'ChatTts',
+    component: () => import('../views/ChatTts.vue'),
+  }
+];
+
+const router = createRouter({
+  history: createWebHashHistory(),
+  routes,
+});
+
+export default router;

+ 0 - 0
src/style.css


+ 683 - 0
src/views/ChatTts.vue

@@ -0,0 +1,683 @@
+<script setup>
+import { ref, computed, onMounted, onBeforeUnmount } from "vue";
+import BG from "./components/BG.vue";
+import BottomArea from "./components/BottomArea.vue";
+import ChatList from "./components/ChatList.vue";
+import NavBarPage from "../components/NavBarPage.vue";
+import PQueue from "p-queue";
+import axios from "axios";
+import { fetchEventSource } from "@microsoft/fetch-event-source";
+import { useRoute, useRouter } from "vue-router";
+import delay from "delay";
+import { fetchTaskStatus } from "./utils/api";
+
+const route = useRoute();
+const router = useRouter();
+const conversationId = computed(() => route.query.taskId); // 会话ID
+const round = computed(() => route.query.round); // 轮次
+const sessionId = ref("");
+const taskStatus = ref(false)
+const loading = ref(true);  //初始化进入的加载状态
+
+const currentRate = ref(0);
+const currentRound = computed(() => {
+  const filteredMessages = chatList.value.filter(
+    (message) =>
+      message.text !== "我没有听清,麻烦在重复一下。" && message.text !== ""
+  );
+  return Math.floor(filteredMessages.length / 2);
+});
+// 当前进度百分比整数部分
+const rate = computed(() => {
+  return Math.floor((currentRound.value / round.value) * 100);
+});
+
+const text = computed(() => `${currentRound.value} / ${round.value}`);
+
+// 对话记录
+const chatList = ref([]);
+let i = 0;
+// 我 我是 我是中国人 我是中国人的英雄 我是中国人的英雄。[
+const updateChatList = async (message = "", loading = true) => {
+  const timer = setInterval(() => {
+    i = i + 1;
+    chatList.value = chatList.value.map((item, index) => {
+      if (index == chatList.value.length - 1) {
+        return {
+          ...item,
+          text: message.substring(0, i),
+        };
+      } else {
+        return item;
+      }
+    });
+    localStorage.setItem("chatHistory", JSON.stringify(chatList.value));
+    if (i >= message.length) {
+      clearInterval(timer);
+    }
+  }, 50);
+};
+
+// 创建一个队列实例,设置并发数为 1
+const queue = new PQueue({ concurrency: 3 });
+const queue1 = new PQueue({ concurrency: 1 });
+const queue2 = new PQueue({ concurrency: 1 });
+
+// 需要tts的文本队列
+const messageQueue = [];
+
+// lastStrIndex 用于记录上一个字符串的结束位置
+let lastStrIndex = 0;
+
+// 将字符串根据标点符号断句分割,并添加到messageQueue中
+const splitMessage = async (str) => {
+  const punctuation = ["。","," ,"!", "?", ";", ":"];
+
+  for (let i = lastStrIndex; i < str.length; i++) {
+    if (punctuation.includes(str[i])) {
+      const message = str.slice(lastStrIndex, i + 1);
+      console.log(message, "==========");
+      playTTS(message);
+      lastStrIndex = i + 1; // 更新上一个字符串的结束位置
+    }
+  }
+};
+
+const handleStartRecord = async () => {
+  queue.clear();
+  queue1.clear();
+};
+
+const generateSessionId = () => {
+  // 获取当前时间戳
+  const timestamp = Date.now().toString();
+  // 生成一个随机数
+  const randomNum = Math.floor(Math.random() * 10000);
+  // 将时间戳和随机数拼接成会话ID
+  const sessionId = `${timestamp}${randomNum}`;
+  return sessionId;
+};
+
+// 发送消息
+const handleStopRecord = (message) => {
+  lastStrIndex = 0; // 重置
+  chatList.value = [
+    ...chatList.value,
+    {
+      id: Date.now(),
+      type: "user",
+      text: message,
+      loading: false,
+    },
+    {
+      id: Date.now(),
+      type: "gpt",
+      text: "",
+      loading: true,
+    },
+  ];
+  sessionId.value = generateSessionId();
+  chatGpt(message);
+};
+// let audioQueue = [];  // 音频队列,保存按请求顺序的音频数据
+// let playingQueue = []; // 用来记录当前正在播放的音频序号
+// let isPlaying = false;  // 当前是否正在播放
+// let orderId = 0;  // 请求顺序号
+
+// 用来控制正在播放音频的锁定机制
+let playingLock = false;  // 标识是否正在播放
+
+
+let audioQueue = [];  // 音频队列,保存按请求顺序的音频数据
+let playingQueue = []; // 记录已播放的音频序号
+let isPlaying = false;  // 当前是否正在播放
+let orderId = 0;  // 请求顺序号
+
+// 1. 发起 TTS 请求(修改后)
+const playTTS = async (ttsMessage) => {
+  queue.add(async () => {
+    const currentOrderId = orderId++;  // 先获取顺序号再发送请求
+
+    try {
+      const res = await axios.get(
+        `/openapi-prd/ai/ttt/synthesize?text=${ttsMessage}&sessionId=${sessionId.value}`,
+        { responseType: "arraybuffer" }
+      );
+      
+      // 即使请求失败也要保持队列连续性(新增部分)
+      const audioItem = {
+        orderId: currentOrderId,
+        audioData: res.data || null,
+        status: res.data ? 'valid' : 'invalid'
+      };
+      
+      audioQueue.push(audioItem);
+      audioQueue.sort((a, b) => a.orderId - b.orderId);  // 保持队列有序
+
+      if (!isPlaying) await playNextAudio();
+    } catch (error) {
+      console.error("调用 TTS API 时出错:", error);
+      // 请求失败时插入占位符保持顺序(关键修复)
+      audioQueue.push({ 
+        orderId: currentOrderId,
+        audioData: null,
+        status: 'invalid'
+      });
+      audioQueue.sort((a, b) => a.orderId - b.orderId);
+      if (!isPlaying) await playNextAudio();
+    }
+  });
+};
+
+// 2. 播放下一个音频(修改后)
+const playNextAudio = async () => {
+  // if (audioQueue.length === 0) {
+  //   isPlaying = false;
+  //   console.log("所有音频播放完毕.");
+  //   return;
+  // }
+  if (audioQueue.length === 0 && playingQueue.length > 0) {
+  const maxPlayed = Math.max(...playingQueue);
+  if (orderId > maxPlayed + 1) {
+    console.log("仍有未完成请求,暂不重置");
+  } else {
+    isPlaying = false;
+    // 新增:当队列完全播放完毕时重置所有状态
+    audioQueue = [];
+    playingQueue = [];
+    orderId = 0;  // 可选:如果需要重置顺序号
+    console.log("所有音频播放完毕,已重置播放队列.");
+    return;
+  }
+  }
+
+  const nextAudio = getNextAudioToPlay();
+  if (!nextAudio) {
+    console.log("等待后续音频数据...");
+    return;
+  }
+
+  try {
+    isPlaying = true;
+    console.log(`正在播放音频,序号:${nextAudio.orderId}`);
+
+    if (nextAudio.status === 'valid') {
+      await playAudio(nextAudio.audioData);
+    } else {
+      console.warn(`跳过无效音频,序号:${nextAudio.orderId}`);
+    }
+
+    // 更新播放状态(优化处理)
+    playingQueue = [...new Set([...playingQueue, nextAudio.orderId])].sort((a, b) => a - b);
+    audioQueue = audioQueue.filter(audio => audio.orderId !== nextAudio.orderId);
+
+    // 立即尝试播放下一个(优化播放流程)
+    isPlaying = false;
+    await playNextAudio();
+  } catch (error) {
+    console.error(`播放失败,序号:${nextAudio.orderId}`, error);
+    audioQueue = audioQueue.filter(audio => audio.orderId !== nextAudio.orderId);
+    isPlaying = false;
+    await playNextAudio();
+  }
+};
+
+// 获取下一个应该播放的音频(修改后)
+const getNextAudioToPlay = () => {
+  // 获取当前应该播放的最小序号
+  const expectedOrderId = playingQueue.length > 0 
+    ? Math.max(...playingQueue) + 1 
+    : 0;
+
+  // 查找第一个匹配的有效音频(新增有效性检查)
+  const nextAudio = audioQueue.find(audio => 
+    audio.orderId === expectedOrderId &&
+    audio.status === 'valid'
+  );
+
+  // 如果找不到有效音频但有序号匹配的无效项,跳过该序号
+  if (!nextAudio) {
+    const invalidAudio = audioQueue.find(audio => 
+      audio.orderId === expectedOrderId
+    );
+    
+    if (invalidAudio) {
+      console.warn(`检测到无效音频,自动跳过序号:${expectedOrderId}`);
+      playingQueue.push(expectedOrderId);
+      return getNextAudioToPlay(); // 递归处理
+    }
+  }
+
+  return nextAudio || null;
+};
+// 1. 发起 TTS 请求
+
+// const playTTS = async (ttsMessage) => {
+//   queue.add(async () => {
+//     const currentOrderId = orderId++;
+
+//     try {
+//       const res = await axios.get(
+//         `/openapi-prd/ai/ttt/synthesize?text=${ttsMessage}&sessionId=${sessionId.value}`,
+//         { responseType: "arraybuffer" }
+//       );
+//       if (!res.data || res.data.byteLength === 0) {
+//         console.error("音频数据无效,无法播放.");
+//         return;
+//       }
+//       // 按顺序存入音频队列
+//       audioQueue.push({ orderId: currentOrderId, audioData: res.data });
+
+//       // 仅在没有播放时启动播放
+//       if (!isPlaying) {
+//         await playNextAudio();
+//       }
+//     } catch (error) {
+//       console.error("调用 TTS API 时出错:", error);
+//     }
+//   });
+// };
+// 2. 播放下一个音频
+// const playNextAudio = async () => {
+//   // 如果播放队列为空,停止播放
+//   if (audioQueue.length === 0) {
+//     isPlaying = false;  // 播放完所有音频
+//     // audioQueue = []; // 确保列表为空
+//     console.log("所有音频播放完毕.");
+//     return;
+//   }
+
+//   // 找到队列中最小序号的音频,确保顺序播放
+//   const nextAudio = getNextAudioToPlay();
+//   console.log(nextAudio,"没有找到下一个应该播放的音频,队列尚未按顺序准备好.");
+
+//   // 如果找不到下一个应该播放的音频,表示队列还没有按顺序准备好
+//   if (!nextAudio) {
+//     console.log("没有找到下一个应该播放的音频,队列尚未按顺序准备好.");
+//     console.log("当前音频队列:", audioQueue);  // 打印当前音频队列
+//     console.log("当前已播放的序列:", playingQueue);  // 打印已经播放的音频序列
+//     return;
+//   }
+
+//   // 打印正在播放的音频序号
+//   console.log(`正在播放音频,序号:${nextAudio.orderId}`);
+
+//   try {
+//     isPlaying = true;  // 标记当前正在播放
+//     playingLock = true;  // 锁定播放,避免新的音频插入
+
+//     // 播放当前音频
+//     await playAudio(nextAudio.audioData);
+
+//     // 播放完成后,移除已播放的音频
+//     audioQueue = audioQueue.filter(audio => audio.orderId !== nextAudio.orderId);
+//     playingQueue.push(nextAudio.orderId);  // 将已播放的序号添加到播放记录
+
+//     // 输出音频播放完成的日志
+//     console.log(`音频序号 ${nextAudio.orderId} 播放完成,等待播放下一个包...`);
+
+//     // 解除锁定,等待下一个音频播放
+//     playingLock = false;
+
+//     // 使用定时器周期性检查是否可以播放下一个音频
+//     const checkNextAudioTimer = setInterval(async () => {
+//       if (!playingLock) {
+//         await playNextAudio();
+//         clearInterval(checkNextAudioTimer);  // 清除定时器
+//       }
+//     }, 300); // 每秒检查一次
+//   } catch (error) {
+//     console.error(`播放音频序号 ${nextAudio.orderId} 时出错:`, error);
+//     // 如果某个音频播放失败,跳过这个音频,继续播放下一个
+//     audioQueue = audioQueue.filter(audio => audio.orderId !== nextAudio.orderId);  // 移除报错的音频
+//     playingQueue.push(nextAudio.orderId);  // 将已播放的序号添加到播放记录
+//     playingLock = false;  // 解除锁定
+//     // 继续播放下一个音频
+//     await playNextAudio();
+//   }
+// };
+
+// 获取下一个应该播放的音频(保证顺序)
+// const getNextAudioToPlay = () => {
+//   // 检查音频队列中是否有下一个应该播放的音频
+//   console.log("检查下一个播放的音频...");
+//   for (let i = 0; i < audioQueue.length; i++) {
+//     const audio = audioQueue[i];
+//     // 找到最小的序号,即请求的顺序号
+//     if (audio.orderId === playingQueue.length) {
+//       console.log(`找到了下一个应该播放的音频,序号:${audio.orderId}`);
+//       return audio;
+//     }
+//   }
+//   console.log("没有找到下一个应该播放的音频,返回 null");
+//   return null;  // 如果没有找到匹配的音频,返回 null
+// };
+
+// 播放音频的方法
+const playAudio = (audioData) => {
+  return new Promise((resolve, reject) => {
+    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+    const blob = new Blob([audioData], { type: "audio/wav" });
+    const url = URL.createObjectURL(blob);
+    const audioBufferSourceNode = audioContext.createBufferSource();
+
+    fetch(url)
+        .then((response) => {
+          if (!response.ok) {
+            console.error("音频文件请求失败: ", response.statusText);
+            reject(new Error("音频文件请求失败"));
+            return;
+          }
+          return response.arrayBuffer();
+        })
+        .then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
+        .then((audioBuffer) => {
+          audioBufferSourceNode.buffer = audioBuffer;
+          audioBufferSourceNode.connect(audioContext.destination);
+
+          audioBufferSourceNode.start(0);
+
+          // 当音频播放完毕时,调用 resolve
+          audioBufferSourceNode.onended = () => {
+            URL.revokeObjectURL(url);
+            resolve();  // 播放完成后,继续播放下一个
+          };
+
+          audioBufferSourceNode.onerror = (error) => {
+            console.error("音频播放错误:", error);
+            URL.revokeObjectURL(url);
+            reject(error);  // 播放失败时,返回错误
+          };
+        })
+        .catch((error) => {
+          console.error("音频加载或解码时出错:", error);
+          reject(error);  // 如果解码或播放出错,reject
+        });
+  });
+};
+// const playTTS1 = async (ttsMessage) => {
+//   try {
+//     const res = await axios.post(
+//       `/openapi-stg/ai/voice/tts/v2`,
+//       { sessionId: "N7FB_G0WlrOLjc", text: ttsMessage },
+//       {
+//         responseType: "arraybuffer",
+//         headers: {
+//           "X-Ai-TTS-Appid": "2b1317fb5b284b308dc90a6fdeae6c4e",
+//         },
+//       }
+//     );
+//     console.log(res.data);
+
+//     queue1.add(async () => {
+//       // 播放获取到的音频
+//       await playAudio(res.data);
+//     });
+//   } catch (error) {
+//     console.error("Error calling TTS API:", error);
+//   }
+// };
+
+// const playAudio1 = (audioData, options = {}) => {
+//   return new Promise((resolve, reject) => {
+//     const blob = new Blob([audioData], { type: "audio/wav" });
+//     const url = URL.createObjectURL(blob);
+//     const audio = new Audio(url);
+
+//     audio.setAttribute("id", "audio");
+//     audio.setAttribute("autoplay", "autoplay");
+
+//     if (options.volume) {
+//       audio.volume = options.volume; // 设置音量
+//     }
+
+//     audio.onended = () => {
+//       URL.revokeObjectURL(url);
+//       resolve();
+//     };
+
+//     audio.onerror = (error) => {
+//       URL.revokeObjectURL(url);
+//       reject(error);
+//     };
+
+//     audio
+//       .play()
+//       .then(() => console.log("Audio playing"))
+//       .catch(reject);
+//   });
+// };
+
+const chatGpt = async (userMessage) => {
+  const downloadUrl = "/openapi-prd/ai/intelligent-tutoring/task/dialogue";
+  i = 0;
+  queue2.add(() => delay(3000));
+  try {
+    const response = await axios.post(
+      downloadUrl,
+      {
+        conversationId: conversationId.value,
+        content: userMessage,
+      },
+      {
+        headers: {
+          "Content-Type": "application/json",
+        },
+        onDownloadProgress: ({ event }) => {
+          const xhr = event.target;
+          let { responseText } = xhr;
+          console.log("responseText", responseText);
+          if (responseText.includes("code") && responseText.includes("400")) {
+            // console.log("responseTextcode", responseText);
+            // 更新聊天列表
+            const text = "我没有听清,麻烦在重复一下。";
+            updateChatList(text, false);
+            queue.add(async () => {
+              await splitMessage(text);
+            });
+          } else {
+            // 用于语音播报
+            queue.add(async () => {
+              await splitMessage(responseText);
+            });
+            
+            queue2.add(async () => await updateChatList(responseText));
+            
+            //updateChatList(responseText);
+          }
+        },
+      }
+    );
+    setTimeout(() => {
+         handleTaskStatus()
+     },3000)
+    //queue2.add(async () => await updateChatList("", false));
+  } catch (error) {
+    //updateChatList("", false);
+    console.error("出错:", error);
+  }
+};
+
+const chatGpt2 = (userMessage) => {
+  const params = {
+    conversationId: "2c64eb8b69be432ca0bb9ae55bc78def",
+    content: userMessage,
+  };
+  const ctrlAbout = new AbortController();
+  fetchEventSource(
+    "https://fls-ai.pingan.com.cn/openapi/ai/intelligent-tutoring/task/dialogue",
+    {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json", // 文本返回格式
+      },
+      body: JSON.stringify(params),
+      signal: ctrlAbout.signal,
+      openWhenHidden: true, // 在浏览器标签页隐藏时保持与服务器的EventSource连接
+      onmessage(res) {
+        // 操作流式数据
+        console.log(JSON.parse(res.data), "==========");
+      },
+      onclose(res) {
+        // 关闭流
+      },
+      onerror(error) {
+        // 返回流报错
+      },
+    }
+  );
+};
+
+const chatGpt1 = async (userMessage) => {
+  const downloadUrl =
+    "https://fls-ai.pingan.com.cn/openapi/ai/llm/forward/api/ai/nlp/dialogue";
+
+  try {
+    const response = await axios.post(
+      downloadUrl,
+      {
+        conversationId: "1976cfe3a5174f9ba768677f789cad7e",
+        content: `${userMessage},请输出纯文本的回答,不要使用markdown输出`,
+        messageId: Date.now(),
+        applicationId: "",
+      },
+      {
+        headers: {
+          "Team-Id": "123456",
+          Authorization: "9b2f86c99b5847739045e6b85f355301",
+          "Content-Type": "application/json",
+        },
+        onDownloadProgress: ({ event }) => {
+          const xhr = event.target;
+          const { responseText } = xhr;
+          console.log(responseText);
+          // 更新聊天列表
+          updateChatList(responseText);
+          // 用于语音播报
+          queue.add(async () => {
+            await splitMessage(responseText);
+          });
+        },
+      }
+    );
+    updateChatList("", false);
+  } catch (error) {
+    updateChatList("", false);
+    console.error("下载文件时出错:", error);
+  }
+};
+const next = () => {
+  router.push({
+    name: "result",
+    query: { conversationId: conversationId.value },
+  });
+};
+
+const loadChatHistory = () => {
+  
+  const chatHistory = localStorage.getItem("chatHistory");
+  const chatStatus = localStorage.getItem("status");
+  if (chatHistory) {
+    const history = JSON.parse(chatHistory);
+    history.forEach((item) => {
+      chatList.value.push(item);
+    });
+  }
+  if(chatStatus){
+    taskStatus.value = JSON.parse(chatStatus)
+  }
+};
+
+// 查询任务结束状态
+
+const handleTaskStatus = async () => {
+  const res =  await fetchTaskStatus(conversationId.value)
+  if(res.code == 200){
+    taskStatus.value = res.body
+    localStorage.setItem("status", res.body);
+  }
+}
+
+// 在组件挂载时调用
+onMounted(() => {
+  loadChatHistory();
+  setTimeout(() => {
+    loading.value = false;
+  }, 3000);
+});
+
+</script>
+
+<template>
+  <div class="main">
+    <nav-bar-page title="考试" />
+    <div class="rate-circle">
+      <van-circle
+        :stroke-width="80"
+        size="70px"
+        v-model:current-rate="currentRate"
+        :rate="rate"
+        :text="text"
+      />
+    </div>
+    <BG />
+    <div class="loading" v-show="loading">
+      <van-loading color="#0094ff" size="26px" vertical>加载中...</van-loading>
+    </div>
+    
+    <ChatList :chatList="chatList" />
+    <BottomArea
+      @startRecord="handleStartRecord"
+      @stopRecord="handleStopRecord"
+      v-show="rate < 100 || taskStatus"
+    />
+    <div v-show="rate >= 100 || taskStatus" class="next-btn">
+      <van-button style="width: 100%" @click="next" type="primary"
+        >已完成对话,下一步</van-button
+      >
+    </div>
+  </div>
+</template>
+<style>
+* {
+  -webkit-touch-callout: none; /*系统默认菜单被禁用*/
+  -webkit-user-select: none; /*webkit浏览器*/
+  -khtml-user-select: none; /*早期浏览器*/
+  -moz-user-select: none; /*火狐*/
+  -ms-user-select: none; /*IE10*/
+  user-select: none;
+}
+</style>
+
+<style scoped>
+.main {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+}
+.rate-circle {
+  position: fixed;
+  right: 20px;
+  top: 70px;
+  z-index: 9999;
+}
+.next-btn {
+  position: fixed;
+  bottom: calc(20px + env(safe-area-inset-bottom));
+  width: 100%;
+  box-sizing: border-box;
+  padding: 0 20px;
+}
+.loading {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 9999;
+}
+</style>

+ 281 - 0
src/views/DetailView.vue

@@ -0,0 +1,281 @@
+<script setup>
+import { ref, reactive, toRefs, onMounted } from "vue";
+import NavBarPage from "../components/NavBarPage.vue";
+import selectPage from "./result/selectPage.vue";
+import { fetchTaskCreate } from "./utils/api";
+import { showToast } from "vant";
+import { useRouter } from "vue-router";
+
+const router = useRouter();
+
+const selectedRole = ref("");
+const selectedNature = ref(null);
+const selectedRounds = ref(0);
+
+const roleOptions = [
+  { text: "25岁年轻客户", value: "25岁年轻客户" },
+];
+
+const roundsOptions = [
+  { text: 10, value: 10 },
+  { text: 15, value: 15},
+  { text: 20, value: 20 },
+];
+
+const naturesOptions = [
+  { text: "固执己见,不愿妥协", value: "固执己见,不愿妥协" },
+  { text: "细心周到,考虑周全", value: "细心周到,考虑周全" },
+  { text: "易怒暴躁,难以沟通", value: "易怒暴躁,难以沟通" },
+  { text: "幽默风趣,气氛活跃", value: "幽默风趣,气氛活跃" },
+  { text: "自私冷漠,缺乏同理", value: "自私冷漠,缺乏同理" },
+];
+
+const handleSelectNature = (option) => {
+  selectedNature.value = option.value;
+};
+
+const handleSelectRoles = (option) => {
+  selectedRole.value = option.value;
+};
+
+const handleSelectRounds = (option) => {
+  selectedRounds.value = option.value;
+};
+
+const handleExam = async () => {
+  if (!selectedRole.value || !selectedNature.value || !selectedRounds.value) {
+    showToast("请选择角色、性格和轮数");
+    return;
+  }
+  const data = {
+    tutoringRole: selectedRole.value,
+    personality: selectedNature.value,
+    round: selectedRounds.value,
+    sceneType:"QIRONG_SHICHANG",
+  };
+  try {
+    const { body } = await fetchTaskCreate(data);
+    router.push({
+      name: "ChatTts",
+      query: { taskId: body, round: selectedRounds.value },
+    });
+  } catch (error) {
+    console.log(error);
+  }
+};
+onMounted(() => {
+  localStorage.removeItem("chatHistory");
+  localStorage.removeItem("status");
+});
+</script>
+<template>
+  <div class="detail">
+    <nav-bar-page title="课程详情" />
+    <div style="height: 46px"></div>
+    <div class="content">
+      <div class="banner">
+        <img src="../assets/role3.jpg" alt="" />
+      </div>
+      <div class="produce">
+        <p>电话销售</p>
+        <div class="p_content">
+          <span class="bg">背景:</span>
+          <span>
+            客户通过抖音直播了解到我们是一家售卖新车的车商,此前客户只知道购买新车需要到对应的4S店内去选车购车,对于我们二网汽贸店是什么样的概念并不清楚,但是听到直播间内的车型车价介绍后依然十分感兴趣,因此选择在直播间内留资。我们的销售仅仅获取到了客户的联系方式,对于客户的用车周期,目标车型,以及购车方式等都不了解。接下来需要在前期的电话及微信等渠道与客户沟通,在互动中解答客户的疑问,建立基本信任并发掘客户真实的购车需求。
+          </span>
+        </div>
+      </div>
+      <div class="rules">
+        <div class="title">
+          <van-icon
+            name="records-o"
+            size="1.5rem"
+            color="rgba(64,149,229,1)"
+            style="font-weight: bold"
+          />
+          <span>规则</span>
+        </div>
+        <div class="r_content">
+          <div class="r_item">
+            <label for="roles">陪练角色</label>
+            <selectPage
+              :optionValue="roleOptions"
+              placeholder="请选择角色"
+              @update:selectedOption="handleSelectRoles"
+            />
+          </div>
+          <div class="r_item">
+            <label for="nature">陪练性格</label>
+            <selectPage
+              :optionValue="naturesOptions"
+              placeholder="请选择性格"
+              @update:selectedOption="handleSelectNature"
+            />
+          </div>
+          <div class="r_item">
+            <label for="rounds">交互轮次</label>
+            <selectPage
+              :optionValue="roundsOptions"
+              placeholder="请选择轮次"
+              @update:selectedOption="handleSelectRounds"
+            />
+          </div>
+        </div>
+      </div>
+      <div class="bottom">
+        <button class="exam" @click="handleExam">考试</button>
+        <button>练习</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less">
+.detail {
+  width: 100%;
+  height: 100%;
+  // overflow: hidden;
+
+  /* background-color: #f5f5f5; */
+  .content {
+    padding: 0 1.88rem;
+  }
+
+  .produce {
+    margin-top: 1.63rem;
+
+    p {
+      color: rgba(16, 16, 16, 1);
+      font-size: 1rem;
+      font-family: Times New Roman-regular;
+    }
+
+    .p_content {
+      width: 100%;
+      height: 8rem;
+      overflow: scroll;
+    }
+
+    span {
+      line-height: 1.25rem;
+      color: rgba(102, 102, 102, 1);
+      font-size: 0.88rem;
+      text-align: left;
+      font-family: SourceHanSansSC-bold;
+    }
+
+    .bg {
+      font-weight: 600;
+      color: #000;
+    }
+  }
+
+  .rules {
+    margin: 2.38rem 0;
+    width: 100%;
+    height: 13.69rem;
+    line-height: 1.25rem;
+    border-radius: 0.75rem;
+    background-color: rgba(255, 255, 255, 1);
+    color: rgba(16, 16, 16, 1);
+    font-size: 0.88rem;
+    text-align: center;
+    box-shadow: 0rem 0.06rem 0.88rem 0rem rgba(0, 1, 130, 0.08);
+    font-family: -regular;
+
+    .title {
+      display: flex;
+      align-items: center;
+      padding: 1.03rem 0.88rem;
+    }
+
+    .title span {
+      margin-left: 0.31rem;
+      color: rgba(64, 149, 229, 1);
+      font-size: 1rem;
+      font-family: Times New Roman-regular;
+    }
+
+    .r_content {
+      margin-top: 0.44rem;
+    }
+
+    .r_item {
+      display: flex;
+      // justify-content: space-between;
+      align-items: center;
+      padding: 0 1.75rem;
+      margin-bottom: 0.75rem;
+      font-size: 0.88rem;
+      color: rgba(16, 16, 16, 1);
+    }
+
+    .r_item select {
+      width: 11.56rem;
+      height: 2rem;
+      line-height: 1.25rem;
+      border-radius: 0.38rem;
+      font-family: PingFangSC-regular;
+      border: 0.06rem solid rgba(187, 187, 187, 1);
+      padding: 0 10px;
+      -webkit-appearance: none;
+      -moz-appearance: none;
+      appearance: none;
+      background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20fill%3D%22%23000%22%20fill-opacity%3D%22.9%22%20fill-rule%3D%22evenodd%22%20d%3D%22M17.35%209.65a.5.5%200%200%200-.7%200L12%2014.29%207.35%209.65a.5.5%200%201%200-.7.7l5%205c.2.2.5.2.7%200l5-5a.5.5%200%200%200%200-.7%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E")
+        no-repeat right 10px center;
+      background-size: 1.5rem 1.5rem;
+      /* 调整箭头的大小 */
+    }
+
+    .r_item label {
+      // width: 3.5rem;
+      // height: 1.25rem;
+      // line-height: 1.25rem;
+      // color: rgba(16,16,16,1);
+      // font-size: 0.88rem;
+      // text-align: left;
+      font-family: PingFangSC-medium;
+      margin-right: 1.1rem;
+    }
+  }
+
+  .bottom {
+    width: 100%;
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 1.38rem;
+
+    button {
+      width: 10rem;
+      height: 2.44rem;
+      line-height: 1.25rem;
+      border-radius: 0.38rem 0.38rem 0.38rem 0.38rem;
+      background-color: rgba(243, 249, 255, 1);
+      font-size: 0.88rem;
+      text-align: center;
+      font-family: -regular;
+      border: 0.06rem solid rgba(163, 208, 253, 1);
+      color: #1989fa;
+    }
+
+    .exam {
+      color: #fff;
+      background-color: rgba(64, 149, 229, 1);
+      margin-right: 0.63rem;
+    }
+  }
+}
+
+.banner {
+  height: 11.63rem;
+  border-radius: 0.75rem;
+  background-color: rgba(229, 229, 229, 1);
+  overflow: hidden;
+}
+
+.banner img {
+  width: 100%;
+  height: 11.63rem;
+  object-fit: cover;
+}
+</style>

+ 255 - 0
src/views/HomeView.vue

@@ -0,0 +1,255 @@
+<script setup>
+import { ref, onMounted } from "vue";
+import { useRouter } from "vue-router";
+import banner1 from "../assets/banner1.jpg";
+import banner2 from "../assets/banner2.jpg";
+import banner3 from "../assets/banner3.jpg";
+import banner4 from "../assets/banner4.jpg";
+const active = ref(0);
+const value = ref("");
+const images = ref([
+  { url: banner1 },
+  { url: banner2 },
+  { url: banner3 },
+  { url: banner4 },
+]);
+const router = useRouter();
+const handleDetail = () => {
+  router.push("/detail");
+};
+onMounted(() => {
+  localStorage.removeItem("chatHistory");
+  localStorage.removeItem("status");
+});
+</script>
+<template>
+  <div class="home">
+    <!-- 人物头像 -->
+    <div class="avatar">
+      <header>
+        <img src="../assets/head.jpg" alt="" />
+        <div class="head_content">
+          <span>Chris B</span>
+          <span>探索未知,发现自我~</span>
+        </div>
+      </header>
+      <div class="search">
+        <van-search v-model="value" placeholder="搜索" />
+      </div>
+      <div class="banner">
+        <van-swipe :autoplay="3000">
+          <van-swipe-item v-for="(image, index) in images" :key="index">
+            <div class="image-wrapper">
+              <img :src="image.url" alt="轮播图片" />
+              <div class="text-wrapper">
+                <div>模拟对话场景,提升业务能力</div>
+                <button>查看任务</button>
+              </div>
+            </div>
+          </van-swipe-item>
+        </van-swipe>
+      </div>
+      <div class="card_list">
+        <p>陪练课程</p>
+        <div class="card_content">
+          <div class="card_item" @click="handleDetail()">
+            <img src="../assets/role3.jpg" alt="" />
+            <p>电话销售</p>
+            <span>电话销售场景</span>
+          </div>
+          <div class="card_item">
+            <img src="../assets/role2.jpg" alt="" />
+            <p>电话销售(未开启)</p>
+            <span>电话销售场景</span>
+          </div>
+          <div class="card_item">
+            <img src="../assets/role1.jpg" alt="" />
+            <p>电话销售(未开启)</p>
+            <span>电话销售场景</span>
+          </div>
+          <div class="card_item">
+            <img src="../assets/role.jpg" alt="" />
+            <p>电话销售(未开启)</p>
+            <span>电话销售场景</span>
+          </div>
+        </div>
+      </div>
+    </div>
+    <!-- 底部tabbar -->
+    <div style="visibility: hidden; height: 50px"></div>
+    <van-tabbar v-model="active">
+      <van-tabbar-item name="home" icon="home-o">首页</van-tabbar-item>
+      <van-tabbar-item name="friends" icon="contact-o">我的</van-tabbar-item>
+    </van-tabbar>
+  </div>
+</template>
+
+<style scoped lang="less">
+p {
+  margin: 0;
+  padding: 0;
+}
+
+html {
+  touch-action: manipulation;
+  /* 禁止双指缩放 */
+}
+
+@media (max-width: 767px) {
+  html {
+    touch-action: pan-y;
+    /* 允许垂直滚动,禁止水平滚动和缩放 */
+  }
+}
+
+.avatar {
+  padding: 0 1.38rem;
+
+  header {
+    padding: 1.31rem 0;
+    display: flex;
+    align-items: center;
+
+    img {
+      width: 3.13rem;
+      height: 3.13rem;
+      border-radius: 6.25rem;
+      background-color: rgba(229, 229, 229, 1);
+      box-shadow: 0rem 0.5rem 1.5rem 0rem rgba(92, 117, 169, 0.4);
+      margin-right: 1.25rem;
+    }
+
+    .head_content {
+      display: flex;
+      flex-direction: column;
+
+      span {
+        line-height: 1.44rem;
+        color: rgba(16, 16, 16, 1);
+        font-size: 1rem;
+        text-align: left;
+        font-family: PingFangSC-medium;
+      }
+
+      span:nth-child(2) {
+        color: rgba(102, 102, 102, 0.7);
+        font-size: 0.88rem;
+        line-height: 1.25rem;
+      }
+    }
+  }
+
+  .search {
+    .van-search {
+      width: 100%;
+      height: 2.5rem;
+      padding: 0;
+    }
+
+    :deep(.van-search__content) {
+      border-radius: 0.5rem;
+    }
+  }
+
+  .banner {
+    margin: 1.25rem 0 1.25rem 0;
+  }
+
+  .image-wrapper {
+    position: relative;
+    width: 100%;
+    height: 8.75rem;
+    border-radius: 0.75rem;
+    overflow: hidden;
+
+    img {
+      width: 100%;
+      height: auto;
+      object-fit: cover;
+    }
+
+    .text-wrapper {
+      position: absolute;
+      left: 0.88rem;
+      top: 2.19rem;
+      line-height: 1.56rem;
+      color: rgba(255, 255, 255, 1);
+      font-size: 1.13rem;
+      text-align: left;
+      font-family: Times New Roman-regular;
+
+      button {
+        margin-top: 1.44rem;
+        width: 5.25rem;
+        height: 1.75rem;
+        border: 1px solid rgba(255, 255, 255, 1);
+        line-height: 1.06rem;
+        border-radius: 0.38rem;
+        background-color: rgba(255, 255, 255, 1);
+        color: rgba(9, 52, 74, 1);
+        font-size: 0.75rem;
+        text-align: center;
+        font-family: -regular;
+      }
+    }
+  }
+
+  .card_list {
+    p {
+      line-height: 1.44rem;
+      color: rgba(16, 16, 16, 1);
+      font-size: 1rem;
+      text-align: left;
+      font-family: Times New Roman-regular;
+      margin-bottom: 0.38rem;
+    }
+
+    .card_content {
+      display: flex;
+      flex-wrap: wrap;
+      justify-content: space-between;
+      /* margin-top: 1.25rem; */
+    }
+
+    .card_item {
+      width: 47%;
+      height: 13.75rem;
+      background-color: rgba(255, 255, 255, 1);
+      box-shadow: 0rem 0.06rem 0.88rem 0rem rgba(0, 1, 130, 0.08);
+      border-radius: 0.75rem;
+      overflow: hidden;
+      margin-bottom: 1rem;
+
+      img {
+        width: 100%;
+        height: 9.75rem;
+        object-fit: cover;
+      }
+
+      p {
+        margin: 0.63rem 0 0 0.63rem;
+        line-height: 1.25rem;
+        color: rgba(16, 16, 16, 1);
+        font-size: 0.88rem;
+        text-align: left;
+        font-family: PingFangSC-medium;
+      }
+
+      span {
+        line-height: 1.06rem;
+        color: rgba(102, 102, 102, 1);
+        font-size: 0.75rem;
+        text-align: left;
+        font-family: PingFangSC-regular;
+        margin-left: 0.63rem;
+      }
+    }
+    // @media (min-width: 600px) {
+    //   .card {
+    //     width: 50%;
+    //     height: 400px;
+    //   }
+    // }
+  }
+}
+</style>

+ 301 - 0
src/views/ResultView.vue

@@ -0,0 +1,301 @@
+<script setup lang="ts">
+import {
+  ref,
+  reactive,
+  toRefs,
+  onMounted,
+  watch,
+  computed,
+  onBeforeUnmount,
+} from "vue";
+import NavBarPage from "../components/NavBarPage.vue";
+import randarPage from "./result/randarPage.vue";
+import { useRoute } from "vue-router";
+import { fetchTaskScore } from "./utils/api";
+import { Toast } from "vant";
+
+const route = useRoute();
+
+const score = ref(0);
+const data = ref(null);
+const scoresList = ref(null);
+const defectsList = ref(null);
+const taskId = computed(() => route.query.conversationId); // 会话ID "2c64eb8b69be432ca0bb9ae55bc78def"
+const randarlist = ref([0, 0, 0, 0]);
+const loading = ref(false);
+
+const nameMapping = {
+  introduction_profession: "自我介绍专业性",
+  basic_information: "客户画像采集",
+  purchase_intent: "需求挖掘能力",
+  invitation_results: "沟通转化效果",
+  penalty_points: "扣分项",
+  defects: "评分详解"
+};
+
+const handleResult = async () => {
+  let res;
+  let shouldContinue = true; // 控制是否继续调用接口的标志
+  loading.value = true;
+
+  while (shouldContinue) {
+    res = await fetchTaskScore(taskId.value);
+
+    if (res.code !== 200) {
+      // 当返回错误时,停止调用接口
+      loading.value = false
+      shouldContinue = false;
+    } else if (res.body && Object.keys(res.body).length !== 0) {
+      // 当res.body有值时,停止调用接口
+      data.value = res.body;
+      shouldContinue = false;
+    } else {
+      await new Promise((resolve) => setTimeout(resolve, 3000));
+    }
+  }
+  if (res && res.code === 200 && res.body) {
+    loading.value = false;
+    const scores = extractScores(res.body);
+    score.value = scores.reduce((sum, score) => sum + score, 0);
+    scoresList.value = Object.keys(res.body).reduce((acc, key) => {
+      if (key !== "defects") {
+        acc[key] = {
+          ...res.body[key],
+          name: nameMapping[key], // 添加 name 字段
+        };
+      }
+      return acc;
+    }, {});
+    defectsList.value = res.body.defects;
+    console.log(defectsList.value, '000000000');
+    const obj = extractScores(scoresList.value);
+    randarlist.value = Object.values(obj);
+  }
+};
+
+// 处理“分”字
+const extractScores = (data) => {
+  return Object.values(data).map((item) => {
+    // 确保 score 是一个字符串,并且去除可能的 "分" 字
+    let scoreStr = String(item.score).replace("分", "");
+
+    // 将 score 转换为整数,如果转换失败则返回 0
+    const score = parseInt(scoreStr, 10);
+    return isNaN(score) ? 0 : score;
+  });
+};
+
+onMounted(() => {
+  handleResult();
+  window.addEventListener("popstate", function (event) {
+    // 执行一些操作,例如关闭当前页面或跳转到其他页面
+    window.location.href = "/#/home";
+  });
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener("popstate", function (event) {
+    // 执行一些操作,例如关闭当前页面或跳转到其他页面
+    window.location.href = "/#/home";
+  });
+});
+</script>
+<template>
+  <div class="result" style="overflow: hidden">
+    <NavBarPage title="考试" type="home" />
+    <div style="height: 46px"></div>
+    <div class="score">
+      <span>最终得分</span>
+      <p class="score-num">
+        <span style="margin-right: 0.2rem;">{{ score }}</span>分
+      </p>
+    </div>
+    <div style="height: 9.06rem; visibility: hidden"></div>
+    <div class="content">
+      <div style="width: 100vw; height: 19.06rem">
+        <randarPage :randarList="randarlist" />
+      </div>
+      <div class="loading" v-show="loading">
+       <van-loading color="#0094ff" size="26px" vertical>加载中...</van-loading>
+      </div>
+      <div class="standard">
+        <div class="standard-title">
+          <img src="../assets/robot.jpg" alt="" />
+          <span>评分标准</span>
+        </div>
+        <div class="standard-content" v-for="(item, key) in scoresList" :key="key">
+          <div class="standard-subtitle">
+            <span class="pro">{{ item.name == "扣分项" ? item.name : `${item.name}(25分)` }}:</span>
+            <span>{{ item.score }}</span>
+          </div>
+          <p>
+            <span>得分原因:</span>
+            <span>{{ item.reason }}</span>
+          </p>
+        </div>
+        <div class="standard-content" style="margin-top: 1.5rem;">
+          <div class="standard-title">
+          <img src="../assets/robot.jpg" alt="" />
+          <span>评分详解</span>
+        </div>
+          <div class="standard-content" v-for="(item, index) in defectsList" :key="index">
+            <div class="standard-subtitle">
+            <span class="pro">轮次{{item.rounds}}:</span>
+          </div>
+            <p>
+              <span>原句:</span>
+              <span>{{ item.original_sentence }}</span>
+            </p>
+            <p>
+              <span>不足:</span>
+              <span>{{ item.defect }}</span>
+            </p>
+            <p>
+              <span>建议:</span>
+              <span>{{ item.example }}</span>
+            </p>
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less">
+html,
+body {
+  margin: 0;
+  padding: 0;
+  height: 100vh;
+  overflow: hidden;
+  /* 禁止页面整体滚动 */
+}
+
+.result {
+  width: 100%;
+  height: 100%;
+  /* height: 100vh; */
+  overflow: hidden;
+}
+
+.score {
+  position: fixed;
+  z-index: 999;
+  width: 100%;
+  height: 9.06rem;
+  background-color: rgba(243, 249, 255, 1);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  span {
+    display: flex;
+    width: 5.06rem;
+    height: 2.88rem;
+    font-weight: bold;
+    line-height: 2.88rem;
+    color: rgba(64, 149, 229, 1);
+    font-size: 1rem;
+    text-align: center;
+    font-family: Times New Roman-regular;
+  }
+
+  .score-num {
+    display: flex;
+    align-items: last baseline;
+    color: rgba(64, 149, 229, 1);
+    font-size: 1rem;
+
+    span {
+      display: block;
+      width: 5.06rem;
+      height: 3.31rem;
+      line-height: 4.25rem;
+      font-size: 3rem;
+      text-align: center;
+      font-family: Times New Roman-regular;
+    }
+  }
+}
+
+.content {
+  width: 100%;
+  height: 100%;
+  overflow: scroll;
+}
+
+.standard-title {
+  display: flex;
+  align-items: center;
+  padding: 0 0.94rem;
+  margin-bottom: 0.63rem;
+
+  img {
+    width: 1.5rem;
+    height: 1.5rem;
+  }
+
+  span {
+    display: inline-block;
+    width: 4rem;
+    height: 1.44rem;
+    line-height: 1.44rem;
+    margin-left: 0.33rem;
+    color: rgba(64, 149, 229, 1);
+    font-size: 1rem;
+    text-align: center;
+    font-family: Times New Roman-regular;
+  }
+}
+
+.standard-subtitle {
+  padding: 0.56rem 1rem;
+  height: 1.06rem;
+  font-size: 0.88rem;
+  font-family: Times New Roman-regular;
+
+  span {
+    color: rgba(64, 149, 229, 1);
+    font-weight: 600;
+  }
+
+  .pro {
+    display: inline-block;
+    height: 1.06rem;
+    line-height: 1.06rem;
+    padding: 0 0.3rem;
+    font-weight: normal;
+    color: rgba(102, 102, 102, 1);
+    border-left: 0.3rem solid rgba(64, 149, 229, 1);
+  }
+}
+
+.standard-content p {
+  padding: 0 1.63rem;
+  margin-bottom: 0.31rem;
+  line-height: 1.25rem;
+  color: rgba(102, 102, 102, 1);
+  font-size: 0.88rem;
+  text-align: left;
+  font-family: SourceHanSansSC-bold;
+}
+
+.standard-content p span:nth-child(1) {
+  font-weight: 600;
+}
+
+.loading {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 9999;
+}
+</style>

+ 48 - 0
src/views/components/BG.vue

@@ -0,0 +1,48 @@
+<script setup>
+import { ref } from "vue";
+
+const images = ["image0.jpg","image0.jpg","image0.jpg"];
+const currentIndex = ref(2);
+
+setInterval(() => {
+  currentIndex.value = (currentIndex.value + 1) % images.length;
+}, 10000);
+</script>
+
+<template>
+  <div class="bg">
+    <img
+      :class="currentIndex === index ? 'active' : ''"
+      v-for="(item, index) in images"
+      :key="item"
+      :src="item"
+    />
+  </div>
+</template>
+
+<style scoped>
+.bg {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.bg img {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  transition: all 0.5s ease-in-out;
+  opacity: 0;
+}
+
+.bg img.active {
+  opacity: 1;
+}
+</style>

+ 353 - 0
src/views/components/BottomArea.vue

@@ -0,0 +1,353 @@
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from "vue";
+import { KeyboardVoiceOutlined, KeyboardAltOutlined } from "@vicons/material";
+import axios from "axios";
+import { Icon } from "@vicons/utils";
+import Recorder from "recorder-core";
+import "recorder-core/src/engine/wav";
+import "recorder-core/src/extensions/frequency.histogram.view";
+import "recorder-core/src/extensions/lib.fft";
+
+const emit = defineEmits(["startRecord", "stopRecord"]);
+
+const isTalking = ref(false);
+const sessionId = ref("");
+
+const userTalk = ref("");
+
+let rec;
+let testSampleRate = ref(16000);
+let testBitRate = ref(16);
+let SendInterval = 300; // 控制实时传输间隔
+
+let realTimeSendTryType,
+  realTimeSendTryEncBusy,
+  realTimeSendTryTime = 0,
+  realTimeSendTryNumber,
+  transferUploadNumberMax,
+  realTimeSendTryChunk,
+  voicePkgSeq = 0,
+  wave = null;
+
+const RealTimeSendTryReset = (type) => {
+  realTimeSendTryType = type;
+  realTimeSendTryTime = 0;
+  realTimeSendTryEncBusy = 0;
+  realTimeSendTryNumber = 0;
+  transferUploadNumberMax = 0;
+  realTimeSendTryChunk = null;
+};
+
+const RealTimeSendTry = (buffers, bufferSampleRate, isClose) => {
+  const t1 = Date.now();
+  if (realTimeSendTryTime === 0) {
+    realTimeSendTryTime = t1;
+    realTimeSendTryEncBusy = 0;
+    realTimeSendTryNumber = 0;
+    transferUploadNumberMax = 0;
+    realTimeSendTryChunk = null;
+  }
+
+  if (!isClose && t1 - realTimeSendTryTime < SendInterval) {
+    return; // 控制缓冲达到指定间隔才进行传输
+  }
+
+  realTimeSendTryTime = t1;
+  const number = ++realTimeSendTryNumber;
+
+  let pcm = [],
+    pcmSampleRate = 0;
+  if (buffers.length > 0) {
+    // 借用SampleData函数进行数据的连续处理,采样率转换是顺带的,得到新的pcm数据
+    const chunk = Recorder.SampleData(
+      buffers,
+      bufferSampleRate,
+      testSampleRate.value,
+      realTimeSendTryChunk,
+      { frameType: isClose ? "" : realTimeSendTryType }
+    );
+
+    for (
+      let i = realTimeSendTryChunk ? realTimeSendTryChunk.index : 0;
+      i < chunk.index;
+      i++
+    ) {
+      buffers[i] = null; // 清理已处理完的缓冲数据
+    }
+
+    realTimeSendTryChunk = chunk;
+    pcm = chunk.data;
+    pcmSampleRate = chunk.sampleRate;
+  }
+
+  // 如果没有新数据,或者结束时数据量太小,不能进行转码
+  if (pcm.length === 0 || (isClose && pcm.length < 2000)) {
+    TransferUpload(number, null, 0, null, isClose);
+    return;
+  }
+
+  // 实时编码队列阻塞处理
+  if (!isClose) {
+    if (realTimeSendTryEncBusy >= 2) {
+      console.log("编码队列阻塞,已丢弃一帧", 1);
+      return;
+    }
+  }
+  realTimeSendTryEncBusy++;
+
+  const encStartTime = Date.now();
+  const recMock = Recorder({
+    type: realTimeSendTryType,
+    sampleRate: testSampleRate.value, // 采样率
+    bitRate: testBitRate.value, // 比特率
+  });
+
+  recMock.mock(pcm, pcmSampleRate);
+  recMock.stop(
+    (blob, duration) => {
+      if (realTimeSendTryEncBusy) realTimeSendTryEncBusy--;
+      blob.encTime = Date.now() - encStartTime;
+      TransferUpload(number, blob, duration, recMock, isClose);
+    },
+    (msg) => {
+      if (realTimeSendTryEncBusy) realTimeSendTryEncBusy--;
+      console.log("不应该出现的错误:" + msg, 1);
+    }
+  );
+};
+
+const TransferUpload = (number, blobOrNull, duration, blobRec, isClose) => {
+  transferUploadNumberMax = Math.max(transferUploadNumberMax, number);
+  if (blobOrNull) {
+    let blob = blobOrNull;
+    let encTime = blob.encTime;
+    // 发送数据的方式一:Base64文本发送
+    let reader = new FileReader();
+    reader.onloadend = () => {
+      let base64 = (/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result) || [])[1];
+      // 可以实现 WebSocket send(base64), WebRTC send(base64), XMLHttpRequest send(base64)
+      asrPost("SESSION_IN", base64);
+    };
+    reader.readAsDataURL(blob);
+  }
+
+  if (isClose) {
+    console.log(
+      "No." +
+        (number < 100 ? ("000" + number).substr(-3) : number) +
+        ":已停止传输"
+    );
+  }
+};
+const makeRandomString = (len) => {
+  len = len || 32;
+  let $chars =
+    "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; /****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
+  let maxPos = $chars.length;
+  let pwd = "";
+  for (let i = 0; i < len; i++) {
+    pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
+  }
+  return pwd + new Date().getTime();
+};
+// 语音识别
+const asrPost = async (eventCodeType = "SESSION_IN", base64) => {
+  const res = await axios.post(
+    `/openapi-stg/ai/voice/asr/v1`,
+    {
+      sessionId: sessionId.value,
+      eventCodeType,
+      voicePkgSeq: ++voicePkgSeq,
+      audio: base64,
+      format: 16000,
+      encoding: "WAV",
+    },
+    {
+      headers: {
+        "X-Ai-Asr-Appid": "2b1317fb5b284b308dc90a6fdeae6c4e",
+      },
+    }
+  );
+
+  console.log(res.data.body.asrResultText);
+
+  userTalk.value = userTalk.value + res.data.body.asrResultText;
+};
+
+onMounted(() => {
+  sessionId.value = makeRandomString(5);
+  if (rec) {
+    rec.close();
+  }
+
+  rec = Recorder({
+    type: "unknown",
+    onProcess: (buffers, powerLevel, bufferDuration, bufferSampleRate) => {
+      RealTimeSendTry(buffers, bufferSampleRate, false);
+      wave &&
+        buffers[buffers.length - 1] &&
+        wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate);
+    },
+  });
+
+  const t = setTimeout(() => {
+    console.log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)", 1);
+  }, 8000);
+
+  rec.open(
+    () => {
+      if (Recorder.FrequencyHistogramView) {
+        wave = Recorder.FrequencyHistogramView({
+          elem: ".recwave",
+          lineCount: 10,
+          position: 0,
+          minHeight: 1,
+          fallDuration: 400,
+          stripeEnable: false,
+          mirrorEnable: true,
+          linear: [0, "#fff", 1, "#fff"],
+        });
+      }
+
+      clearTimeout(t);
+    },
+    (msg, isUserNotAllow) => {
+      clearTimeout(t);
+      console.log(
+        (isUserNotAllow ? "UserNotAllow," : "") + "无法录音:" + msg,
+        1
+      );
+    }
+  );
+  //console.log(document.querySelector(".recwave"));
+  //const div = document.createElement("div");
+  // const div = document.querySelector(".wave");
+  // div.innerHTML =
+  //   '<div style="height:100px;width:100%;" class="recwave"></div>';
+  //document.body.prepend(div);
+});
+
+onBeforeUnmount(() => {
+  if (rec) rec.close();
+});
+
+const startRecording = async (e) => {
+  e.preventDefault();
+  isTalking.value = true;
+  userTalk.value = "";
+  asrPost("SESSION_BEGIN");
+  rec.start();
+  RealTimeSendTryReset("wav");
+  emit("startRecord");
+};
+
+const stopRecording = async () => {
+  isTalking.value = false;
+  //等待500ms后,在执行以下代码
+  await new Promise((resolve) => setTimeout(resolve, 1000));
+  rec.stop();
+  RealTimeSendTry([], 0, true); // 最后一次发送
+  await asrPost("SESSION_END");
+  voicePkgSeq = 0;
+  emit("stopRecord", userTalk.value);
+};
+</script>
+
+<template>
+  <div
+    :class="`bottom-btn ${isTalking ? 'touch-start' : ''}`"
+    @touchstart="startRecording"
+    @touchend="stopRecording"
+  >
+    <div class="btn">
+      <div class="text">按住说话</div>
+      <Icon class="icon" color="white"><KeyboardAltOutlined /></Icon>
+    </div>
+    <div class="talk-item">
+      <div class="wave">
+        <div class="recwave" style="height: 100%; width: 100%"></div>
+      </div>
+      <Icon class="icon" color="#1989fa"><KeyboardVoiceOutlined /></Icon>
+      <div>松手发送</div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.bottom-btn {
+  position: fixed;
+  bottom: 0;
+  right: 0;
+  width: 120vw;
+  left: -10vw;
+  display: flex;
+  justify-content: center;
+  height: 80px;
+  background: rgba(255, 255, 255, 0);
+  transition: height 0.1s, background 0.1s, border-radius 0.1s;
+  padding-bottom: env(safe-area-inset-bottom);
+}
+.bottom-btn.touch-start {
+  background: rgba(255, 255, 255, 0.8);
+  border-radius: 50% 50% 0 0;
+  height: 120px;
+}
+.bottom-btn.touch-start .btn {
+  display: none;
+}
+.bottom-btn.touch-start .talk-item {
+  visibility: visible;
+}
+.btn {
+  width: 90vw;
+  height: 40px;
+  background: rgba(150, 150, 150, 0.5);
+  color: white;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  position: absolute;
+  bottom: calc(20px + env(safe-area-inset-bottom));
+}
+.btn .text {
+  flex-grow: 1;
+  text-align: center;
+}
+.btn .icon {
+  margin-right: 10px;
+}
+
+.talk-item {
+  position: absolute;
+  bottom: calc(40px + env(safe-area-inset-bottom));
+  visibility: hidden;
+  text-align: center;
+  color: #1989fa;
+}
+.talk-item .wave {
+  position: absolute;
+  top: -100px;
+  left: 50%;
+  margin-left: -50px;
+  width: 100px;
+  height: 50px;
+  background: #1989fa;
+  border-radius: 8px;
+}
+.talk-item .wave::after {
+  content: "";
+  position: absolute;
+  bottom: -8px;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 0;
+  height: 0;
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  border-top: 8px solid rgb(25, 137, 250);
+}
+.talk-item .icon {
+  font-size: 24px;
+}
+</style>

+ 126 - 0
src/views/components/ChatList.vue

@@ -0,0 +1,126 @@
+<script setup>
+import { defineProps, onMounted, nextTick, ref, watch } from "vue";
+
+const scrollRef = ref(null);
+
+const props = defineProps({
+  chatList: Array,
+});
+
+watch(
+  () => props.chatList,
+  () => {
+    scrollToBottom();
+  },
+  { deep: true }
+);
+
+const scrollToBottom = async () => {
+  await nextTick();
+  if (scrollRef.value) scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
+};
+
+defineExpose({
+  scrollToBottom,
+});
+</script>
+
+<template>
+  <div id="scrollRef" ref="scrollRef" class="chat-area">
+    <div v-for="item in chatList" :key="item.id" :class="`chat-${item.type}`">
+      <div v-if="item.text" class="chat-item">
+        {{ item.text }}
+      </div>
+      <div v-if="!item.text" class="loader">
+        <div></div>
+        <div></div>
+        <div></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.chat-area {
+  position: relative;
+  position: fixed;
+  width: 100vw;
+  max-height: 50vh;
+  /* bottom: calc(80px + env(safe-area-inset-bottom)); */
+  bottom: calc(65px + env(safe-area-inset-bottom));
+  padding: 10px;
+  overflow-y: auto;
+  box-sizing: border-box;
+  mask-image: linear-gradient(to top, black 70%, transparent);
+  -mask-image: linear-gradient(to top, black 70%, transparent);
+}
+.chat-area .chat-gpt {
+  display: flex;
+  justify-content: start;
+}
+.chat-area .chat-user {
+  display: flex;
+  justify-content: end;
+}
+.chat-item {
+  color: white;
+  padding: 10px;
+  font-size: 14px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: column;
+  word-break: break-all;
+  position: relative;
+  background: rgba(25, 137, 250, 0.8);
+  max-width: 45%;
+}
+.chat-area .chat-gpt .chat-item {
+  border-radius: 0 4px 4px 4px;
+  background-color: white;
+  color: #333;
+}
+.chat-area .chat-user .chat-item {
+  border-radius: 4px 0 4px 4px;
+}
+
+.loader {
+  display: inline-block;
+  position: relative;
+  height: 40px;
+}
+
+.loader div {
+  display: inline-block;
+  position: absolute;
+  left: 8px;
+  width: 12px;
+  height: 12px;
+  background: white;
+  border-radius: 50%;
+  animation: loading 1s cubic-bezier(0, 0.5, 0.5, 1) infinite;
+}
+
+.loader div:nth-child(1) {
+  left: 8px;
+  animation-delay: -0.24s;
+}
+.loader div:nth-child(2) {
+  left: 32px;
+  animation-delay: -0.12s;
+}
+.loader div:nth-child(3) {
+  left: 56px;
+  animation-delay: 0;
+}
+
+@keyframes loading {
+  0%,
+  80%,
+  100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+</style>

+ 105 - 0
src/views/demo/Asr.vue

@@ -0,0 +1,105 @@
+<script setup>
+import Recorder from 'recorder-core';
+import 'recorder-core/src/engine/mp3';
+import 'recorder-core/src/engine/mp3-engine'
+//可选的插件支持项,把需要的插件按需引入进来即可
+//import 'recorder-core/src/extensions/waveview'
+import 'recorder-core/src/extensions/frequency.histogram.view'
+import 'recorder-core/src/extensions/lib.fft'
+
+import { ref } from 'vue';
+
+let rec = null;
+let processTime = 0;
+let wave = null;
+
+const recOpen = (success) => {
+  rec = Recorder({
+    type: "mp3",
+    sampleRate: 16000,
+    bitRate: 16,
+    onProcess: (buffers, powerLevel, bufferDuration, bufferSampleRate) => {
+      processTime = Date.now();
+      wave && wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate);
+    }
+  });
+
+  rec.open(() => {
+    if (Recorder.FrequencyHistogramView) wave = Recorder.FrequencyHistogramView({ 
+        elem: ".recwave"
+        ,lineCount:20
+		,position:0
+		,minHeight:1
+		,fallDuration:400
+		,stripeEnable:false
+		,mirrorEnable:true
+		,linear:[0,"#0ac",1,"#0ac"] 
+    });
+    success && success();
+  }, (msg, isUserNotAllow) => {
+    console.log((isUserNotAllow ? "UserNotAllow, " : "") + "Unable to record: " + msg);
+  });
+
+    // Create the WaveView element and prepend it to the body
+    const div = document.createElement("div");
+    div.innerHTML = '<div style="height:100px;width:100%;" class="recwave"></div>';
+    document.body.prepend(div);
+};
+
+const startRecording = () => {
+  rec.start();
+
+  rec.watchDogTimer = setInterval(() => {
+    if (!rec || rec.watchDogTimer != rec.watchDogTimer) {
+      clearInterval(rec.watchDogTimer);
+      return;
+    }
+    if (Date.now() < rec.wdtPauseT) return;
+    if (Date.now() - (processTime || startTime) > 1500) {
+      clearInterval(rec.watchDogTimer);
+      console.error(processTime ? "Recording interrupted" : "Recording did not start properly");
+      // Your error handling logic here
+    }
+  }, 1000);
+
+  const startTime = Date.now();
+  rec.wdtPauseT = 0;
+  processTime = 0;
+};
+
+const stopRecording = () => {
+  clearInterval(rec.watchDogTimer);
+  rec.stop((blob, duration) => {
+    const localUrl = (window.URL || webkitURL).createObjectURL(blob);
+    console.log(blob, localUrl, "Duration: " + duration + "ms");
+
+    rec.close();
+    rec = null;
+
+    var audio = document.createElement("audio");
+    document.body.prepend(audio);
+    audio.controls = true;
+    audio.src = localUrl;
+    audio.play();
+  }, (msg) => {
+    console.log("Recording failed: " + msg);
+    rec.close();
+    rec = null;
+  });
+};
+
+recOpen(() => {
+  // Callback after successfully opening recorder
+});
+</script>
+
+<template>
+    <div>
+        <van-button type="primary" @click="startRecording">开始录音</van-button>
+        <van-button type="primary" @click="stopRecording">停止录音</van-button>
+    </div>
+</template>
+
+<style>
+
+</style>

+ 457 - 0
src/views/demo/AsrDemo.vue

@@ -0,0 +1,457 @@
+<template>
+  <div class="no-select">
+    <!-- <button @mousedown="recStart('mp3')" @mouseup="recStop">
+      mp3按住录音并实时asr
+    </button> -->
+    <van-button
+      style="margin: 20px 0"
+      type="primary"
+      size="large"
+      @touchstart="recStart('wav')"
+      @touchend="recStop"
+      >手机端长按</van-button
+    >
+    <van-button
+      type="primary"
+      size="large"
+      @mousedown="recStart('wav')"
+      @mouseup="recStop"
+      >电脑端鼠标长按</van-button
+    >
+    <!-- <button @mousedown="recStart('pcm')" @mouseup="recStop">
+      pcm按住录音并实时asr(不支持播放)
+    </button> -->
+    <div>{{ userTalk }}</div>
+    <div>{{ text }}</div>
+    <!-- <div class="audioPlay"></div>
+    <div class="progress"></div> -->
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from "vue";
+import axios from "axios";
+import Recorder from "recorder-core";
+import "recorder-core/src/engine/mp3";
+import "recorder-core/src/engine/mp3-engine";
+import "recorder-core/src/engine/wav";
+import "recorder-core/src/engine/pcm";
+//可选的插件支持项,把需要的插件按需引入进来即可
+//import 'recorder-core/src/extensions/waveview'
+import "recorder-core/src/extensions/frequency.histogram.view";
+import "recorder-core/src/extensions/lib.fft";
+
+const userTalk = ref("");
+
+let rec;
+let testOutputWavLog = ref(false); // 用于调试是否输出wav格式
+let testSampleRate = ref(16000);
+let testBitRate = ref(16);
+let SendInterval = 300; // 控制实时传输间隔
+
+let realTimeSendTryType,
+  realTimeSendTryEncBusy,
+  realTimeSendTryTime = 0,
+  realTimeSendTryNumber,
+  transferUploadNumberMax,
+  realTimeSendTryChunk,
+  voicePkgSeq = 0;
+
+const RealTimeSendTryReset = (type) => {
+  realTimeSendTryType = type;
+  realTimeSendTryTime = 0;
+  realTimeSendTryEncBusy = 0;
+  realTimeSendTryNumber = 0;
+  transferUploadNumberMax = 0;
+  realTimeSendTryChunk = null;
+};
+
+const RealTimeSendTry = (buffers, bufferSampleRate, isClose) => {
+  const t1 = Date.now();
+  if (realTimeSendTryTime === 0) {
+    realTimeSendTryTime = t1;
+    realTimeSendTryEncBusy = 0;
+    realTimeSendTryNumber = 0;
+    transferUploadNumberMax = 0;
+    realTimeSendTryChunk = null;
+  }
+
+  if (!isClose && t1 - realTimeSendTryTime < SendInterval) {
+    return; // 控制缓冲达到指定间隔才进行传输
+  }
+
+  realTimeSendTryTime = t1;
+  const number = ++realTimeSendTryNumber;
+
+  let pcm = [],
+    pcmSampleRate = 0;
+  if (buffers.length > 0) {
+    // 借用SampleData函数进行数据的连续处理,采样率转换是顺带的,得到新的pcm数据
+    const chunk = Recorder.SampleData(
+      buffers,
+      bufferSampleRate,
+      testSampleRate.value,
+      realTimeSendTryChunk,
+      { frameType: isClose ? "" : realTimeSendTryType }
+    );
+
+    for (
+      let i = realTimeSendTryChunk ? realTimeSendTryChunk.index : 0;
+      i < chunk.index;
+      i++
+    ) {
+      buffers[i] = null; // 清理已处理完的缓冲数据
+    }
+
+    realTimeSendTryChunk = chunk;
+    pcm = chunk.data;
+    pcmSampleRate = chunk.sampleRate;
+  }
+
+  // 如果没有新数据,或者结束时数据量太小,不能进行转码
+  if (pcm.length === 0 || (isClose && pcm.length < 2000)) {
+    TransferUpload(number, null, 0, null, isClose);
+    return;
+  }
+
+  // 实时编码队列阻塞处理
+  if (!isClose) {
+    if (realTimeSendTryEncBusy >= 2) {
+      console.log("编码队列阻塞,已丢弃一帧", 1);
+      return;
+    }
+  }
+  realTimeSendTryEncBusy++;
+
+  const encStartTime = Date.now();
+  const recMock = Recorder({
+    type: realTimeSendTryType,
+    sampleRate: testSampleRate.value, // 采样率
+    bitRate: testBitRate.value, // 比特率
+  });
+
+  recMock.mock(pcm, pcmSampleRate);
+  recMock.stop(
+    (blob, duration) => {
+      if (realTimeSendTryEncBusy) realTimeSendTryEncBusy--;
+      blob.encTime = Date.now() - encStartTime;
+      TransferUpload(number, blob, duration, recMock, isClose);
+    },
+    (msg) => {
+      if (realTimeSendTryEncBusy) realTimeSendTryEncBusy--;
+      console.log("不应该出现的错误:" + msg, 1);
+    }
+  );
+};
+
+const TransferUpload = (number, blobOrNull, duration, blobRec, isClose) => {
+  transferUploadNumberMax = Math.max(transferUploadNumberMax, number);
+  if (blobOrNull) {
+    let blob = blobOrNull;
+    let encTime = blob.encTime;
+    // 发送数据的方式一:Base64文本发送
+    let reader = new FileReader();
+    reader.onloadend = () => {
+      let base64 = (/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result) || [])[1];
+      // 可以实现 WebSocket send(base64), WebRTC send(base64), XMLHttpRequest send(base64)
+      asrPost("SESSION_IN", base64);
+    };
+    reader.readAsDataURL(blob);
+
+    // 发送数据的方式二:Blob二进制发送
+    // 可以实现 WebSocket send(blob), WebRTC send(blob), XMLHttpRequest send(blob)
+
+    const numberFail =
+      number < transferUploadNumberMax
+        ? '<span style="color:red">顺序错乱的数据,如果要求不高可以直接丢弃,或者调大SendInterval试试</span>'
+        : "";
+    const logMsg =
+      "No." +
+      (number < 100 ? ("000" + number).substr(-3) : number) +
+      numberFail;
+
+    console.log(
+      blob,
+      duration,
+      blobRec,
+      logMsg + "花" + ("___" + encTime).substr(-3) + "ms"
+    );
+
+    // 插入html
+    const childDiv = document.createElement("div");
+    const button = document.createElement("button");
+
+    childDiv.textContent = `${logMsg + ("___" + encTime).substr(-3) + "ms"}`;
+    button.textContent = "播放";
+    button.addEventListener("click", () => {
+      recPlay(blob);
+    });
+
+    childDiv.appendChild(button);
+    //document.querySelector(".progress").appendChild(childDiv);
+  }
+
+  if (isClose) {
+    console.log(
+      "No." +
+        (number < 100 ? ("000" + number).substr(-3) : number) +
+        ":已停止传输"
+    );
+  }
+};
+
+const recStart = async (type) => {
+  userTalk.value = "";
+  text.value = "";
+  lastStrIndex = 0;
+  await asrPost("SESSION_BEGIN");
+  rec.start();
+  RealTimeSendTryReset(type);
+};
+
+const recStop = async () => {
+  rec.stop();
+  RealTimeSendTry([], 0, true); // 最后一次发送
+  await asrPost("SESSION_END");
+  voicePkgSeq = 0;
+  chatGpt();
+};
+
+const recPlay = (blob) => {
+  const audioPlayElement = document.querySelector(".audioPlay");
+  audioPlayElement.innerHTML = "";
+  const audio = document.createElement("audio");
+  audio.controls = true;
+  audioPlayElement.appendChild(audio);
+  audio.src = (window.URL || webkitURL).createObjectURL(blob);
+  audio.play();
+
+  setTimeout(() => {
+    (window.URL || webkitURL).revokeObjectURL(audio.src);
+  }, 5000);
+};
+
+const asrPost = async (eventCodeType = "SESSION_IN", base64) => {
+  const wgToken = `C1qziFGlIv3tnCQxcFaStrLuZOO2ZZXjN7FB_G0WlrOLjclfObbSaXAKzl4RWwQBf_0Zhsm0CoVvdVsYMD18iM_LJrxtn7LHJJQuF9UoUuF3fvqOwrG4EF6Z4GahtxtQ2oeaPQBBNKlgVW1xUW7tkhEdXWqzDHPA_I_91Lczk0PI4guhx1c88Hst4-HI8pdMbiUdEJzj3d3a2W06Fa0XA9Q0taAwaRd1k9jUrDVyj9GfS84_SIgJF4SPjWVfsraV79ieb_StgRcUwZjbscGPMlifnJD6F00wwNbxG7AuCHbl3EtMfSed1vuVx3AsizIckwzIVSVRpOGw72cdAMui-I6es9Ozj2ITzSa5KgyXEpX4qCHF1VcCM1wlHLQ_5hLnJIi4r8NsnJPsxMYrTw`;
+  const res = await axios.post(
+    `https://fls-ai-stg-sit.pingan.com.cn/openapi/ai/voice/asr/v1?channelId=ASP-TEST&sceneId=ASP-TEST&token=${wgToken}`,
+    {
+      sessionId: "N7FB_G0WlrOLjc",
+      eventCodeType,
+      voicePkgSeq: ++voicePkgSeq,
+      audio: base64,
+      format: 16000,
+      encoding: "WAV",
+    },
+    {
+      headers: {
+        "X-Ai-Asr-Appid": "2b1317fb5b284b308dc90a6fdeae6c4e",
+      },
+    }
+  );
+
+  console.error(res.data.body.asrResultText);
+
+  userTalk.value = userTalk.value + res.data.body.asrResultText;
+};
+
+onMounted(() => {
+  if (rec) {
+    rec.close();
+  }
+
+  rec = Recorder({
+    type: "unknown",
+    onProcess: (buffers, powerLevel, bufferDuration, bufferSampleRate) => {
+      RealTimeSendTry(buffers, bufferSampleRate, false);
+    },
+  });
+
+  const t = setTimeout(() => {
+    console.log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)", 1);
+  }, 8000);
+
+  rec.open(
+    () => {
+      clearTimeout(t);
+    },
+    (msg, isUserNotAllow) => {
+      clearTimeout(t);
+      console.log(
+        (isUserNotAllow ? "UserNotAllow," : "") + "无法录音:" + msg,
+        1
+      );
+    }
+  );
+});
+
+onBeforeUnmount(() => {
+  if (rec) rec.close();
+});
+
+////////
+
+import PQueue from "p-queue";
+
+const text = ref("");
+
+// 创建一个队列实例,设置并发数为 1
+const queue = new PQueue({ concurrency: 1 });
+const queue1 = new PQueue({ concurrency: 1 });
+
+// 需要tts的文本
+const messageQueue = [];
+
+// lastStrIndex 用于记录上一个字符串的结束位置
+let lastStrIndex = 0;
+
+// 将字符串根据标点符号断句分割,并添加到messageQueue中
+const splitMessage = async (str) => {
+  const punctuation = ["。", "!", "?", ";", ":", ","];
+
+  for (let i = lastStrIndex; i < str.length; i++) {
+    if (punctuation.includes(str[i])) {
+      const message = str.slice(lastStrIndex, i + 1);
+      console.log(message, "==========");
+      await playTTS(message);
+      lastStrIndex = i + 1; // 更新上一个字符串的结束位置
+    }
+  }
+};
+
+const playTTS = async (ttsMessage) => {
+  //const ttsMessage =
+  //  "这个错误通常表示浏览器无法识别音频数据的格式或编码,导致无法加载音频源。为了解决这个问题,你可以尝试使用静态的 WAV 格式音频文件以确保能够正常播放,并且避免直接处理原始的音频数据。以下是一个示例代码来加载外部的 WAV 格式音频文件:"; // 替换为你想要播报的文本
+
+  try {
+    const wgToken = `C1qziFGlIv3tnCQxcFaStrLuZOO2ZZXjN7FB_G0WlrOLjclfObbSaXAKzl4RWwQBf_0Zhsm0CoVvdVsYMD18iM_LJrxtn7LHJJQuF9UoUuF3fvqOwrG4EF6Z4GahtxtQ2oeaPQBBNKlgVW1xUW7tkhEdXWqzDHPA_I_91Lczk0PI4guhx1c88Hst4-HI8pdMbiUdEJzj3d3a2W06Fa0XA9Q0taAwaRd1k9jUrDVyj9GfS84_SIgJF4SPjWVfsraV79ieb_StgRcUwZjbscGPMlifnJD6F00wwNbxG7AuCHbl3EtMfSed1vuVx3AsizIckwzIVSVRpOGw72cdAMui-I6es9Ozj2ITzSa5KgyXEpX4qCHF1VcCM1wlHLQ_5hLnJIi4r8NsnJPsxMYrTw`;
+    const res = await axios.post(
+      `https://fls-ai-stg-sit.pingan.com.cn/openapi/ai/voice/tts/v2?channelId=ASP-TEST&sceneId=ASP-TEST&token=${wgToken}`,
+      { sessionId: "N7FB_G0WlrOLjc", text: ttsMessage },
+      {
+        responseType: "arraybuffer",
+        headers: {
+          "X-Ai-TTS-Appid": "2b1317fb5b284b308dc90a6fdeae6c4e",
+        },
+      }
+    );
+    console.log(res.data);
+
+    queue1.add(async () => {
+      // 播放获取到的音频
+      await playAudio(res.data);
+    });
+  } catch (error) {
+    console.error("Error calling TTS API:", error);
+  }
+};
+
+// const playAudio = async (audioData) => {
+//   const blob = new Blob([audioData], { type: "audio/wav" });
+//   const url = URL.createObjectURL(blob);
+//   const audio = new Audio(url);
+//   audio.onended = () => URL.revokeObjectURL(url);
+//   audio.onerror = (error) => console.error("Audio playback error:", error);
+//   audio
+//     .play()
+//     .then(() => console.log("Audio playing"))
+//     .catch((error) => console.error("Error playing audio:", error));
+// };
+
+const playAudio = (audioData, options = {}) => {
+  return new Promise((resolve, reject) => {
+    const blob = new Blob([audioData], { type: "audio/wav" });
+    const url = URL.createObjectURL(blob);
+    const audio = new Audio(url);
+
+    if (options.volume) {
+      audio.volume = options.volume; // 设置音量
+    }
+
+    audio.onended = () => {
+      URL.revokeObjectURL(url);
+      resolve();
+    };
+
+    audio.onerror = (error) => {
+      URL.revokeObjectURL(url);
+      reject(error);
+    };
+
+    audio
+      .play()
+      .then(() => console.log("Audio playing"))
+      .catch(reject);
+  });
+};
+
+const chatGpt = async () => {
+  const downloadUrl =
+    "https://fls-ai.pingan.com.cn/openapi/ai/llm/forward/api/ai/nlp/dialogue?token=mVcexzY_mjtGAL5_exPlmAyfOJxuuEthWY1mk9tUFC_HwceY58uRZ2WDhz7-ttexCdUtFN8C7V636_jIq6fzaSfqIj8OQyhUPKPMa2eZjLlblT77ySqBt_lYM6iEAhrj7-raGmySMmkLS4Rqh651Ak2tqmUbjS64cqv5ofMsuadOCg1J-CtLFt7NeSoU4N3Kpm5MJ_4sOFBhQGfBym88dcwxosFl9LbvhpyleXFf6fOZkkOj0l2X8Nr2pfNjYs3_VOmCQxrxXh1XZ_a1v9qj5_rA9k9wGNNQfmr2JwJTUT4V9NwtNq94gNFt8C0J6MWKVE2eyr25Rke8tkKu3CGNNmspmEFpr6LavPlaWnWOIh9CRJ1cIDB70pg_JD2l0nPTkPbtaTQaIGTz"; // 替换为实际的下载URL
+
+  try {
+    const response = await axios.post(
+      downloadUrl,
+      {
+        conversationId: "1976cfe3a5174f9ba768677f789cad7e",
+        content: `${userTalk.value} 请输出纯文本的回答,不要使用markdown输出`,
+        messageId: Date.now(),
+        applicationId: "",
+      },
+      {
+        headers: {
+          "Team-Id": "123456",
+          Authorization: "9833ae306bde47f8b00b20c18ec809ae",
+          "Content-Type": "application/json",
+        },
+        onDownloadProgress: ({ event }) => {
+          //console.log(event);
+          const xhr = event.target;
+          const { responseText } = xhr;
+          //   // Always process the final line
+          //   const lastIndex = responseText.lastIndexOf(
+          //     "\n",
+          //     responseText.length - 2
+          //   );
+          //   let chunk = responseText;
+
+          //   if (lastIndex !== -1) chunk = responseText.substring(lastIndex);
+          //console.log(responseText, "chunk====");
+          text.value = responseText;
+          queue.add(async () => {
+            await splitMessage(responseText);
+          });
+        },
+      }
+    );
+    console.log(response);
+    console.log(messageQueue, "messageQueue=======0000");
+  } catch (error) {
+    console.error("下载文件时出错:", error);
+  }
+};
+</script>
+
+<style scoped>
+.no-select {
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+* {
+  -webkit-touch-callout: none;
+
+  -webkit-user-select: none;
+
+  -khtml-user-select: none;
+
+  -moz-user-select: none;
+
+  -ms-user-select: none;
+
+  user-select: none;
+
+  -o-user-select: none;
+}
+</style>

+ 55 - 0
src/views/demo/Home.vue

@@ -0,0 +1,55 @@
+<script setup>
+import { ref } from "vue";
+// 用于sse请求 对sse进行了封装 支持post携带参数请求 文档: https://www.npmjs.com/package/@microsoft/fetch-event-source
+import { fetchEventSource } from "@microsoft/fetch-event-source";
+
+const images = ["image0.png", "image1.png", "image2.png", "image3.png"];
+
+const currentIndex = ref(0);
+
+setInterval(() => {
+  currentIndex.value = (currentIndex.value + 1) % images.length;
+}, 5000); // 切换图片间隔时间(毫秒)
+</script>
+
+<template>
+  <div class="main">
+    <div class="bg">
+      <img
+        :class="currentIndex === index ? 'active' : ''"
+        v-for="(item, index) in images"
+        :key="item"
+        :src="item"
+      />
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.main {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+}
+.bg {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.bg img {
+  position: absolute;
+  top: 0;
+  left: 0;
+  transition: all 0.5s ease-in-out;
+  opacity: 0;
+}
+
+.bg img.active {
+  opacity: 1;
+}
+</style>

+ 51 - 0
src/views/demo/Pic.vue

@@ -0,0 +1,51 @@
+<script setup>
+import { ref, computed } from 'vue';
+
+const images = [
+  'image1.png',
+  'image2.png',
+  'image3.png'
+];
+
+const currentIndex = ref(0);
+
+setInterval(() => {
+  currentIndex.value = (currentIndex.value + 1) % images.length;
+}, 5000); // 切换图片间隔时间(毫秒)
+
+const currentImage = computed(() => images[currentIndex.value]);
+
+</script>
+
+<template>
+    <div class="image-gallery">
+        <img :class="currentIndex === index ? 'active' : ''" v-for="(item, index) in images" :key="item" :src="item" alt="Gallery Image" />
+    </div>
+  </template>
+
+<style scoped>
+.image-gallery {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.image-gallery img {
+    position:absolute;
+    top:0;
+    left:0;
+    transition: all 0.5s ease-in-out;
+    opacity: 0;
+}
+
+.image-gallery img.active {
+    opacity: 1;
+}
+
+
+
+</style>

+ 53 - 0
src/views/demo/Sse.vue

@@ -0,0 +1,53 @@
+<script setup>
+import { fetchEventSource } from '@microsoft/fetch-event-source';
+import { ref } from 'vue';
+
+const message = ref('');
+const result = ref('');
+
+/**
+ * Establishes a server-sent event stream to receive real-time messages from the server.
+ *
+ * @param {object} [params={}] - The parameters to be sent to the server.
+ *
+ * @returns {void}
+ */
+const getRealtimeMessage = ()=>{
+  const params = {
+    message: message.value
+  }; 
+  const ctrlAbout = new AbortController();
+  fetchEventSource('/api/events', {
+    method: 'POST',
+    headers: {
+      'Authorization': 'token',
+      'Content-Type': 'application/json', // 文本返回格式
+    },
+    body: JSON.stringify(params),
+    signal: ctrlAbout.signal,
+    openWhenHidden: true, // 在浏览器标签页隐藏时保持与服务器的EventSource连接
+    onmessage(res) {
+      // 操作流式数据
+      result.value += res.data + '\n';
+    },
+    onclose(res) {
+      // 关闭流
+    },
+    onerror(error) {
+      // 返回流报错
+    }
+  })
+}
+</script>
+
+<template>
+    <div>
+        <van-field v-model="message" placeholder="请输入" type="textarea" />
+        <van-button type="primary" @click="getRealtimeMessage">发送</van-button>
+        <van-field v-model="result" type="textarea" />
+    </div>
+</template>
+
+<style scoped>
+
+</style>

+ 151 - 0
src/views/demo/Steam.vue

@@ -0,0 +1,151 @@
+<template>
+  <div>
+    <button @click="downloadFile">说话</button>
+    <div>{{ text }}</div>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, watch, ref } from "vue";
+import axios from "axios";
+import PQueue from "p-queue";
+
+const text = ref("");
+
+// 创建一个队列实例,设置并发数为 1
+const queue = new PQueue({ concurrency: 1 });
+const queue1 = new PQueue({ concurrency: 1 });
+
+// 需要tts的文本
+const messageQueue = [];
+
+// lastStrIndex 用于记录上一个字符串的结束位置
+let lastStrIndex = 0;
+
+// 将字符串根据标点符号断句分割,并添加到messageQueue中
+const splitMessage = async (str) => {
+  const punctuation = ["。", "!", "?", ";", ":", ","];
+
+  for (let i = lastStrIndex; i < str.length; i++) {
+    if (punctuation.includes(str[i])) {
+      const message = str.slice(lastStrIndex, i + 1);
+      console.log(message, "==========");
+      await playTTS(message);
+      lastStrIndex = i + 1; // 更新上一个字符串的结束位置
+    }
+  }
+};
+
+const playTTS = async (ttsMessage) => {
+  //const ttsMessage =
+  //  "这个错误通常表示浏览器无法识别音频数据的格式或编码,导致无法加载音频源。为了解决这个问题,你可以尝试使用静态的 WAV 格式音频文件以确保能够正常播放,并且避免直接处理原始的音频数据。以下是一个示例代码来加载外部的 WAV 格式音频文件:"; // 替换为你想要播报的文本
+
+  try {
+    const wgToken = `C1qziFGlIv3tnCQxcFaStrLuZOO2ZZXjN7FB_G0WlrOLjclfObbSaXAKzl4RWwQBf_0Zhsm0CoVvdVsYMD18iM_LJrxtn7LHJJQuF9UoUuF3fvqOwrG4EF6Z4GahtxtQ2oeaPQBBNKlgVW1xUW7tkhEdXWqzDHPA_I_91Lczk0PI4guhx1c88Hst4-HI8pdMbiUdEJzj3d3a2W06Fa0XA9Q0taAwaRd1k9jUrDVyj9GfS84_SIgJF4SPjWVfsraV79ieb_StgRcUwZjbscGPMlifnJD6F00wwNbxG7AuCHbl3EtMfSed1vuVx3AsizIckwzIVSVRpOGw72cdAMui-I6es9Ozj2ITzSa5KgyXEpX4qCHF1VcCM1wlHLQ_5hLnJIi4r8NsnJPsxMYrTw`;
+    const res = await axios.post(
+      `https://fls-ai-stg-sit.pingan.com.cn/openapi/ai/voice/tts/v2?channelId=ASP-TEST&sceneId=ASP-TEST&token=${wgToken}`,
+      { sessionId: "N7FB_G0WlrOLjc", text: ttsMessage },
+      {
+        responseType: "arraybuffer",
+        headers: {
+          "X-Ai-TTS-Appid": "2b1317fb5b284b308dc90a6fdeae6c4e",
+        },
+      }
+    );
+    console.log(res.data);
+
+    queue1.add(async () => {
+      // 播放获取到的音频
+      await playAudio(res.data);
+    });
+  } catch (error) {
+    console.error("Error calling TTS API:", error);
+  }
+};
+
+// const playAudio = async (audioData) => {
+//   const blob = new Blob([audioData], { type: "audio/wav" });
+//   const url = URL.createObjectURL(blob);
+//   const audio = new Audio(url);
+//   audio.onended = () => URL.revokeObjectURL(url);
+//   audio.onerror = (error) => console.error("Audio playback error:", error);
+//   audio
+//     .play()
+//     .then(() => console.log("Audio playing"))
+//     .catch((error) => console.error("Error playing audio:", error));
+// };
+
+const playAudio = (audioData, options = {}) => {
+  return new Promise((resolve, reject) => {
+    const blob = new Blob([audioData], { type: "audio/wav" });
+    const url = URL.createObjectURL(blob);
+    const audio = new Audio(url);
+
+    if (options.volume) {
+      audio.volume = options.volume; // 设置音量
+    }
+
+    audio.onended = () => {
+      URL.revokeObjectURL(url);
+      resolve();
+    };
+
+    audio.onerror = (error) => {
+      URL.revokeObjectURL(url);
+      reject(error);
+    };
+
+    audio
+      .play()
+      .then(() => console.log("Audio playing"))
+      .catch(reject);
+  });
+};
+
+const downloadFile = async () => {
+  const downloadUrl =
+    "https://fls-ai.pingan.com.cn/openapi/ai/llm/forward/api/ai/nlp/dialogue?token=mVcexzY_mjtGAL5_exPlmAyfOJxuuEthWY1mk9tUFC_HwceY58uRZ2WDhz7-ttexCdUtFN8C7V636_jIq6fzaSfqIj8OQyhUPKPMa2eZjLlblT77ySqBt_lYM6iEAhrj7-raGmySMmkLS4Rqh651Ak2tqmUbjS64cqv5ofMsuadOCg1J-CtLFt7NeSoU4N3Kpm5MJ_4sOFBhQGfBym88dcwxosFl9LbvhpyleXFf6fOZkkOj0l2X8Nr2pfNjYs3_VOmCQxrxXh1XZ_a1v9qj5_rA9k9wGNNQfmr2JwJTUT4V9NwtNq94gNFt8C0J6MWKVE2eyr25Rke8tkKu3CGNNmspmEFpr6LavPlaWnWOIh9CRJ1cIDB70pg_JD2l0nPTkPbtaTQaIGTz"; // 替换为实际的下载URL
+
+  try {
+    const response = await axios.post(
+      downloadUrl,
+      {
+        conversationId: "1976cfe3a5174f9ba768677f789cad7e",
+        content: "请输出纯文本的回答,不要markdown:如何学习英语?",
+        messageId: Date.now(),
+        applicationId: "",
+      },
+      {
+        headers: {
+          "Team-Id": "123456",
+          Authorization: "dffb9e92eec04c30bb441a5316a4e55c",
+          "Content-Type": "application/json",
+        },
+        onDownloadProgress: ({ event }) => {
+          //console.log(event);
+          const xhr = event.target;
+          const { responseText } = xhr;
+          //   // Always process the final line
+          //   const lastIndex = responseText.lastIndexOf(
+          //     "\n",
+          //     responseText.length - 2
+          //   );
+          //   let chunk = responseText;
+
+          //   if (lastIndex !== -1) chunk = responseText.substring(lastIndex);
+          //console.log(responseText, "chunk====");
+          text.value = responseText;
+          queue.add(async () => {
+            await splitMessage(responseText);
+          });
+        },
+      }
+    );
+    console.log(response);
+    console.log(messageQueue, "messageQueue=======0000");
+  } catch (error) {
+    console.error("下载文件时出错:", error);
+  }
+};
+onMounted(() => {});
+</script>

+ 57 - 0
src/views/demo/Tts.vue

@@ -0,0 +1,57 @@
+<template>
+  <div>
+    <div class="model-viewer" ref="modelViewer"></div>
+    <button @click="playTTS">播放语音</button>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import axios from "axios";
+
+const modelViewer = ref(null);
+
+const playTTS = async () => {
+  const ttsMessage =
+    "替换为你想要播报的文本"; // 替换为你想要播报的文本
+
+  try {
+    const wgToken = `C1qziFGlIv3tnCQxcFaStrLuZOO2ZZXjN7FB_G0WlrOLjclfObbSaXAKzl4RWwQBf_0Zhsm0CoVvdVsYMD18iM_LJrxtn7LHJJQuF9UoUuF3fvqOwrG4EF6Z4GahtxtQ2oeaPQBBNKlgVW1xUW7tkhEdXWqzDHPA_I_91Lczk0PI4guhx1c88Hst4-HI8pdMbiUdEJzj3d3a2W06Fa0XA9Q0taAwaRd1k9jUrDVyj9GfS84_SIgJF4SPjWVfsraV79ieb_StgRcUwZjbscGPMlifnJD6F00wwNbxG7AuCHbl3EtMfSed1vuVx3AsizIckwzIVSVRpOGw72cdAMui-I6es9Ozj2ITzSa5KgyXEpX4qCHF1VcCM1wlHLQ_5hLnJIi4r8NsnJPsxMYrTw`;
+    const res = await axios.post(
+      `https://fls-ai-stg-sit.pingan.com.cn/openapi/ai/voice/tts/v2?channelId=ASP-TEST&sceneId=ASP-TEST&token=${wgToken}`,
+      { sessionId: "N7FB_G0WlrOLjc", text: ttsMessage },
+      {
+        responseType: "arraybuffer",
+        headers: {
+          "X-Ai-TTS-Appid": "2b1317fb5b284b308dc90a6fdeae6c4e",
+        },
+      }
+    );
+    console.log(res.data);
+
+    // 播放获取到的音频
+    playAudio(res.data);
+  } catch (error) {
+    console.error("Error calling TTS API:", error);
+  }
+};
+
+const playAudio = (audioData) => {
+  const blob = new Blob([audioData], { type: "audio/wav" });
+  const url = URL.createObjectURL(blob);
+  const audio = new Audio(url);
+  audio.onended = () => URL.revokeObjectURL(url);
+  audio.onerror = (error) => console.error("Audio playback error:", error);
+  audio
+    .play()
+    .then(() => console.log("Audio playing"))
+    .catch((error) => console.error("Error playing audio:", error));
+};
+</script>
+
+<style scoped>
+.model-viewer {
+  width: 100%;
+  height: 400px;
+}
+</style>

+ 92 - 0
src/views/result/randarPage.vue

@@ -0,0 +1,92 @@
+<script setup lang="ts">
+import { onMounted, defineProps, nextTick, watch ,ref} from 'vue'
+import * as echarts from 'echarts';
+const props = defineProps({
+    randarList: Array // 或者根据你的需求,使用其他类型
+});
+const list = ref([0,0,0,0])
+const echartsRander = (randarList) => {
+    const dom = document.getElementById('chartsId');
+    if (!dom) return;
+    const myChart = echarts.init(dom);
+
+    const option = {
+        color: ['#56A3F1'],
+        radar: {
+            center: ['51%', '50%'], // 设置雷达图的中心位置,避免坐标轴名称被遮挡
+            radius: '58%',
+            indicator: [
+                { name: '自我介绍专业性', max: 25 },
+                { name: '客户画像采集', max: 25 },
+                { name: '需求挖掘能力', max: 25 },
+                { name: '沟通转化效果', max: 25 }
+            ],
+            axisName: {
+                color: '#fff',
+                backgroundColor: '#666',
+                borderRadius: 3,
+                padding: [3, 3]
+            }
+        },
+        series: [
+            {
+                type: 'radar',
+                data: [
+                    {
+                        symbol: 'rect',
+                        symbolSize: 8,
+                        lineStyle: {
+                            type: 'dashed'
+                        },
+                        value: randarList,
+                        label: {
+                            show: true,
+                            formatter: function (params) {
+                                return params.value;
+                            }
+                        },
+                    }
+                ]
+            }
+        ]
+    };
+
+    option && myChart.setOption(option);
+    window.addEventListener('resize', function () {
+        myChart.resize();
+    });
+}
+
+// onMounted(() => {
+
+//     nextTick(()=>{
+//         console.log(props.randarList,'00000000000')
+//         echartsRander(props.randarList)
+//     })
+//     // echartsRander(props.randarList)
+// })
+
+onMounted(() => {
+    nextTick(() => {
+        if (props.randarList && props.randarList.length > 0) {
+            list.value = props.randarList;
+            echartsRander(props.randarList);
+        }
+    });
+});
+
+// 使用 watch 来观察 props.randarList 的变化
+watch(() => props.randarList, (newVal, oldVal) => {
+    if (newVal && newVal.length > 0) {
+        list.value = newVal
+        echartsRander(newVal);
+    }
+});
+
+</script>
+<template>
+    <!-- <div> -->
+        <div id="chartsId" style="width: 100%;height: 100%;"></div>
+        <!-- <div v-else>暂无数据</div> -->
+    <!-- </div> -->
+</template>

+ 133 - 0
src/views/result/selectPage.vue

@@ -0,0 +1,133 @@
+<template>
+    <div class="custom-select" ref="selectRef">
+        <!-- 触发区域 -->
+        <div class="custom-select-trigger" @click="toggleDropdown">
+            <span class="selected-text">{{ selectedOption?.text || placeholder }}</span>
+            <van-icon name="arrow-down" class="dropdown-icon" />
+        </div>
+
+        <!-- 下拉框内容 -->
+        <div v-if="showDropdown" class="custom-dropdown"
+            :style="{ top: dropdownStyle.top + 'px', left: dropdownStyle.left + 'px', width: dropdownStyle.width + 'px' }">
+            <div v-for="(option, index) in optionValue" :key="index" class="dropdown-item"
+                :class="{ 'dropdown-item-selected': option.value === selectedOption?.value }"
+                @click="selectOption(option)">
+                {{ option.text }}
+            </div>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { ref, nextTick,defineProps,defineEmits,onMounted,onBeforeUnmount} from 'vue';
+
+defineProps({
+    optionValue: {
+        type: Array,
+        required: true,
+    },
+    placeholder: {
+        type: String,
+        default: '请选择',
+    }
+})
+const emit = defineEmits(['update:selectedOption']);
+const showDropdown = ref(false);
+const selectedOption = ref(null);
+
+const selectRef = ref(null);
+const dropdownStyle = ref({});
+
+const toggleDropdown = async () => {
+    showDropdown.value = !showDropdown.value;
+    if (showDropdown.value) {
+        await nextTick();
+        updateDropdownPosition();
+    }
+};
+
+const selectOption = (option) => {
+    selectedOption.value = option;
+    emit('update:selectedOption', option);
+    showDropdown.value = false;
+};
+
+const updateDropdownPosition = () => {
+    const triggerRect = selectRef.value.getBoundingClientRect();
+    dropdownStyle.value = {
+        top: `${triggerRect.height}px`,
+        left: '0px',
+        width: `${triggerRect.width}px`,
+    };
+};
+
+const handleDocumentClick = (event) => {
+    if (!selectRef.value.contains(event.target)) {
+        showDropdown.value = false;
+    }
+};
+
+onMounted(() => {
+    document.addEventListener('click', handleDocumentClick);
+});
+
+onBeforeUnmount(() => {
+    document.removeEventListener('click', handleDocumentClick);
+});
+
+</script>
+
+<style scoped>
+.custom-select {
+    width: 11.56rem;
+    position: relative;
+}
+
+.custom-select-trigger {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 0 10px;
+    border: 1px solid #dcdfe6;
+    background: #fff;
+    border-radius: 0.38rem;
+    height: 2rem;
+}
+
+.custom-dropdown {
+    position: absolute;
+    z-index: 1000;
+    background: #fff;
+    border: 1px solid #ebeef5;
+    border-radius: 0.38rem;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+    max-height: 10rem;
+    overflow-y: auto;
+    width: 100%;
+    /* 保持与触发器等宽 */
+}
+
+.selected-text{
+    background-color: rgba(255,255,255,1);
+    color: rgba(16,16,16,1);
+    font-size: 0.88rem;
+    text-align: left;
+    font-family: PingFangSC-regular;
+}
+
+.dropdown-item {
+    padding: 5px 8px;
+    font-size: 0.88rem;
+    text-align: left;
+    cursor: pointer;
+    font-family: PingFangSC-regular;
+}
+
+.dropdown-item:hover {
+    background-color: #f5f7fa;
+}
+.dropdown-item-selected {
+    color: #fff;
+    background-color: #4095e5;
+}
+</style>

+ 25 - 0
src/views/utils/api.js

@@ -0,0 +1,25 @@
+import request from "./request";
+
+// 创建对话
+export async function fetchTaskCreate(data) {
+  return request(`/openapi-prd/ai/intelligent-tutoring/task/create`,{
+    method:"POST",
+    data,
+  });
+}
+
+//获取分数
+
+export async function fetchTaskScore(conversationId) {
+  return request(`/openapi-prd/ai/intelligent-tutoring/task/score?conversationId=${conversationId}`,{
+    method:"get",
+  });
+}
+
+//查询任务结束状态
+
+export async function fetchTaskStatus(conversationId) {
+  return request(`/openapi-prd/ai/intelligent-tutoring/task/endFlag?conversationId=${conversationId}`,{
+    method:"get",
+  });
+}

+ 46 - 0
src/views/utils/request.js

@@ -0,0 +1,46 @@
+import axios, { CanceledError } from 'axios'
+import { showToast } from 'vant'
+
+// 创建一个 axios 实例
+const service = axios.create({
+  baseURL: "", // 所有的请求地址前缀部分
+  timeout: 60000, // 请求超时时间毫秒
+  headers: {
+    // 设置后端需要的传参类型
+    'Content-Type': 'application/json'
+  }
+})
+
+// 添加请求拦截器
+service.interceptors.request.use(
+  (config) => {
+    return config
+  },
+  (error) => {
+    // 对请求错误做些什么
+    showToast('网络请求失败')
+    return Promise.reject(error)
+  }
+)
+
+// 添加响应拦截器
+service.interceptors.response.use(
+  (response) => {
+    const res = response.data
+    // 对响应错误做点什么
+    if (res.code !== 200) {
+        showToast(res.msg)
+    }
+    return res
+  },
+  (error) => {
+    // 对响应错误做点什么
+    if (error instanceof CanceledError) {
+      return Promise.reject(error)
+    }
+    showToast(error.response?.data?.message || error.message)
+    return Promise.reject(error)
+  }
+)
+
+export default service

+ 47 - 0
vite.config.js

@@ -0,0 +1,47 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import fs from 'fs'
+
+const stgToken = 'C1qziFGlIv3tnCQxcFaStrLuZOO2ZZXjN7FB_G0WlrOLjclfObbSaXAKzl4RWwQBf_0Zhsm0CoVvdVsYMD18iM_LJrxtn7LHJJQuF9UoUuF3fvqOwrG4EF6Z4GahtxtQ2oeaPQBBNKlgVW1xUW7tkhEdXWqzDHPA_I_91Lczk0PI4guhx1c88Hst4-HI8pdMbiUdEJzj3d3a2W06Fa0XA9Q0taAwaRd1k9jUrDVyj9GfS84_SIgJF4SPjWVfsraV79ieb_StgRcUwZjbscGPMlifnJD6F00wwNbxG7AuCHbl3EtMfSed1vuVx3AsizIckwzIVSVRpOGw72cdAMui-I6es9Ozj2ITzSa5KgyXEpX4qCHF1VcCM1wlHLQ_5hLnJIi4r8NsnJPsxMYrTw'
+
+const prdToken = '2g4pgi27kWjPBjaKoHSfZAyfOJxuuEthWY1mk9tUbyPSrr6sn_6aPGaTxT7_ldXoKIJuMt8C7V636_jIq6fzaSfqIj8OQyhUPKPMa2eZjLlblT77ySqBt_lYM6iEAhrj7-raGmySMmkLS4Rqh651Ak2tqmUbjS64cqv5ofMsuadOCg1J-CtLFt7NeSoU4N3Kpm5MJ_4sOFBhQGfBym88dcwxosFl9LbvhpyleXFf6fOZkkOj0l2X8Nr2pfNjYs3_VOmCQxrxXh1XZ_a1v9qj5_rA9k9wGNNQfmr2JwJTUT4V9NwtNq94gNFt8C0J6MWKRHvYyb2XRkaCnEKu8QiNElcIwU9l9azouu5Lc3uafG1dd98yFm9I784oNWvIpVjUrsTkFiJxNGTz'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    host: '0.0.0.0',
+    proxy: {
+      '/openapi-stg': {
+        target: 'https://fls-ai-stg-sit.pingan.com.cn/openapi', // 设置代理目标
+        changeOrigin: true,
+        rewrite: (path) => {
+          // 提取原始路径
+          const originalPath = path.replace(/^\/openapi-stg/, '');
+          // 判断是否包含查询参数,如果有则追加 &,否则追加 ?
+          const separator = originalPath.indexOf('?') === -1 ? '?' : '&';
+          // 拼接 token 参数
+          return `${originalPath}${separator}token=${stgToken}&channelId=ASP-TEST&sceneId=ASP-TEST`;
+        }
+      },
+      '/openapi-prd': {
+        target: 'https://fls-ai.pingan.com.cn/openapi', // 设置代理目标
+        changeOrigin: true,
+        //rewrite: (path) => path.replace(/^\/openapi-prd/, '') // 重写路径
+        rewrite: (path) => {
+          // 提取原始路径
+          const originalPath = path.replace(/^\/openapi-prd/, '');
+          // 判断是否包含查询参数,如果有则追加 &,否则追加 ?
+          const separator = originalPath.indexOf('?') === -1 ? '?' : '&';
+          // 拼接 token 参数
+          return `${originalPath}${separator}token=${prdToken}&channelId=ASP-TEST&sceneId=ASP-TEST`;
+        }
+      },
+      '/testTts': {
+        target: 'http://192.168.92.190:5001/', // 设置代理目标
+        changeOrigin: true,
+        rewrite: (path) => path.replace(/^\/testTts/, '') // 重写路径
+      }
+    }
+  }
+})