Xin Zhang
commited on
Commit
·
b1cc7ae
1
Parent(s):
5873ede
[feature]: add frontend src.
Browse files- frontend/.gitattributes +36 -0
- frontend/.gitignore +26 -0
- frontend/README.md +36 -0
- frontend/index.html +2 -4
- frontend/package.json +42 -0
- frontend/postcss.config.js +6 -0
- frontend/{favicon.ico → public/favicon.ico} +0 -0
- frontend/src/App.vue +69 -0
- frontend/src/config/axios/config.ts +48 -0
- frontend/src/config/axios/index.ts +75 -0
- frontend/src/config/client_config.ts +23 -0
- frontend/src/hooks/showError.ts +3 -0
- frontend/src/hooks/useCache.ts +17 -0
- frontend/src/index.d.ts +34 -0
- frontend/src/main.ts +28 -0
- frontend/src/router.ts +43 -0
- frontend/src/stores/config.ts +20 -0
- frontend/src/stores/measure.ts +15 -0
- frontend/src/stores/session.ts +269 -0
- frontend/src/style.scss +156 -0
- frontend/src/utils/audio_utils.ts +83 -0
- frontend/src/utils/retry.ts +27 -0
- frontend/src/utils/size.ts +47 -0
- frontend/src/views/404/index.vue +24 -0
- frontend/src/views/Footer.vue +23 -0
- frontend/src/views/Header.vue +200 -0
- frontend/src/views/Home/index.vue +905 -0
- frontend/src/views/Settings/index.vue +103 -0
- frontend/src/vite-env.d.ts +1 -0
- frontend/tailwind.config.js +12 -0
- frontend/tsconfig.json +30 -0
- frontend/tsconfig.node.json +10 -0
- frontend/vite.config.ts +53 -0
- main.py +2 -2
- {frontend → web}/assets/index-b1f15c01.css +0 -0
- {frontend → web}/assets/index-fc3a0f87.js +0 -0
- web/favicon.ico +0 -0
- web/index.html +15 -0
frontend/.gitattributes
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
www
|
| 14 |
+
www-ssr
|
| 15 |
+
*.local
|
| 16 |
+
|
| 17 |
+
# Editor directories and files
|
| 18 |
+
.vscode
|
| 19 |
+
!.vscode/extensions.json
|
| 20 |
+
.idea
|
| 21 |
+
.DS_Store
|
| 22 |
+
*.suo
|
| 23 |
+
*.ntvs*
|
| 24 |
+
*.njsproj
|
| 25 |
+
*.sln
|
| 26 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
| 6 |
+
|
| 7 |
+
## Recommended IDE Setup
|
| 8 |
+
|
| 9 |
+
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
| 10 |
+
|
| 11 |
+
## Type Support For `.vue` Imports in TS
|
| 12 |
+
|
| 13 |
+
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
| 14 |
+
|
| 15 |
+
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
| 16 |
+
|
| 17 |
+
1. Disable the built-in TypeScript Extension
|
| 18 |
+
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
| 19 |
+
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
| 20 |
+
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
## 当前项目是一个独立的纯前端项目,按照以下流程运行
|
| 24 |
+
|
| 25 |
+
1. 安装依赖
|
| 26 |
+
```bash
|
| 27 |
+
yarn install
|
| 28 |
+
```
|
| 29 |
+
2. 启动项目(本地开发测试,后端api配置在config/client_config.ts中)
|
| 30 |
+
```bash
|
| 31 |
+
yarn dev
|
| 32 |
+
```
|
| 33 |
+
3. 打包项目 (打包后的项目在当前目录的www下,cp他们到python的web目录下)
|
| 34 |
+
```bash
|
| 35 |
+
yarn build
|
| 36 |
+
```
|
frontend/index.html
CHANGED
|
@@ -2,14 +2,12 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>Translator</title>
|
| 8 |
-
<script type="module" crossorigin src="./assets/index-fc3a0f87.js"></script>
|
| 9 |
-
<link rel="stylesheet" href="./assets/index-b1f15c01.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div id="app"></div>
|
| 13 |
-
|
| 14 |
</body>
|
| 15 |
</html>
|
|
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>Translator</title>
|
|
|
|
|
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="app"></div>
|
| 11 |
+
<script type="module" src="/src/main.ts"></script>
|
| 12 |
</body>
|
| 13 |
</html>
|
frontend/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "RealTime-Translator",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.1",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": " vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"generate-client": "openapi --input api.json --output ./src/client --client axios",
|
| 11 |
+
"update-api": "wget http://localhost:8086/api/openapi.json -O api.json && yarn generate-client"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@ricky0123/vad-web": "^0.0.18",
|
| 15 |
+
"@types/node": "^20.6.2",
|
| 16 |
+
"ant-design-vue": "4.x",
|
| 17 |
+
"axios": "^1.6.0",
|
| 18 |
+
"pinia": "^2.1.6",
|
| 19 |
+
"pinia-plugin-persist": "^1.0.0",
|
| 20 |
+
"pinia-plugin-persistedstate": "^3.2.0",
|
| 21 |
+
"qs": "^6.11.2",
|
| 22 |
+
"tools-javascript": "^1.0.26",
|
| 23 |
+
"tools-vue3": "^1.1.5",
|
| 24 |
+
"vue": "3.3.4",
|
| 25 |
+
"vue-i18n": "^9.10.1",
|
| 26 |
+
"vue-router": "4",
|
| 27 |
+
"web-storage-cache": "^1.1.1"
|
| 28 |
+
},
|
| 29 |
+
"devDependencies": {
|
| 30 |
+
"@types/spark-md5": "^3.0.4",
|
| 31 |
+
"@vitejs/plugin-vue": "^4.2.3",
|
| 32 |
+
"autoprefixer": "^10.4.16",
|
| 33 |
+
"openapi-typescript-codegen": "^0.25.0",
|
| 34 |
+
"postcss": "^8.4.31",
|
| 35 |
+
"sass": "^1.68.0",
|
| 36 |
+
"tailwindcss": "^3.3.5",
|
| 37 |
+
"typescript": "^5.0.2",
|
| 38 |
+
"vite": "^4.4.5",
|
| 39 |
+
"vite-plugin-static-copy": "^1.0.1",
|
| 40 |
+
"vue-tsc": "^1.8.5"
|
| 41 |
+
}
|
| 42 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/{favicon.ico → public/favicon.ico}
RENAMED
|
File without changes
|
frontend/src/App.vue
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<!-- <Header/> -->
|
| 3 |
+
<router-view class="content"/>
|
| 4 |
+
<!-- <Footer/> -->
|
| 5 |
+
</template>
|
| 6 |
+
|
| 7 |
+
<script setup lang="ts">
|
| 8 |
+
import Header from "@/views/Header.vue";
|
| 9 |
+
import Footer from "@/views/Footer.vue";
|
| 10 |
+
|
| 11 |
+
// import * as api from "@/client";
|
| 12 |
+
import {onBeforeMount, onMounted,watch, provide, ref} from "vue";
|
| 13 |
+
import {useSettingsStore} from "@/stores/config.ts";
|
| 14 |
+
|
| 15 |
+
import axios from "axios";
|
| 16 |
+
import {getRandomNumInt} from "@/utils/size.ts";
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
const base_url = axios.defaults.baseURL
|
| 20 |
+
|
| 21 |
+
// const registerSession = async () => {
|
| 22 |
+
// console.log('register ...')
|
| 23 |
+
// const role = settingsStore.$state.role_name
|
| 24 |
+
|
| 25 |
+
// const response = await fetch(`${base_url}/register?role=${role}`)
|
| 26 |
+
// const res = await response.json()
|
| 27 |
+
// console.log('res: ', res)
|
| 28 |
+
// return res['session_id']
|
| 29 |
+
// }
|
| 30 |
+
|
| 31 |
+
// watch(() => settingsStore.$state.role_name, async (role_name: any) => {
|
| 32 |
+
// console.log('>>>>> role updated', role_name)
|
| 33 |
+
// let session_id = await registerSession()
|
| 34 |
+
// if (!session_id) {
|
| 35 |
+
// console.log('register session failed')
|
| 36 |
+
// session_id = getRandomNumInt(100000, 999999)
|
| 37 |
+
// }
|
| 38 |
+
// // @ts-ignore
|
| 39 |
+
// sessionsStore.$patch({current_session_id: session_id + ''})
|
| 40 |
+
// console.log('session id: ', sessionsStore.$state.current_session_id)
|
| 41 |
+
// })
|
| 42 |
+
|
| 43 |
+
onMounted(async () => {
|
| 44 |
+
// console.log('app mounted', settingsStore.$state)
|
| 45 |
+
|
| 46 |
+
// let session_id = await registerSession()
|
| 47 |
+
// if (!session_id) {
|
| 48 |
+
// console.log('register session failed')
|
| 49 |
+
// session_id = getRandomNumInt(100000, 999999)
|
| 50 |
+
// }
|
| 51 |
+
// // @ts-ignore
|
| 52 |
+
// sessionsStore.$patch({current_session_id: session_id + ''})
|
| 53 |
+
// console.log('session id: ', sessionsStore.$state.current_session_id)
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
</script>
|
| 57 |
+
|
| 58 |
+
<style scoped>
|
| 59 |
+
.content {
|
| 60 |
+
background-color: white;
|
| 61 |
+
max-width: 1280px;
|
| 62 |
+
min-height: 720px;
|
| 63 |
+
margin: 0 auto;
|
| 64 |
+
display: flex;
|
| 65 |
+
flex-direction: column;
|
| 66 |
+
align-items: center;
|
| 67 |
+
justify-content: space-between;
|
| 68 |
+
}
|
| 69 |
+
</style>
|
frontend/src/config/axios/config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
const config: {
|
| 4 |
+
base_url: {
|
| 5 |
+
base: string
|
| 6 |
+
dev: string
|
| 7 |
+
pro: string
|
| 8 |
+
test: string
|
| 9 |
+
}
|
| 10 |
+
result_code: number | string
|
| 11 |
+
default_headers: AxiosHeaders
|
| 12 |
+
request_timeout: number
|
| 13 |
+
} = {
|
| 14 |
+
/**
|
| 15 |
+
* api请求基础路径
|
| 16 |
+
*/
|
| 17 |
+
base_url: {
|
| 18 |
+
// 开发环境接口前缀
|
| 19 |
+
base: '',
|
| 20 |
+
|
| 21 |
+
// 打包开发环境接口前缀
|
| 22 |
+
dev: '',
|
| 23 |
+
|
| 24 |
+
// 打包生产环境接口前缀
|
| 25 |
+
pro: '',
|
| 26 |
+
|
| 27 |
+
// 打包测试环境接口前缀
|
| 28 |
+
test: ''
|
| 29 |
+
},
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* 接口成功返回状态码
|
| 33 |
+
*/
|
| 34 |
+
result_code: '0000',
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* 接口请求超时时间
|
| 38 |
+
*/
|
| 39 |
+
request_timeout: 60000,
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* 默认接口请求类型
|
| 43 |
+
* 可选值:application/x-www-form-urlencoded multipart/form-data
|
| 44 |
+
*/
|
| 45 |
+
default_headers: 'application/json'
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export { config }
|
frontend/src/config/axios/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios, {
|
| 2 |
+
AxiosInstance,
|
| 3 |
+
AxiosRequestConfig,
|
| 4 |
+
AxiosRequestHeaders,
|
| 5 |
+
AxiosResponse,
|
| 6 |
+
AxiosError
|
| 7 |
+
} from 'axios'
|
| 8 |
+
|
| 9 |
+
import { notification } from 'ant-design-vue';
|
| 10 |
+
|
| 11 |
+
import qs from 'qs'
|
| 12 |
+
|
| 13 |
+
import { config } from '@/config/axios/config'
|
| 14 |
+
|
| 15 |
+
const { result_code, base_url } = config
|
| 16 |
+
|
| 17 |
+
// export const PATH_URL = base_url[import.meta.env.VITE_API_BASEPATH]
|
| 18 |
+
|
| 19 |
+
// export const PATH_URL = 'localhost:8000/'
|
| 20 |
+
export const PATH_URL = '/'
|
| 21 |
+
|
| 22 |
+
// 创建axios实例
|
| 23 |
+
const service: AxiosInstance = axios.create({
|
| 24 |
+
baseURL: PATH_URL, // api 的 base_url
|
| 25 |
+
timeout: config.request_timeout // 请求超时时间
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
// request拦截器
|
| 29 |
+
service.interceptors.request.use(
|
| 30 |
+
(config: any ) => {
|
| 31 |
+
if (
|
| 32 |
+
config.method === 'post' &&
|
| 33 |
+
(config.headers as AxiosRequestHeaders)['Content-Type'] ===
|
| 34 |
+
'application/x-www-form-urlencoded'
|
| 35 |
+
) {
|
| 36 |
+
config.data = qs.stringify(config.data)
|
| 37 |
+
}
|
| 38 |
+
// get参数编码
|
| 39 |
+
if (config.method === 'get' && config.params) {
|
| 40 |
+
let url = config.url as string
|
| 41 |
+
url += '?'
|
| 42 |
+
const keys = Object.keys(config.params)
|
| 43 |
+
for (const key of keys) {
|
| 44 |
+
if (config.params[key] !== void 0 && config.params[key] !== null) {
|
| 45 |
+
url += `${key}=${encodeURIComponent(config.params[key])}&`
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
url = url.substring(0, url.length - 1)
|
| 49 |
+
config.params = {}
|
| 50 |
+
config.url = url
|
| 51 |
+
}
|
| 52 |
+
return config
|
| 53 |
+
},
|
| 54 |
+
(error: AxiosError) => {
|
| 55 |
+
// Do something with request error
|
| 56 |
+
Promise.reject(error)
|
| 57 |
+
}
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
service.interceptors.response.use(
|
| 61 |
+
(response: any) => {
|
| 62 |
+
if (response.data.code === result_code) {
|
| 63 |
+
return response.data
|
| 64 |
+
} else {
|
| 65 |
+
notification.error(response.data.message)
|
| 66 |
+
}
|
| 67 |
+
},
|
| 68 |
+
(error: AxiosError) => {
|
| 69 |
+
console.log('err' + error) // for debug
|
| 70 |
+
notification.error(error)
|
| 71 |
+
return Promise.reject(error)
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
export { service }
|
frontend/src/config/client_config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios, {AxiosResponse} from "axios";
|
| 2 |
+
import {useCache} from "@/hooks/useCache";
|
| 3 |
+
import {toggleError} from '@/hooks/showError'
|
| 4 |
+
import router from "@/router";
|
| 5 |
+
|
| 6 |
+
const { wsCache } = useCache();
|
| 7 |
+
|
| 8 |
+
export const test_server = '127.0.0.1:9191'
|
| 9 |
+
// export const test_server = '59.110.18.232:19001'
|
| 10 |
+
|
| 11 |
+
axios.defaults.baseURL = import.meta.env.PROD ? '/api' : `http://${test_server}/api`;
|
| 12 |
+
axios.interceptors.request.use(
|
| 13 |
+
(config: any) => {
|
| 14 |
+
// Do something before request is sent
|
| 15 |
+
const {wsCache} = useCache()
|
| 16 |
+
const token = wsCache.get('token')
|
| 17 |
+
if (token) {
|
| 18 |
+
//@ts-ignore
|
| 19 |
+
config.headers.Authorization = 'Bearer ' + token
|
| 20 |
+
}
|
| 21 |
+
return config;
|
| 22 |
+
}
|
| 23 |
+
)
|
frontend/src/hooks/showError.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { reactive} from 'vue'
|
| 2 |
+
const toggleError = reactive({show:false, title:'error',msg:'Failed to connect to server'})
|
| 3 |
+
export {toggleError}
|
frontend/src/hooks/useCache.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 配置浏览器本地存储的方式,可直接存储对象数组。
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import WebStorageCache from 'web-storage-cache'
|
| 6 |
+
|
| 7 |
+
type CacheType = 'sessionStorage' | 'localStorage'
|
| 8 |
+
|
| 9 |
+
export const useCache = (type: CacheType = 'sessionStorage') => {
|
| 10 |
+
const wsCache: WebStorageCache = new WebStorageCache({
|
| 11 |
+
storage: type
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
return {
|
| 15 |
+
wsCache
|
| 16 |
+
}
|
| 17 |
+
}
|
frontend/src/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
| 2 |
+
declare module "*.txt" {
|
| 3 |
+
const content: string;
|
| 4 |
+
export default content;
|
| 5 |
+
}
|
| 6 |
+
declare module '*.vue' {
|
| 7 |
+
import { DefineComponent } from 'vue'
|
| 8 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
| 9 |
+
const component: DefineComponent<{}, {}, any>
|
| 10 |
+
export default component
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
declare module 'qs';
|
| 14 |
+
|
| 15 |
+
declare type Recordable<T = any, K = string> = Record<K extends null | undefined ? string : K, T>
|
| 16 |
+
|
| 17 |
+
declare type AxiosHeaders =
|
| 18 |
+
| 'application/json'
|
| 19 |
+
| 'application/x-www-form-urlencoded'
|
| 20 |
+
| 'multipart/form-data'
|
| 21 |
+
|
| 22 |
+
declare type AxiosMethod = 'get' | 'post' | 'delete' | 'put'
|
| 23 |
+
|
| 24 |
+
declare type AxiosResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'
|
| 25 |
+
|
| 26 |
+
declare type AxiosConfig = {
|
| 27 |
+
params?: any
|
| 28 |
+
data?: any
|
| 29 |
+
url?: string
|
| 30 |
+
method?: AxiosMethod
|
| 31 |
+
headersType?: string
|
| 32 |
+
responseType?: AxiosResponseType
|
| 33 |
+
}
|
| 34 |
+
|
frontend/src/main.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createApp } from 'vue'
|
| 2 |
+
import Antd from 'ant-design-vue';
|
| 3 |
+
import './config/client_config'
|
| 4 |
+
import { createPinia } from 'pinia';
|
| 5 |
+
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
| 6 |
+
import 'ant-design-vue/dist/reset.css';
|
| 7 |
+
import './style.scss'
|
| 8 |
+
|
| 9 |
+
import App from './App.vue'
|
| 10 |
+
import router from './router'
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
// import * as Sentry from "@sentry/browser";
|
| 14 |
+
//
|
| 15 |
+
// Sentry.init({
|
| 16 |
+
// dsn: "http://1f5e3e8958a24528b5030068902e177e@127.0.0.1:8543/7",
|
| 17 |
+
// debug: true,
|
| 18 |
+
// });
|
| 19 |
+
|
| 20 |
+
const pinia = createPinia();
|
| 21 |
+
pinia.use(piniaPluginPersistedstate);
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
createApp(App)
|
| 25 |
+
.use(pinia)
|
| 26 |
+
.use(router)
|
| 27 |
+
.use(Antd)
|
| 28 |
+
.mount('#app')
|
frontend/src/router.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
| 2 |
+
|
| 3 |
+
import NotFoundVue from '@/views/404/index.vue';
|
| 4 |
+
import WelcomeVue from '@/views/Home/index.vue';
|
| 5 |
+
import SettingsVue from '@/views/Settings/index.vue';
|
| 6 |
+
|
| 7 |
+
const routes: Array<RouteRecordRaw> = [
|
| 8 |
+
{
|
| 9 |
+
name:"home",
|
| 10 |
+
path: '/',
|
| 11 |
+
component: WelcomeVue,
|
| 12 |
+
meta: {
|
| 13 |
+
requiresAgreement: false,
|
| 14 |
+
}
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
name:"settings",
|
| 18 |
+
path:'/settings',
|
| 19 |
+
component: SettingsVue,
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
name:"404",
|
| 23 |
+
path:'/404',
|
| 24 |
+
component: NotFoundVue,
|
| 25 |
+
}
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
const router = createRouter({
|
| 29 |
+
// history: createWebHistory(),
|
| 30 |
+
history: createWebHistory('/app/'),
|
| 31 |
+
routes,
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
router.beforeEach((to, from, next) => {
|
| 35 |
+
console.log('=============== router to : ', to)
|
| 36 |
+
if (to.matched.length === 0) {
|
| 37 |
+
next({ name: '404' });
|
| 38 |
+
} else {
|
| 39 |
+
next();
|
| 40 |
+
}
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
export default router;
|
frontend/src/stores/config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { defineStore } from 'pinia';
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
export const useSettingsStore = defineStore({
|
| 6 |
+
id: 'settings',
|
| 7 |
+
persist: true,
|
| 8 |
+
state: () => {
|
| 9 |
+
return {
|
| 10 |
+
vad: 0.3,
|
| 11 |
+
fs: 'trans-font-size-18',
|
| 12 |
+
width_max: false,
|
| 13 |
+
role_name: 'assistant',
|
| 14 |
+
file_type: 'wav',
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
actions: {
|
| 18 |
+
}
|
| 19 |
+
});
|
| 20 |
+
|
frontend/src/stores/measure.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineStore } from 'pinia';
|
| 2 |
+
|
| 3 |
+
export const useMeasureStore = defineStore({
|
| 4 |
+
id: 'measure_store',
|
| 5 |
+
persist: false,
|
| 6 |
+
state: () => {
|
| 7 |
+
return {
|
| 8 |
+
first_request_time: 0,
|
| 9 |
+
first_response_time: 0,
|
| 10 |
+
}
|
| 11 |
+
},
|
| 12 |
+
actions: {
|
| 13 |
+
}
|
| 14 |
+
});
|
| 15 |
+
|
frontend/src/stores/session.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineStore } from 'pinia';
|
| 2 |
+
import { ref, computed } from 'vue';
|
| 3 |
+
|
| 4 |
+
// 定义会话中单个节点的结构 (如果比纯文本更复杂)
|
| 5 |
+
export interface SessionNode {
|
| 6 |
+
id: string; // 或者其他唯一标识符
|
| 7 |
+
text: string;
|
| 8 |
+
translatedText?: string; // 可选的翻译文本
|
| 9 |
+
timestamp: number; // 时间戳
|
| 10 |
+
// 可以添加其他元数据,如语言、说话人等
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// 定义存储在 Pinia 和用于 Modal 列表的会话摘要结构
|
| 14 |
+
export interface SessionSummary {
|
| 15 |
+
startTime: number; // 作为唯一 ID 和排序依据
|
| 16 |
+
title: string; // 第一句话的前10个字
|
| 17 |
+
outline: string[]; // 前两行内容
|
| 18 |
+
nodeCount: number; // 会话中的节点总数
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const LOCAL_STORAGE_SESSION_PREFIX = 'rt_session_'; // 本地存储键前缀
|
| 22 |
+
|
| 23 |
+
export const useSessionStore = defineStore('session', () => {
|
| 24 |
+
// --- State ---
|
| 25 |
+
|
| 26 |
+
// 会话摘要列表,将由 pinia-plugin-persistedstate 自动持久化
|
| 27 |
+
const sessionSummaries = ref<SessionSummary[]>([]);
|
| 28 |
+
|
| 29 |
+
// 当前活动会话的节点 (不持久化)
|
| 30 |
+
const currentSessionNodes = ref<SessionNode[]>([]);
|
| 31 |
+
// 当前活动会话的开始时间 (不持久化)
|
| 32 |
+
const currentSessionStartTime = ref<number | null>(null);
|
| 33 |
+
// 标记会话是否正在进行中 (不持久化)
|
| 34 |
+
const isSessionActive = ref(false);
|
| 35 |
+
|
| 36 |
+
// --- Getters ---
|
| 37 |
+
|
| 38 |
+
// 按开始时间降序排列的会话摘要
|
| 39 |
+
const sortedSessionSummaries = computed(() => {
|
| 40 |
+
// 创建副本进行排序,避免直接修改响应式 ref
|
| 41 |
+
return [...sessionSummaries.value].sort((a, b) => b.startTime - a.startTime);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
// --- Actions ---
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* 开始一个新的会话
|
| 48 |
+
*/
|
| 49 |
+
function startSession() {
|
| 50 |
+
if (isSessionActive.value) {
|
| 51 |
+
console.warn("尝试在已有活动会话时开始新会话。");
|
| 52 |
+
// 可以选择结束旧会话或直接返回
|
| 53 |
+
// endSession(); // 如果需要自动结束旧会话
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
currentSessionStartTime.value = Date.now();
|
| 57 |
+
currentSessionNodes.value = [];
|
| 58 |
+
isSessionActive.value = true;
|
| 59 |
+
console.log(`新会话开始于: ${new Date(currentSessionStartTime.value).toLocaleString()}`);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* 向当前活动会话添加一个节点
|
| 64 |
+
* @param node - 要添加的会话节点
|
| 65 |
+
*/
|
| 66 |
+
function addNode(node: SessionNode) {
|
| 67 |
+
if (!isSessionActive.value || !currentSessionStartTime.value) {
|
| 68 |
+
console.warn("没有活动的会话来添加节点。");
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
currentSessionNodes.value.push(node);
|
| 72 |
+
// 可选:如果需要更强的容错性,可以在这里进行增量保存到 localStorage
|
| 73 |
+
// saveCurrentSessionToLocalStorage();
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* 结束当前活动会话,保存完整内容到 localStorage,并更新摘要列表
|
| 78 |
+
*/
|
| 79 |
+
function endSession() {
|
| 80 |
+
if (!isSessionActive.value || !currentSessionStartTime.value) {
|
| 81 |
+
console.log("没有活动的会话可以结束。");
|
| 82 |
+
// 确保状态被重置
|
| 83 |
+
isSessionActive.value = false;
|
| 84 |
+
currentSessionStartTime.value = null;
|
| 85 |
+
currentSessionNodes.value = [];
|
| 86 |
+
return;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const startTime = currentSessionStartTime.value;
|
| 90 |
+
const nodes = [...currentSessionNodes.value]; // 创建副本
|
| 91 |
+
|
| 92 |
+
// 重置当前会话状态
|
| 93 |
+
isSessionActive.value = false;
|
| 94 |
+
currentSessionStartTime.value = null;
|
| 95 |
+
currentSessionNodes.value = [];
|
| 96 |
+
|
| 97 |
+
if (nodes.length === 0) {
|
| 98 |
+
console.log("会话结束,但没有节点需要保存。");
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// 1. 生成摘要信息
|
| 103 |
+
const title = nodes[0]?.text.substring(0, 10) || '无标题会话';
|
| 104 |
+
const n1 = nodes[0];
|
| 105 |
+
const outline = [
|
| 106 |
+
`${n1?.text.substring(0, 56)}...\n`,
|
| 107 |
+
`${n1?.translatedText?.substring(0, 56)}...\n`,
|
| 108 |
+
]
|
| 109 |
+
// `${n1?.text.substring(0, 56)}\n${'-'.repeat(60)}\n${n1.translatedText?.substring(0, 56)}\n`
|
| 110 |
+
const summary: SessionSummary = {
|
| 111 |
+
startTime,
|
| 112 |
+
title,
|
| 113 |
+
outline,
|
| 114 |
+
nodeCount: nodes.length,
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
// 2. 保存完整会话内容到 Local Storage
|
| 118 |
+
try {
|
| 119 |
+
const storageKey = `${LOCAL_STORAGE_SESSION_PREFIX}${startTime}`;
|
| 120 |
+
localStorage.setItem(storageKey, JSON.stringify(nodes));
|
| 121 |
+
console.log(`完整会话 ${startTime} 已保存到 localStorage.`);
|
| 122 |
+
|
| 123 |
+
// 3. 更新 Pinia 中的摘要列表
|
| 124 |
+
// 检查是否已存在相同 startTime 的摘要 (理论上不应发生,除非手动操作或错误)
|
| 125 |
+
const existingIndex = sessionSummaries.value.findIndex(s => s.startTime === startTime);
|
| 126 |
+
if (existingIndex === -1) {
|
| 127 |
+
sessionSummaries.value.push(summary);
|
| 128 |
+
} else {
|
| 129 |
+
console.warn(`会话摘要 ${startTime} 已存在,将进行覆盖��`);
|
| 130 |
+
sessionSummaries.value[existingIndex] = summary;
|
| 131 |
+
}
|
| 132 |
+
// pinia-plugin-persistedstate 会自动处理 sessionSummaries 的持久化
|
| 133 |
+
|
| 134 |
+
console.log(`会话 ${startTime} 结束并已处理。`);
|
| 135 |
+
|
| 136 |
+
} catch (error) {
|
| 137 |
+
console.error("保存会话到 localStorage 时出错:", error);
|
| 138 |
+
// 这里可以添加用户反馈,例如提示存储空间不足
|
| 139 |
+
// 也许需要决定是否回滚摘要列表的添加
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/**
|
| 144 |
+
* 从 Local Storage 加载指定会话的完整内容
|
| 145 |
+
* @param startTime - 会话的开始时间戳 (作为 ID)
|
| 146 |
+
* @returns SessionNode[] | null - 会话节点数组或在未找到/出错时返回 null
|
| 147 |
+
*/
|
| 148 |
+
function loadSessionContent(startTime: number): SessionNode[] | null {
|
| 149 |
+
try {
|
| 150 |
+
const storageKey = `${LOCAL_STORAGE_SESSION_PREFIX}${startTime}`;
|
| 151 |
+
const storedData = localStorage.getItem(storageKey);
|
| 152 |
+
if (storedData) {
|
| 153 |
+
const nodes = JSON.parse(storedData) as SessionNode[];
|
| 154 |
+
console.log(`从 localStorage 加载了会话 ${startTime} 的内容 (${nodes.length} 个节点)`);
|
| 155 |
+
return nodes;
|
| 156 |
+
}
|
| 157 |
+
console.warn(`在 localStorage 中未找到键为 ${storageKey} 的会话数据。`);
|
| 158 |
+
return null;
|
| 159 |
+
} catch (error) {
|
| 160 |
+
console.error(`从 localStorage 加载会话 ${startTime} 时出错:`, error);
|
| 161 |
+
return null;
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/**
|
| 166 |
+
* 删除指定的会话 (包括摘要和本地存储的完整内容)
|
| 167 |
+
* @param startTime - 要删除的会话的开始时间戳
|
| 168 |
+
*/
|
| 169 |
+
function deleteSession(startTime: number) {
|
| 170 |
+
try {
|
| 171 |
+
// 1. 从摘要列表中移除
|
| 172 |
+
const index = sessionSummaries.value.findIndex(s => s.startTime === startTime);
|
| 173 |
+
if (index > -1) {
|
| 174 |
+
sessionSummaries.value.splice(index, 1);
|
| 175 |
+
console.log(`会话摘要 ${startTime} 已从 Pinia store 中移除。`);
|
| 176 |
+
// pinia-plugin-persistedstate 会自动更新持久化的摘要列表
|
| 177 |
+
} else {
|
| 178 |
+
console.warn(`尝试删除一个不存在的会话摘要: ${startTime}`);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// 2. 从 Local Storage 中移除完整内容
|
| 182 |
+
const storageKey = `${LOCAL_STORAGE_SESSION_PREFIX}${startTime}`;
|
| 183 |
+
localStorage.removeItem(storageKey);
|
| 184 |
+
console.log(`会话 ${startTime} 的完整内容已从 localStorage 中移除。`);
|
| 185 |
+
|
| 186 |
+
} catch (error) {
|
| 187 |
+
console.error(`删除会话 ${startTime} 时出错:`, error);
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// --- 返回 State, Getters, Actions ---
|
| 192 |
+
return {
|
| 193 |
+
// State
|
| 194 |
+
sessionSummaries, // 摘要列表 (将被持久化)
|
| 195 |
+
currentSessionNodes, // 当前活动会话的节点 (用于可能的实时显示)
|
| 196 |
+
currentSessionStartTime, // 当前活动会话的开始时间
|
| 197 |
+
isSessionActive, // 会话是否活动
|
| 198 |
+
|
| 199 |
+
// Getters
|
| 200 |
+
sortedSessionSummaries, // 排序后的摘要列表
|
| 201 |
+
|
| 202 |
+
// Actions
|
| 203 |
+
startSession,
|
| 204 |
+
addNode,
|
| 205 |
+
endSession,
|
| 206 |
+
loadSessionContent, // 用于下载按钮点击时加载数据
|
| 207 |
+
deleteSession,
|
| 208 |
+
};
|
| 209 |
+
}, {
|
| 210 |
+
// Pinia 持久化配置
|
| 211 |
+
persist: {
|
| 212 |
+
// 只持久化 sessionSummaries 状态
|
| 213 |
+
paths: ['sessionSummaries'],
|
| 214 |
+
// 默认使用 localStorage,如果需要可以指定
|
| 215 |
+
// storage: localStorage,
|
| 216 |
+
},
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
/**
|
| 220 |
+
* 辅助函数:触发浏览器下载会话数据
|
| 221 |
+
* @param startTime - 会话开始时间,用于文件名
|
| 222 |
+
* @param nodes - 要下载的会话节点数据
|
| 223 |
+
* @param format - 'json' 或 'txt' (默认为 'json')
|
| 224 |
+
*/
|
| 225 |
+
export function downloadSessionData(startTime: number, nodes: SessionNode[], format: 'json' | 'txt' = 'json') {
|
| 226 |
+
if (!nodes || nodes.length === 0) {
|
| 227 |
+
console.error("没有数据可供下载:", startTime);
|
| 228 |
+
alert("没有内容可以下载。"); // 给用户反馈
|
| 229 |
+
return;
|
| 230 |
+
}
|
| 231 |
+
try {
|
| 232 |
+
const dateStr = new Date(startTime).toISOString().split('T')[0]; // YYYY-MM-DD
|
| 233 |
+
let dataStr: string;
|
| 234 |
+
let mimeType: string;
|
| 235 |
+
let fileExtension: string;
|
| 236 |
+
|
| 237 |
+
if (format === 'txt') {
|
| 238 |
+
dataStr = nodes.map(n => `${new Date(n.timestamp).toLocaleTimeString()} - ${n.text}`).join('\n');
|
| 239 |
+
mimeType = 'text/plain;charset=utf-8;';
|
| 240 |
+
fileExtension = 'txt';
|
| 241 |
+
} else { // 默认为 json
|
| 242 |
+
dataStr = JSON.stringify(nodes, null, 2); // 美化 JSON 输出
|
| 243 |
+
mimeType = 'application/json;charset=utf-8;';
|
| 244 |
+
fileExtension = 'json';
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
const filename = `session_${dateStr}_${startTime}.${fileExtension}`;
|
| 248 |
+
const blob = new Blob([dataStr], { type: mimeType });
|
| 249 |
+
const link = document.createElement("a");
|
| 250 |
+
|
| 251 |
+
// 使用 createObjectURL 创建一个临时的 URL 指向 Blob 对象
|
| 252 |
+
const url = URL.createObjectURL(blob);
|
| 253 |
+
link.setAttribute("href", url);
|
| 254 |
+
link.setAttribute("download", filename);
|
| 255 |
+
link.style.visibility = 'hidden';
|
| 256 |
+
document.body.appendChild(link);
|
| 257 |
+
link.click(); // 模拟点击下载链接
|
| 258 |
+
|
| 259 |
+
// 清理:移除链接并释放 URL 对象
|
| 260 |
+
document.body.removeChild(link);
|
| 261 |
+
URL.revokeObjectURL(url);
|
| 262 |
+
|
| 263 |
+
console.log(`已触发下载会话 ${startTime} 为 ${filename}`);
|
| 264 |
+
|
| 265 |
+
} catch (error) {
|
| 266 |
+
console.error(`下载会话 ${startTime} 时出错:`, error);
|
| 267 |
+
alert("下载文件时发生错误。"); // 给用户反馈
|
| 268 |
+
}
|
| 269 |
+
}
|
frontend/src/style.scss
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
| 3 |
+
line-height: 1.5;
|
| 4 |
+
font-weight: 400;
|
| 5 |
+
|
| 6 |
+
color-scheme: light dark;
|
| 7 |
+
color: rgba(255, 255, 255, 0.87);
|
| 8 |
+
background-color: #242424;
|
| 9 |
+
|
| 10 |
+
font-synthesis: none;
|
| 11 |
+
text-rendering: optimizeLegibility;
|
| 12 |
+
-webkit-font-smoothing: antialiased;
|
| 13 |
+
-moz-osx-font-smoothing: grayscale;
|
| 14 |
+
-webkit-text-size-adjust: 100%;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
a {
|
| 18 |
+
font-weight: 500;
|
| 19 |
+
color: #646cff;
|
| 20 |
+
text-decoration: inherit;
|
| 21 |
+
}
|
| 22 |
+
a:hover {
|
| 23 |
+
color: #535bf2;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
body {
|
| 27 |
+
margin: 0;
|
| 28 |
+
display: flex;
|
| 29 |
+
place-items: center;
|
| 30 |
+
min-width: 320px;
|
| 31 |
+
height: auto;
|
| 32 |
+
min-height: auto;
|
| 33 |
+
color: #333;
|
| 34 |
+
background: #fff;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
h1 {
|
| 38 |
+
font-size: 3.2em;
|
| 39 |
+
line-height: 1.1;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
button {
|
| 43 |
+
border-radius: 8px;
|
| 44 |
+
border: 1px solid transparent;
|
| 45 |
+
padding: 0.6em 1.2em;
|
| 46 |
+
font-size: 1em;
|
| 47 |
+
font-weight: 500;
|
| 48 |
+
font-family: inherit;
|
| 49 |
+
background-color: #1a1a1a;
|
| 50 |
+
cursor: pointer;
|
| 51 |
+
transition: border-color 0.25s;
|
| 52 |
+
}
|
| 53 |
+
//button:hover {
|
| 54 |
+
// border-color: #646cff;
|
| 55 |
+
//}
|
| 56 |
+
//button:focus,
|
| 57 |
+
//button:focus-visible {
|
| 58 |
+
// outline: 4px auto -webkit-focus-ring-color;
|
| 59 |
+
//}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
$FormMaxWidth: 1024px;
|
| 63 |
+
$FormItemWidth: 1022px;
|
| 64 |
+
|
| 65 |
+
.card {
|
| 66 |
+
border-bottom: solid 2px lightgray;
|
| 67 |
+
//border-radius: 4px;
|
| 68 |
+
align-items: center;
|
| 69 |
+
justify-content: center;
|
| 70 |
+
/* padding: 2em; */
|
| 71 |
+
margin-top: 40px;
|
| 72 |
+
display: flex;
|
| 73 |
+
max-width: $FormMaxWidth;
|
| 74 |
+
width: 100%;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.seg-title {
|
| 78 |
+
margin: 24px 0;
|
| 79 |
+
font-size: 20px;
|
| 80 |
+
font-weight: 500;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.seg-co {
|
| 84 |
+
width: 1022px;
|
| 85 |
+
text-align: left;
|
| 86 |
+
border-left: solid 6px midnightblue;
|
| 87 |
+
padding-left: 8px;
|
| 88 |
+
margin-left: 2px;
|
| 89 |
+
margin-top: 36px;
|
| 90 |
+
line-height: 24px;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
#app {
|
| 94 |
+
margin: 0 auto;
|
| 95 |
+
padding: 0 ;
|
| 96 |
+
text-align: center;
|
| 97 |
+
width: 100%;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.ant-btn {
|
| 101 |
+
padding: 4px 12px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
@media (prefers-color-scheme: light) {
|
| 105 |
+
:root {
|
| 106 |
+
color: #213547;
|
| 107 |
+
background-color: #ffffff;
|
| 108 |
+
}
|
| 109 |
+
a:hover {
|
| 110 |
+
color: #747bff;
|
| 111 |
+
}
|
| 112 |
+
button {
|
| 113 |
+
background-color: #f9f9f9;
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.ant-card {
|
| 118 |
+
background: #f5f6fa;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.ant-card .ant-card-actions {
|
| 122 |
+
background-color: rgba(232, 232, 248, 0.8) !important;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.ant-popover {
|
| 126 |
+
max-width: 800px !important;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.ant-form-item {
|
| 130 |
+
background: transparent;
|
| 131 |
+
margin-bottom: 40px !important;
|
| 132 |
+
.ant-form-item-explain-error {
|
| 133 |
+
color: #ff4d4f;
|
| 134 |
+
text-align: left !important;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.ant-form-item-label {
|
| 139 |
+
label {
|
| 140 |
+
font-size: 18px !important;
|
| 141 |
+
color: #1a1a1a !important;
|
| 142 |
+
font-weight: 500 !important;
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
.ant-tooltip {
|
| 146 |
+
max-width: $FormItemWidth !important;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.ant-page-header-heading {
|
| 150 |
+
width: 1022px !important;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.highlight {
|
| 154 |
+
//background: #feffe6;
|
| 155 |
+
background: ghostwhite;
|
| 156 |
+
}
|
frontend/src/utils/audio_utils.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// AudioUtils.ts
|
| 2 |
+
export class AudioUtils {
|
| 3 |
+
static async createPCM16Data(audioBuffer: AudioBuffer): Promise<ArrayBuffer> {
|
| 4 |
+
const numChannels = 1; // Mono
|
| 5 |
+
const sampleRate = 16000; // Target sample rate
|
| 6 |
+
const format = 1; // PCM
|
| 7 |
+
const bitDepth = 16;
|
| 8 |
+
|
| 9 |
+
// Resample if needed
|
| 10 |
+
let samples = audioBuffer.getChannelData(0);
|
| 11 |
+
if (audioBuffer.sampleRate !== sampleRate) {
|
| 12 |
+
samples = await this.resampleAudio(samples, audioBuffer.sampleRate, sampleRate);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const dataLength = samples.length * (bitDepth / 8);
|
| 16 |
+
const headerLength = 44;
|
| 17 |
+
const totalLength = headerLength + dataLength;
|
| 18 |
+
|
| 19 |
+
const buffer = new ArrayBuffer(totalLength);
|
| 20 |
+
const view = new DataView(buffer);
|
| 21 |
+
|
| 22 |
+
// Write WAV header
|
| 23 |
+
this.writeString(view, 0, 'RIFF');
|
| 24 |
+
view.setUint32(4, totalLength - 8, true);
|
| 25 |
+
this.writeString(view, 8, 'WAVE');
|
| 26 |
+
this.writeString(view, 12, 'fmt ');
|
| 27 |
+
view.setUint32(16, 16, true);
|
| 28 |
+
view.setUint16(20, format, true);
|
| 29 |
+
view.setUint16(22, numChannels, true);
|
| 30 |
+
view.setUint32(24, sampleRate, true);
|
| 31 |
+
view.setUint32(28, sampleRate * numChannels * (bitDepth / 8), true);
|
| 32 |
+
view.setUint16(32, numChannels * (bitDepth / 8), true);
|
| 33 |
+
view.setUint16(34, bitDepth, true);
|
| 34 |
+
this.writeString(view, 36, 'data');
|
| 35 |
+
view.setUint32(40, dataLength, true);
|
| 36 |
+
|
| 37 |
+
// Write audio data
|
| 38 |
+
this.floatTo16BitPCM(view, 44, samples);
|
| 39 |
+
|
| 40 |
+
return buffer;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
static writeString(view: DataView, offset: number, string: string): void {
|
| 44 |
+
for (let i = 0; i < string.length; i++) {
|
| 45 |
+
view.setUint8(offset + i, string.charCodeAt(i));
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
static floatTo16BitPCM(view: DataView, offset: number, input: Float32Array): void {
|
| 50 |
+
for (let i = 0; i < input.length; i++, offset += 2) {
|
| 51 |
+
const s = Math.max(-1, Math.min(1, input[i]));
|
| 52 |
+
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
static async resampleAudio(
|
| 57 |
+
audioData: Float32Array,
|
| 58 |
+
originalSampleRate: number,
|
| 59 |
+
targetSampleRate: number
|
| 60 |
+
): Promise<Float32Array> {
|
| 61 |
+
const originalLength = audioData.length;
|
| 62 |
+
const ratio = targetSampleRate / originalSampleRate;
|
| 63 |
+
const newLength = Math.round(originalLength * ratio);
|
| 64 |
+
const result = new Float32Array(newLength);
|
| 65 |
+
|
| 66 |
+
for (let i = 0; i < newLength; i++) {
|
| 67 |
+
const position = i / ratio;
|
| 68 |
+
const index = Math.floor(position);
|
| 69 |
+
const fraction = position - index;
|
| 70 |
+
|
| 71 |
+
if (index + 1 < originalLength) {
|
| 72 |
+
result[i] = audioData[index] * (1 - fraction) + audioData[index + 1] * fraction;
|
| 73 |
+
} else {
|
| 74 |
+
result[i] = audioData[index];
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return result;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export default AudioUtils;
|
frontend/src/utils/retry.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const retryAsyncFn = async (fn: any, retry: number) => {
|
| 2 |
+
try {
|
| 3 |
+
await fn()
|
| 4 |
+
} catch (e) {
|
| 5 |
+
if (retry > 0) {
|
| 6 |
+
setTimeout(async () => {
|
| 7 |
+
await retryAsyncFn(fn, retry - 1)
|
| 8 |
+
}, 500)
|
| 9 |
+
} else {
|
| 10 |
+
throw e
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const retryFn = (fn: any, retry: number) => {
|
| 16 |
+
try {
|
| 17 |
+
fn()
|
| 18 |
+
} catch (e) {
|
| 19 |
+
if (retry > 0) {
|
| 20 |
+
setTimeout(() => {
|
| 21 |
+
retryFn(fn, retry - 1)
|
| 22 |
+
}, 500)
|
| 23 |
+
} else {
|
| 24 |
+
throw e
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
frontend/src/utils/size.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const sizeCalculator = (size: number) => {
|
| 2 |
+
if (size < 1024) {
|
| 3 |
+
return size + ' B'
|
| 4 |
+
} else if (size < 1024 * 1024) {
|
| 5 |
+
return (size / 1024).toFixed(2) + ' KB'
|
| 6 |
+
} else if (size < 1024 * 1024 * 1024) {
|
| 7 |
+
return (size / 1024 / 1024).toFixed(2) + ' MB'
|
| 8 |
+
} else {
|
| 9 |
+
return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
| 10 |
+
}
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const getRandomNumInt = (min: number, max: number) => {
|
| 14 |
+
var Range = max - min;
|
| 15 |
+
var Rand = Math.random(); //获取[0-1)的随机数
|
| 16 |
+
return (min + Math.round(Rand * Range)); //放大取整
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const formatMs = (ms:any ,all: any) => {
|
| 20 |
+
let ss=ms%1000;ms=(ms-ss)/1000;
|
| 21 |
+
let s=ms%60;ms=(ms-s)/60;
|
| 22 |
+
let m=ms%60;ms=(ms-m)/60;
|
| 23 |
+
let h=ms;
|
| 24 |
+
let t=(h?h+":":"")
|
| 25 |
+
+(all||h+m?("0"+m).substr(-2)+":":"")
|
| 26 |
+
+(all||h+m+s?("0"+s).substr(-2)+"″":"")
|
| 27 |
+
+("00"+ss).substr(-3);
|
| 28 |
+
return t;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export const getRandomItems = (arr: [], num: number) => {
|
| 32 |
+
if (arr.length < num) {
|
| 33 |
+
throw new Error('The array does not contain enough elements.');
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// 复制原数组,避免修改原数组
|
| 37 |
+
let tempArray = [...arr];
|
| 38 |
+
|
| 39 |
+
// 打乱数组
|
| 40 |
+
for (let i = tempArray.length - 1; i > 0; i--) {
|
| 41 |
+
const j = Math.floor(Math.random() * (i + 1));
|
| 42 |
+
[tempArray[i], tempArray[j]] = [tempArray[j], tempArray[i]]; // ES6 的解构赋值来交换元素
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// 返回前num个项目
|
| 46 |
+
return tempArray.slice(0, num);
|
| 47 |
+
}
|
frontend/src/views/404/index.vue
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup lang="ts">
|
| 2 |
+
|
| 3 |
+
import router from "@/router.ts";
|
| 4 |
+
|
| 5 |
+
const backAction = () => {
|
| 6 |
+
router.replace('/')
|
| 7 |
+
}
|
| 8 |
+
</script>
|
| 9 |
+
|
| 10 |
+
<template>
|
| 11 |
+
<div class="not-found-wrapper">
|
| 12 |
+
<a-result status="404" title="404" sub-title="Sorry, the page you visited does not exist.">
|
| 13 |
+
<template #extra>
|
| 14 |
+
<a-button @click="backAction" type="primary">Back Home</a-button>
|
| 15 |
+
</template>
|
| 16 |
+
</a-result>
|
| 17 |
+
</div>
|
| 18 |
+
</template>
|
| 19 |
+
|
| 20 |
+
<style lang="scss" scoped>
|
| 21 |
+
.not-found-wrapper {
|
| 22 |
+
height: calc(100vh - 104px);
|
| 23 |
+
}
|
| 24 |
+
</style>
|
frontend/src/views/Footer.vue
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup lang="ts">
|
| 2 |
+
</script>
|
| 3 |
+
|
| 4 |
+
<template>
|
| 5 |
+
<div class="right">
|
| 6 |
+
© 2025 MoYoYo Inc. All Rights Reserved.
|
| 7 |
+
</div>
|
| 8 |
+
</template>
|
| 9 |
+
|
| 10 |
+
<style scoped>
|
| 11 |
+
|
| 12 |
+
.right {
|
| 13 |
+
position: fixed;
|
| 14 |
+
bottom: 0;
|
| 15 |
+
width: 100%;
|
| 16 |
+
height: 40px;
|
| 17 |
+
line-height: 40px;
|
| 18 |
+
text-align: center;
|
| 19 |
+
font-size: 0.8em;
|
| 20 |
+
background: white;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
</style>
|
frontend/src/views/Header.vue
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts" setup>
|
| 2 |
+
import router from "@/router.ts";
|
| 3 |
+
import {watch, ref, onMounted, onUnmounted} from "vue";
|
| 4 |
+
import {ArrowRightOutlined, DashboardOutlined,
|
| 5 |
+
ExperimentOutlined, SettingOutlined} from "@ant-design/icons-vue";
|
| 6 |
+
|
| 7 |
+
const windowWidth = ref(window.innerWidth)
|
| 8 |
+
|
| 9 |
+
onMounted(() => {
|
| 10 |
+
window.addEventListener('resize', () => {
|
| 11 |
+
windowWidth.value = window.innerWidth
|
| 12 |
+
})
|
| 13 |
+
})
|
| 14 |
+
onUnmounted(() => {
|
| 15 |
+
window.removeEventListener('resize', () => {
|
| 16 |
+
windowWidth.value = window.innerWidth
|
| 17 |
+
})
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
const sessionActive = ref(false)
|
| 21 |
+
const toolsActive = ref(false)
|
| 22 |
+
watch(router.currentRoute, (to, from) => {
|
| 23 |
+
console.log('router changed', to, from)
|
| 24 |
+
|
| 25 |
+
const sessionActivePath = ['/sessions', '/sessions/', '/form', '/form/', '/output']
|
| 26 |
+
|
| 27 |
+
if (sessionActivePath.includes(to.path)) {
|
| 28 |
+
sessionActive.value = true
|
| 29 |
+
toolsActive.value = false
|
| 30 |
+
return
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const toolsActivePath = ['/tools', '/tools/', '/benchmark', '/benchmark/', '/benchmark_detail', '/benchmark_detail/']
|
| 34 |
+
if (toolsActivePath.includes(to.path)) {
|
| 35 |
+
sessionActive.value = false
|
| 36 |
+
toolsActive.value = true
|
| 37 |
+
return
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
sessionActive.value = false
|
| 41 |
+
toolsActive.value = false
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
const gotoSettings = (e: any) => {
|
| 45 |
+
e.preventDefault()
|
| 46 |
+
router.push('/settings')
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const gotoProfiling = (e: any) => {
|
| 50 |
+
e.preventDefault()
|
| 51 |
+
router.push('/profiling')
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const gotoHealthcheck = (e: any) => {
|
| 55 |
+
e.preventDefault()
|
| 56 |
+
router.push('/healthcheck')
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
</script>
|
| 60 |
+
<template>
|
| 61 |
+
<nav class="header-nav">
|
| 62 |
+
<div class="nav-left flex flex-row justify-between items-center">
|
| 63 |
+
<router-link to="/" >
|
| 64 |
+
<img
|
| 65 |
+
alt="logo"
|
| 66 |
+
src="/logo.webp"
|
| 67 |
+
class="logo"
|
| 68 |
+
/>
|
| 69 |
+
</router-link>
|
| 70 |
+
|
| 71 |
+
</div>
|
| 72 |
+
<div class="title">
|
| 73 |
+
<div v-if="windowWidth >= 1280">
|
| 74 |
+
<div class="primary">AVATAR
|
| 75 |
+
</div>
|
| 76 |
+
<div class="secondary">(power by AI)</div>
|
| 77 |
+
</div>
|
| 78 |
+
<h3 v-if="windowWidth < 1280"
|
| 79 |
+
class="text-3xl font-medium text-primary-dark dark:text-ternary-light hidden sm:block"
|
| 80 |
+
style="font-size: 22px; line-height: 60px; font-weight: 600;"
|
| 81 |
+
>AVATAR</h3>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="nav-right flex flex-row justify-between items-center">
|
| 84 |
+
<!-- <a-button type="link" href="/">-->
|
| 85 |
+
<!-- <span :style="router.currentRoute.value.path == '/' ? 'border-bottom: solid 2px; ' : ''" >Home</span>-->
|
| 86 |
+
<!-- </a-button>-->
|
| 87 |
+
<!-- <router-link to="/settings" >-->
|
| 88 |
+
<!-- <img-->
|
| 89 |
+
<!-- alt="logo"-->
|
| 90 |
+
<!-- src="/logo.webp"-->
|
| 91 |
+
<!-- class="logo"-->
|
| 92 |
+
<!-- />-->
|
| 93 |
+
<!-- </router-link>-->
|
| 94 |
+
<a-button type="ghost" style="margin-right: 12px;"
|
| 95 |
+
size="large" @click="gotoProfiling">
|
| 96 |
+
<template #icon>
|
| 97 |
+
<ExperimentOutlined style="font-size: 24px;"/>
|
| 98 |
+
</template>
|
| 99 |
+
</a-button>
|
| 100 |
+
<a-button type="ghost" style="margin-right: 12px;"
|
| 101 |
+
size="large" @click="gotoHealthcheck">
|
| 102 |
+
<template #icon>
|
| 103 |
+
<DashboardOutlined style="font-size: 24px;"/>
|
| 104 |
+
</template>
|
| 105 |
+
</a-button>
|
| 106 |
+
<a-button type="ghost" size="large" @click="gotoSettings">
|
| 107 |
+
<template #icon>
|
| 108 |
+
<SettingOutlined style="font-size: 24px;"/>
|
| 109 |
+
</template>
|
| 110 |
+
</a-button>
|
| 111 |
+
<!-- <a-button type="link" target="_blank" >-->
|
| 112 |
+
<!-- <span>Contact</span>-->
|
| 113 |
+
<!-- </a-button>-->
|
| 114 |
+
</div>
|
| 115 |
+
</nav>
|
| 116 |
+
</template>
|
| 117 |
+
<style scoped lang="scss">
|
| 118 |
+
.header-nav {
|
| 119 |
+
display: flex;
|
| 120 |
+
align-items: center;
|
| 121 |
+
justify-content: space-between;
|
| 122 |
+
width: 100vw;
|
| 123 |
+
height: 72px;
|
| 124 |
+
background: aliceblue;
|
| 125 |
+
box-shadow: 1px 1px 2px 1px #d9d9d9;
|
| 126 |
+
top: 0;
|
| 127 |
+
position: sticky;
|
| 128 |
+
z-index: 99;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.nav-left {
|
| 132 |
+
position: absolute;
|
| 133 |
+
left: 20px;
|
| 134 |
+
top: 12px;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.nav-right {
|
| 138 |
+
.ant-btn {
|
| 139 |
+
span {
|
| 140 |
+
font-size: 15px;
|
| 141 |
+
font-weight: 600;
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.logo {
|
| 147 |
+
width: 48px;
|
| 148 |
+
height: 48px;
|
| 149 |
+
border-radius: 24px;
|
| 150 |
+
will-change: filter;
|
| 151 |
+
transition: filter 300ms;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.logo:hover {
|
| 155 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.logo.vue:hover {
|
| 159 |
+
filter: drop-shadow(0 0 2em #42b883aa);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.nav-items {
|
| 163 |
+
margin-left: 10px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.nav-right {
|
| 167 |
+
position: absolute;
|
| 168 |
+
right: 20px;
|
| 169 |
+
height: 64px;
|
| 170 |
+
align-items: center;
|
| 171 |
+
display: flex;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.title {
|
| 175 |
+
margin-left: 84px;
|
| 176 |
+
line-height: 48px;
|
| 177 |
+
height: 54px;
|
| 178 |
+
|
| 179 |
+
.primary {
|
| 180 |
+
font-weight: bold;
|
| 181 |
+
text-align: left;
|
| 182 |
+
font-size: 16px;
|
| 183 |
+
letter-spacing: -0.8px;
|
| 184 |
+
font-family: Courier,Menlo, monospace, 'SFMono-Regular', Consolas, 'Liberation Mono';
|
| 185 |
+
}
|
| 186 |
+
.secondary {
|
| 187 |
+
font-family: Courier,Menlo, monospace, 'SFMono-Regular', Consolas, 'Liberation Mono';
|
| 188 |
+
text-align: left;
|
| 189 |
+
font-size: 8px;
|
| 190 |
+
letter-spacing: -0.2px;
|
| 191 |
+
font-weight: 400;
|
| 192 |
+
line-height: 20px;
|
| 193 |
+
margin-top: -12px;
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.switch-icon {
|
| 198 |
+
margin-left: 20px;
|
| 199 |
+
}
|
| 200 |
+
</style>
|
frontend/src/views/Home/index.vue
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup lang="ts">
|
| 2 |
+
import router from "@/router.ts";
|
| 3 |
+
import {
|
| 4 |
+
DownloadOutlined, FileMarkdownOutlined, SettingOutlined,
|
| 5 |
+
AudioOutlined, DeleteOutlined, FileTextOutlined,
|
| 6 |
+
ArrowRightOutlined, ContainerOutlined, UploadOutlined
|
| 7 |
+
} from "@ant-design/icons-vue";
|
| 8 |
+
import { test_server } from "@/config/client_config.ts";
|
| 9 |
+
import axios from "axios";
|
| 10 |
+
import { nextTick, onMounted, onUnmounted, reactive, ref, createVNode, computed, watch } from "vue";
|
| 11 |
+
|
| 12 |
+
import { useSessionStore, downloadSessionData } from "@/stores/session";
|
| 13 |
+
import type { SessionSummary, SessionNode } from '@/stores/session'; // 导入类型
|
| 14 |
+
import { useSettingsStore } from "@/stores/config.ts";
|
| 15 |
+
const sessionStore = useSessionStore()
|
| 16 |
+
const settingsStore = useSettingsStore();
|
| 17 |
+
// https://github.surmon.me/videojs-player video.js debug page;
|
| 18 |
+
// const base_url = 'http://192.168.110.102:8000'
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
const host = import.meta.env.PROD ? window.location.host : test_server
|
| 22 |
+
// const pathname = window.location.pathname
|
| 23 |
+
let ws_prefix = 'ws'
|
| 24 |
+
if (host.startsWith('127.0.0.1') || host.startsWith('localhost')) {
|
| 25 |
+
ws_prefix = 'ws'
|
| 26 |
+
} else {
|
| 27 |
+
ws_prefix = 'wss'
|
| 28 |
+
}
|
| 29 |
+
const ws_url = `${ws_prefix}://` + host + `/ws?`
|
| 30 |
+
console.warn('ws_url: ', ws_url)
|
| 31 |
+
|
| 32 |
+
const sock = ref(null)
|
| 33 |
+
const startWebSock = async (lang_str: string) => {
|
| 34 |
+
console.warn('start websocket ...')
|
| 35 |
+
// 确保在创建 WebSocket 连接之前关闭已有连接
|
| 36 |
+
// @ts-ignore
|
| 37 |
+
if (sock.value && sock.value.readyState !== WebSocket.CLOSED) {
|
| 38 |
+
// @ts-ignore
|
| 39 |
+
sock.value.close();
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// const socket_url = `${ws_url}${lang_str}${'&vad=' + vadValueRef.value}`
|
| 43 |
+
const socket_url = `${ws_url}${lang_str}`
|
| 44 |
+
// @ts-ignore
|
| 45 |
+
sock.value = new WebSocket(socket_url)
|
| 46 |
+
// @ts-ignore
|
| 47 |
+
sock.value.binaryType = "arraybuffer";
|
| 48 |
+
console.warn('created web socket ...')
|
| 49 |
+
// @ts-ignore
|
| 50 |
+
sock.value.addEventListener('open', () => {
|
| 51 |
+
console.log('WebSocket 连接成功');
|
| 52 |
+
startAudioCapture(); // WebSocket 连接成功后开始音频捕获
|
| 53 |
+
isRecording.value = true; // 连接成功时自动开始录音
|
| 54 |
+
});
|
| 55 |
+
// @ts-ignore
|
| 56 |
+
sock.value.addEventListener('close', () => {
|
| 57 |
+
console.log('WebSocket 连接已关闭');
|
| 58 |
+
});
|
| 59 |
+
// @ts-ignore
|
| 60 |
+
sock.value.onclose = (event: any) => {
|
| 61 |
+
console.log('code:', event.code, 'reason:', event.reason, 'wasClean:', event.wasClean)
|
| 62 |
+
// https://www.cnblogs.com/gxp69/p/11736749.html
|
| 63 |
+
console.log('WebSocket 连接已关闭:', event);
|
| 64 |
+
};
|
| 65 |
+
// @ts-ignore
|
| 66 |
+
sock.value.addEventListener('error', (error) => {
|
| 67 |
+
console.error('WebSocket 连接错误:', error);
|
| 68 |
+
});
|
| 69 |
+
// @ts-ignore
|
| 70 |
+
sock.value.addEventListener('message', (event) => {
|
| 71 |
+
try { // 添加 try-catch 保证 JSON 解析失败不中断程序
|
| 72 |
+
const data = JSON.parse(event.data)
|
| 73 |
+
console.log('WebSocket 收到消息:', data);
|
| 74 |
+
if (data && data['result']) {
|
| 75 |
+
updateViewData(data['result'])
|
| 76 |
+
}
|
| 77 |
+
} catch (e) {
|
| 78 |
+
console.error("解析 WebSocket 消息失败:", e, "原始数据:", event.data);
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const stopWebSock = async () => {
|
| 84 |
+
if (sock.value) {
|
| 85 |
+
console.log("主动关闭 WebSocket 连接");
|
| 86 |
+
// @ts-ignore
|
| 87 |
+
sock.value.close(1000, "User closed connection"); // 使用标准关闭代码
|
| 88 |
+
sock.value = null;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const audioStreamRef = ref<MediaStream | null>(null);
|
| 93 |
+
const recorderRef = ref(null);
|
| 94 |
+
const audioContextRef = ref<AudioContext | null>(null);
|
| 95 |
+
const sourceNodeRef = ref(null)
|
| 96 |
+
const processorNodeRef = ref(null)
|
| 97 |
+
const isRecording = ref<boolean>(false);
|
| 98 |
+
|
| 99 |
+
// 启动音频捕获
|
| 100 |
+
const startAudioCapture = async () => {
|
| 101 |
+
try {
|
| 102 |
+
// 检查 AudioContext 是否支持
|
| 103 |
+
// @ts-ignore
|
| 104 |
+
if (!window.AudioContext && !window.webkitAudioContext) {
|
| 105 |
+
alert("浏览器不支持 Web Audio API");
|
| 106 |
+
throw new Error("浏览器不支持 Web Audio API");
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 110 |
+
audio: {
|
| 111 |
+
// @ts-ignore
|
| 112 |
+
sampleRate: 16000,
|
| 113 |
+
channelCount: 1,
|
| 114 |
+
// echoCancellation: true,
|
| 115 |
+
// noiseSuppression: true,
|
| 116 |
+
// autoGainControl: true,
|
| 117 |
+
},
|
| 118 |
+
});
|
| 119 |
+
audioStreamRef.value = stream;
|
| 120 |
+
|
| 121 |
+
// 创建 AudioContext,指定采样率为 16kHz
|
| 122 |
+
const audioContext = new AudioContext({ sampleRate: 16000 });
|
| 123 |
+
audioContextRef.value = audioContext;
|
| 124 |
+
|
| 125 |
+
// 创建媒体流源节点
|
| 126 |
+
const source = audioContext.createMediaStreamSource(stream);
|
| 127 |
+
// @ts-ignore
|
| 128 |
+
sourceNodeRef.value = source;
|
| 129 |
+
|
| 130 |
+
// 创建脚本处理器节点
|
| 131 |
+
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
| 132 |
+
// @ts-ignore
|
| 133 |
+
processorNodeRef.value = processor;
|
| 134 |
+
|
| 135 |
+
// 连接节点
|
| 136 |
+
source.connect(processor);
|
| 137 |
+
processor.connect(audioContext.destination);
|
| 138 |
+
|
| 139 |
+
// 设置音频处理回调
|
| 140 |
+
processor.onaudioprocess = (e: AudioProcessingEvent) => {
|
| 141 |
+
// @ts-ignore
|
| 142 |
+
if (!isRecording.value || !sock.value || sock.value.readyState !== WebSocket.OPEN) return;
|
| 143 |
+
|
| 144 |
+
const input = e.inputBuffer.getChannelData(0);
|
| 145 |
+
const buffer = new Int16Array(input.length);
|
| 146 |
+
|
| 147 |
+
for (let i = 0; i < input.length; i++) {
|
| 148 |
+
buffer[i] = Math.max(-1, Math.min(1, input[i])) * 0x7FFF;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
sendAudioChunk(buffer);
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
isRecording.value = true;
|
| 155 |
+
console.log('音频捕获已启动');
|
| 156 |
+
|
| 157 |
+
} catch (err) {
|
| 158 |
+
console.error('音频捕获失败:', err);
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const sendAudioChunk = (audioBuffer: any) => {
|
| 163 |
+
// @ts-ignore
|
| 164 |
+
if (sock.value && sock.value.readyState === WebSocket.OPEN) {
|
| 165 |
+
// @ts-ignore
|
| 166 |
+
sock.value.send(audioBuffer);
|
| 167 |
+
// console.log('WebSocket send audio chunk success'); // 减少日志量
|
| 168 |
+
} else {
|
| 169 |
+
console.error('WebSocket 未连接或未打开,无法发送音频');
|
| 170 |
+
// 考虑停止录音
|
| 171 |
+
// handleRecordingSwitch(false);
|
| 172 |
+
}
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
// 请求录音权限并开始录音
|
| 177 |
+
const requirePermissionAction = async () => {
|
| 178 |
+
console.log('requirePermissionAction');
|
| 179 |
+
try {
|
| 180 |
+
// 确保 WebSocket 连接已建立
|
| 181 |
+
// @ts-ignore
|
| 182 |
+
if (!sock.value || sock.value.readyState !== WebSocket.OPEN) {
|
| 183 |
+
console.log('current lang_str : ', transLanguageValue.value)
|
| 184 |
+
const lang_str = transLanguageValue.value
|
| 185 |
+
|
| 186 |
+
resetViewData();
|
| 187 |
+
await startWebSock(lang_str);
|
| 188 |
+
} else {
|
| 189 |
+
// 如果 WebSocket 已经连接,可能只需要重新开始音频捕获(如果之前停止了)
|
| 190 |
+
if (!audioStreamRef.value) {
|
| 191 |
+
await startAudioCapture();
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
} catch (e: any) {
|
| 195 |
+
isRecording.value = false; // 确保出错时关闭开关
|
| 196 |
+
console.log('Error accessing microphone: ', e);
|
| 197 |
+
}
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
// 格式化时间戳函数
|
| 202 |
+
const formatTimestamp = (ms: number): string => {
|
| 203 |
+
const date = new Date(ms);
|
| 204 |
+
const year = date.getFullYear();
|
| 205 |
+
const month = String(date.getMonth() + 1).padStart(2, '0'); // 月份从 0 开始,需要 +1
|
| 206 |
+
const day = String(date.getDate()).padStart(2, '0');
|
| 207 |
+
const hours = String(date.getHours()).padStart(2, '0');
|
| 208 |
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
| 209 |
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
| 210 |
+
|
| 211 |
+
return `${year}-${month}-${day}-${hours}:${minutes}:${seconds}`;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
onMounted(() => {
|
| 215 |
+
console.log('[translator]: mounted')
|
| 216 |
+
fontSizeRef.value = settingsStore.$state.fs
|
| 217 |
+
maxWidthRef.value = settingsStore.$state.width_max
|
| 218 |
+
vadValueRef.value = settingsStore.$state.vad
|
| 219 |
+
|
| 220 |
+
if (sessionStore.isSessionActive) {
|
| 221 |
+
console.warn("检测到上次会话未正常结束,重置状态。");
|
| 222 |
+
sessionStore.$reset(); // 或者手动重置相关状态
|
| 223 |
+
// isRecording.value = false; // 确保 UI 同步
|
| 224 |
+
}
|
| 225 |
+
})
|
| 226 |
+
|
| 227 |
+
onUnmounted(() => {
|
| 228 |
+
console.log('[HomePage]: unmounted')
|
| 229 |
+
if (sock.value) {
|
| 230 |
+
// @ts-ignore
|
| 231 |
+
sock.value.close();
|
| 232 |
+
}
|
| 233 |
+
if (recorderRef.value) {
|
| 234 |
+
stopRecording();
|
| 235 |
+
sessionStore.endSession(); // 结束当前会话
|
| 236 |
+
}
|
| 237 |
+
})
|
| 238 |
+
|
| 239 |
+
// 停止录音
|
| 240 |
+
const stopRecording = () => {
|
| 241 |
+
isRecording.value = false;
|
| 242 |
+
|
| 243 |
+
stopWebSock();
|
| 244 |
+
console.log('音频捕获已停止');
|
| 245 |
+
|
| 246 |
+
if (processorNodeRef.value) {
|
| 247 |
+
// @ts-ignore
|
| 248 |
+
processorNodeRef.value.disconnect();
|
| 249 |
+
processorNodeRef.value = null;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
if (sourceNodeRef.value) {
|
| 253 |
+
// @ts-ignore
|
| 254 |
+
sourceNodeRef.value.disconnect();
|
| 255 |
+
sourceNodeRef.value = null;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
if (audioStreamRef.value) {
|
| 259 |
+
audioStreamRef.value.getTracks().forEach(track => track.stop());
|
| 260 |
+
audioStreamRef.value = null;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
if (audioContextRef.value) {
|
| 264 |
+
// @ts-ignore
|
| 265 |
+
audioContextRef.value.close();
|
| 266 |
+
audioContextRef.value = null;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
current_node_text.value = "";
|
| 270 |
+
current_node_trans_text.value = "";
|
| 271 |
+
current_node_seg_id.value = "";
|
| 272 |
+
console.log('录音已停止');
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
const handleLanguageChange = async (value: string) => {
|
| 277 |
+
console.log(`selected ${value}`);
|
| 278 |
+
isRecording.value = false;
|
| 279 |
+
await stopRecording();
|
| 280 |
+
sessionStore.endSession();
|
| 281 |
+
|
| 282 |
+
console.log('new lang_str: ', value)
|
| 283 |
+
console.log('trans_lang : ', transLanguageValue.value)
|
| 284 |
+
// requirePermissionAction();
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
const handleRecordingSwitch = (checked: boolean) => {
|
| 289 |
+
isRecording.value = checked;
|
| 290 |
+
if (checked) {
|
| 291 |
+
isRecording.value = true; // 更新UI状态
|
| 292 |
+
requirePermissionAction();
|
| 293 |
+
sessionStore.startSession(); // 开始新的会话
|
| 294 |
+
} else {
|
| 295 |
+
isRecording.value = false; // 更新UI状态
|
| 296 |
+
stopRecording();
|
| 297 |
+
sessionStore.endSession();
|
| 298 |
+
}
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
const transLanguageValue = ref("from=en&to=zh");
|
| 303 |
+
const options = [
|
| 304 |
+
{ value: "from=en&to=zh", label: "English -> Chinese" },
|
| 305 |
+
{ value: "from=zh&to=en", label: "Chinese -> English" },
|
| 306 |
+
];
|
| 307 |
+
|
| 308 |
+
// @ts-ignore
|
| 309 |
+
const completedNodesForDisplay: any = reactive([])
|
| 310 |
+
|
| 311 |
+
const current_node_text = ref("");
|
| 312 |
+
const current_node_trans_text = ref("");
|
| 313 |
+
const current_node_seg_id = ref("");
|
| 314 |
+
|
| 315 |
+
const updateViewData = (data: any) => {
|
| 316 |
+
console.log('updateViewData: ', data)
|
| 317 |
+
if (data) {
|
| 318 |
+
const { context, from, to, seg_id, partial, tranContent } = data;
|
| 319 |
+
|
| 320 |
+
if (partial == true) {
|
| 321 |
+
current_node_text.value = context;
|
| 322 |
+
current_node_trans_text.value = tranContent;
|
| 323 |
+
current_node_seg_id.value = seg_id;
|
| 324 |
+
|
| 325 |
+
return;
|
| 326 |
+
} else {
|
| 327 |
+
// partial == false,表示一句话结束
|
| 328 |
+
const finalNode: SessionNode = {
|
| 329 |
+
id: seg_id || crypto.randomUUID(), // 使用后端 ID 或生成一个
|
| 330 |
+
text: context,
|
| 331 |
+
translatedText: tranContent,
|
| 332 |
+
timestamp: Date.now(),
|
| 333 |
+
};
|
| 334 |
+
|
| 335 |
+
sessionStore.addNode(finalNode);
|
| 336 |
+
|
| 337 |
+
if (completedNodesForDisplay.length > 100) {
|
| 338 |
+
// 控制显示列表长度,避免 DOM 过多
|
| 339 |
+
completedNodesForDisplay.splice(0, 40);
|
| 340 |
+
}
|
| 341 |
+
completedNodesForDisplay.push(finalNode);
|
| 342 |
+
current_node_text.value = "";
|
| 343 |
+
current_node_trans_text.value = "";
|
| 344 |
+
current_node_seg_id.value = "";
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
scrollToBottom();
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
const resetViewData = () => {
|
| 352 |
+
completedNodesForDisplay.splice(0, completedNodesForDisplay.length); // 清空显示列表
|
| 353 |
+
current_node_text.value = "";
|
| 354 |
+
current_node_trans_text.value = "";
|
| 355 |
+
current_node_seg_id.value = "";
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
const downloadText2 = async (sid: string, nodes: SessionNode[]) => {
|
| 359 |
+
// @ts-ignore
|
| 360 |
+
// 将数组中的每个字符串元素用换行符连接起来
|
| 361 |
+
// const textContent = all_nodes.join('\n');
|
| 362 |
+
const textContent = nodes.map((node: any) => {
|
| 363 |
+
return `[src]: ${node.text}\n${"-".repeat(80)}\n[dst]: ${node.translatedText}\n\n`
|
| 364 |
+
}).join('\n');
|
| 365 |
+
// 创建 Blob 时指定 MIME 类型为 text/plain
|
| 366 |
+
const blob = new Blob([textContent], { type: "text/plain" });
|
| 367 |
+
const url = URL.createObjectURL(blob);
|
| 368 |
+
const a = document.createElement("a");
|
| 369 |
+
a.href = url;
|
| 370 |
+
// 修改下载文件名为 .txt
|
| 371 |
+
a.download = `${sid}.txt`;
|
| 372 |
+
document.body.appendChild(a);
|
| 373 |
+
a.click();
|
| 374 |
+
document.body.removeChild(a);
|
| 375 |
+
URL.revokeObjectURL(url);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
const downloadText = (sid: number, nodes: SessionNode[]) => { // Removed async as it's not needed here
|
| 380 |
+
try {
|
| 381 |
+
if (!nodes || nodes.length === 0) {
|
| 382 |
+
console.warn("No nodes provided for download.");
|
| 383 |
+
alert("No content available to download for this session.");
|
| 384 |
+
return;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
const textContent = nodes.map((node: SessionNode) => { // Use correct type SessionNode
|
| 388 |
+
// Safely access properties, provide fallback for undefined translatedText
|
| 389 |
+
const srcText = node.text || '(No original text)';
|
| 390 |
+
const dstText = node.translatedText || '(No translation)';
|
| 391 |
+
return `[src]: ${srcText}\n${"-".repeat(80)}\n[dst]: ${dstText}\n\n`;
|
| 392 |
+
}).join(''); // Join without extra newline, as \n\n is already added
|
| 393 |
+
|
| 394 |
+
if (!textContent.trim()) {
|
| 395 |
+
console.warn("Generated text content is empty.");
|
| 396 |
+
alert("Generated content is empty, cannot download.");
|
| 397 |
+
return;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
// Create Blob with UTF-8 charset
|
| 401 |
+
const blob = new Blob([textContent], { type: "text/plain;charset=utf-8;" });
|
| 402 |
+
const url = URL.createObjectURL(blob);
|
| 403 |
+
const a = document.createElement("a");
|
| 404 |
+
|
| 405 |
+
a.href = url;
|
| 406 |
+
a.download = `${formatTimestamp(sid)}.txt`; // Ensure filename is set
|
| 407 |
+
a.style.display = 'none'; // Hide the element
|
| 408 |
+
|
| 409 |
+
document.body.appendChild(a);
|
| 410 |
+
console.log(`Attempting to click download link for ${sid}.txt`);
|
| 411 |
+
a.click(); // Trigger the download
|
| 412 |
+
|
| 413 |
+
// Delay cleanup to ensure download initiation
|
| 414 |
+
setTimeout(() => {
|
| 415 |
+
try {
|
| 416 |
+
document.body.removeChild(a);
|
| 417 |
+
URL.revokeObjectURL(url);
|
| 418 |
+
console.log(`Cleaned up resources for ${sid}.txt`);
|
| 419 |
+
} catch (cleanupError) {
|
| 420 |
+
console.error("Error during download cleanup:", cleanupError);
|
| 421 |
+
}
|
| 422 |
+
}, 100); // Delay cleanup by 100ms
|
| 423 |
+
|
| 424 |
+
} catch (error) {
|
| 425 |
+
console.error("Error creating download file:", error);
|
| 426 |
+
alert("An error occurred while preparing the download.");
|
| 427 |
+
}
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
const placeholder_zh = "体验前请检查麦克风是否可用,指定音频语言、译文语言,点击开关按钮开始录音,即可实时获取识别及翻译的文字。"
|
| 432 |
+
const placeholder_en = "Please check if the microphone is available before the experience, specify the audio language and translation language, click the switch button to start recording, and you can get the recognized and translated text in real time."
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
const transListRef = ref(null);
|
| 436 |
+
// 自动滚动到底部的函数
|
| 437 |
+
const scrollToBottom = () => {
|
| 438 |
+
nextTick(() => {
|
| 439 |
+
if (transListRef.value) {
|
| 440 |
+
// @ts-ignore
|
| 441 |
+
transListRef.value.scrollTop = transListRef.value.scrollHeight + 144;
|
| 442 |
+
}
|
| 443 |
+
});
|
| 444 |
+
};
|
| 445 |
+
|
| 446 |
+
watch(() => [...completedNodesForDisplay], () => {
|
| 447 |
+
scrollToBottom();
|
| 448 |
+
}, { deep: true });
|
| 449 |
+
|
| 450 |
+
watch(() => current_node_trans_text.value, () => {
|
| 451 |
+
scrollToBottom();
|
| 452 |
+
}), { deep: true };
|
| 453 |
+
|
| 454 |
+
// config
|
| 455 |
+
const configVisible = ref(false);
|
| 456 |
+
const showConfig = () => {
|
| 457 |
+
configVisible.value = true;
|
| 458 |
+
}
|
| 459 |
+
const hideConfig = () => {
|
| 460 |
+
configVisible.value = false;
|
| 461 |
+
if (vadValueRef.value != settingsStore.$state.vad) {
|
| 462 |
+
settingsStore.$state.vad = vadValueRef.value
|
| 463 |
+
|
| 464 |
+
if (recorderRef.value) {
|
| 465 |
+
stopRecording();
|
| 466 |
+
sessionStore.endSession();
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
const vadValueRef = ref(0.3);
|
| 472 |
+
const maxWidthRef = ref(false);
|
| 473 |
+
const fontSizeRef = ref('trans-font-size-18')
|
| 474 |
+
const showRealTimeBufferRef = ref(true)
|
| 475 |
+
|
| 476 |
+
const onFontSizeChange = (e: any) => {
|
| 477 |
+
console.log('onFontSizeChange', e.target.value)
|
| 478 |
+
fontSizeRef.value = e.target.value
|
| 479 |
+
settingsStore.$state.fs = e.target.value
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
const sessionsModalVisible = ref(false);
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
// Modal 中下载按钮的处理函数
|
| 486 |
+
const handleDownloadSession = (summary: SessionSummary) => {
|
| 487 |
+
console.log(`请求下载会话: ${summary.startTime}`);
|
| 488 |
+
const nodes = sessionStore.loadSessionContent(summary.startTime);
|
| 489 |
+
if (nodes) {
|
| 490 |
+
// 弹出格式选择或直接下载 JSON
|
| 491 |
+
// const format = prompt("选择下载格式: 'json' 或 'txt'", "json");
|
| 492 |
+
// if (format === 'json' || format === 'txt') {
|
| 493 |
+
// downloadSessionData(summary.startTime, nodes, format);
|
| 494 |
+
// }
|
| 495 |
+
// downloadSessionData(summary.startTime, nodes, 'json'); // 默认下载 JSON
|
| 496 |
+
downloadText(summary.startTime, nodes);
|
| 497 |
+
|
| 498 |
+
} else {
|
| 499 |
+
alert(`无法加载会话 ${summary.startTime} 的内容进行下载。`);
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
// Modal 中删除按钮的处理函数
|
| 504 |
+
const handleDeleteSession = (summary: SessionSummary) => {
|
| 505 |
+
if (confirm(`确定要删除开始于 ${new Date(summary.startTime).toLocaleString()} 的会话吗?\n标题: ${summary.title}`)) {
|
| 506 |
+
sessionStore.deleteSession(summary.startTime);
|
| 507 |
+
console.log(`已删除会话: ${summary.startTime}`);
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
// 计算属性,用于模板中方便访问排序后的摘要
|
| 512 |
+
const sortedSummaries = computed(() => sessionStore.sortedSessionSummaries);
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
</script>
|
| 516 |
+
|
| 517 |
+
<template>
|
| 518 |
+
<div class="view-wrapper">
|
| 519 |
+
<div :class="['content-wrapper', maxWidthRef ? 'wrapper-width-auto': 'wrapper-width-fixed']">
|
| 520 |
+
<div style="margin-top: 10vh; padding: 32px">
|
| 521 |
+
<a-card :bordered="false" style="width: 100%;min-width: 100%;">
|
| 522 |
+
<div v-show="!(completedNodesForDisplay.length || current_node_text)" class="chat-box-placeholder">
|
| 523 |
+
{{
|
| 524 |
+
placeholder_en }}</div>
|
| 525 |
+
<div v-show="(completedNodesForDisplay.length || current_node_text)" class="trans-list"
|
| 526 |
+
ref="transListRef">
|
| 527 |
+
<div v-for="node in completedNodesForDisplay" :key="node.id" :class="['node']"
|
| 528 |
+
:data-seg-id="node.id">
|
| 529 |
+
<div :class="['trans-src-lang', fontSizeRef]">{{ node.text }}</div>
|
| 530 |
+
<div :class="['trans-dst-lang', fontSizeRef]">{{ node.translatedText }}</div>
|
| 531 |
+
</div>
|
| 532 |
+
<div v-show="showRealTimeBufferRef" class=" node current_node" :key="current_node_seg_id">
|
| 533 |
+
<div :class="['trans-src-lang', fontSizeRef]">{{ current_node_text }}</div>
|
| 534 |
+
<div :class="['trans-dst-lang', fontSizeRef]">{{ current_node_trans_text }}</div>
|
| 535 |
+
</div>
|
| 536 |
+
</div>
|
| 537 |
+
<template #actions>
|
| 538 |
+
<div class="actions-box">
|
| 539 |
+
<div class="left-actions">
|
| 540 |
+
<a-popover v-model:open="configVisible" placement="topLeft" trigger="click">
|
| 541 |
+
<template #content>
|
| 542 |
+
<div class="config-content">
|
| 543 |
+
<div v-if="false" class="config-block">
|
| 544 |
+
<h4 style="font-weight: 500;">Speaking Speed:</h4>
|
| 545 |
+
<a-radio-group v-model:value="vadValueRef">
|
| 546 |
+
<a-radio :value="0.1">fastest</a-radio>
|
| 547 |
+
<a-radio :value="0.3">fast</a-radio>
|
| 548 |
+
<a-radio :value="0.5">normal</a-radio>
|
| 549 |
+
<a-radio :value="0.75">slow</a-radio>
|
| 550 |
+
<a-radio :value="1">slowest</a-radio>
|
| 551 |
+
</a-radio-group>
|
| 552 |
+
</div>
|
| 553 |
+
<div class="config-block">
|
| 554 |
+
<h4 style="font-weight: 500;">Page Max Width:</h4>
|
| 555 |
+
<a-switch v-model:checked="maxWidthRef" />
|
| 556 |
+
</div>
|
| 557 |
+
<div class="config-block">
|
| 558 |
+
<h4 style="font-weight: 500;">Show Realtime Buffer:</h4>
|
| 559 |
+
<a-switch v-model:checked="showRealTimeBufferRef" />
|
| 560 |
+
</div>
|
| 561 |
+
<div class="config-block">
|
| 562 |
+
<h4 style="font-weight: 500;">Text Font Size:</h4>
|
| 563 |
+
<a-radio-group v-model:value="fontSizeRef" @change="onFontSizeChange">
|
| 564 |
+
<a-radio :value="'trans-font-size-16'">Small</a-radio>
|
| 565 |
+
<a-radio :value="'trans-font-size-18'">Default</a-radio>
|
| 566 |
+
<a-radio :value="'trans-font-size-20'">Normal</a-radio>
|
| 567 |
+
<a-radio :value="'trans-font-size-22'">Medium</a-radio>
|
| 568 |
+
<a-radio :value="'trans-font-size-24'">Large</a-radio>
|
| 569 |
+
</a-radio-group>
|
| 570 |
+
</div>
|
| 571 |
+
</div>
|
| 572 |
+
<div style="display: flex; justify-content: end;">
|
| 573 |
+
<a-button type="primary" @click="hideConfig">Done</a-button>
|
| 574 |
+
</div>
|
| 575 |
+
</template>
|
| 576 |
+
<a-button type="dashed" shape="circle" size="middle" @click="showConfig">
|
| 577 |
+
<template #icon>
|
| 578 |
+
<SettingOutlined />
|
| 579 |
+
</template>
|
| 580 |
+
</a-button>
|
| 581 |
+
</a-popover>
|
| 582 |
+
|
| 583 |
+
<a-select v-model:value="transLanguageValue" style="width: 240px;"
|
| 584 |
+
placeholder="Select Language" :options="options"
|
| 585 |
+
@change="handleLanguageChange"></a-select>
|
| 586 |
+
<a-button type="dashed" shape="circle" size="middle"
|
| 587 |
+
@click="sessionsModalVisible = true">
|
| 588 |
+
<template #icon>
|
| 589 |
+
<FileTextOutlined />
|
| 590 |
+
</template>
|
| 591 |
+
</a-button>
|
| 592 |
+
<a-modal v-model:open="sessionsModalVisible" width="960px" title="Session History"
|
| 593 |
+
centered :closable="true" ok-text="OK" @ok="sessionsModalVisible = false"
|
| 594 |
+
:footer="null">
|
| 595 |
+
<div class="sessions">
|
| 596 |
+
<div v-if="sortedSummaries.length > 0">
|
| 597 |
+
<div v-for="summary in sortedSummaries" :key="summary.startTime"
|
| 598 |
+
class="session-node">
|
| 599 |
+
<div class="content">
|
| 600 |
+
<!-- <div class="content-title">{{ summary.title }}</div> -->
|
| 601 |
+
<div class="content-text">
|
| 602 |
+
Start at: {{ new Date(summary.startTime).toLocaleString() }} ({{
|
| 603 |
+
summary.nodeCount }} items)
|
| 604 |
+
</div>
|
| 605 |
+
<div class="content-outline">
|
| 606 |
+
<div v-if="summary.outline.length > 0">
|
| 607 |
+
<div class="outline-line" v-for="(line, index) in summary.outline" :key="index">{{
|
| 608 |
+
line
|
| 609 |
+
}}</div>
|
| 610 |
+
</div>
|
| 611 |
+
<i v-else>(No outline available)</i>
|
| 612 |
+
</div>
|
| 613 |
+
</div>
|
| 614 |
+
<div class="session-action">
|
| 615 |
+
<!-- <a-popconfirm title="Are you sure you want to delete this session?"
|
| 616 |
+
ok-text="Yes" cancel-text="No"
|
| 617 |
+
@confirm="handleDeleteSession(summary)">
|
| 618 |
+
<a-button type="danger" shape="circle" size="middle"
|
| 619 |
+
style="margin-left: 8px;">
|
| 620 |
+
<template #icon>
|
| 621 |
+
<DeleteOutlined />
|
| 622 |
+
</template>
|
| 623 |
+
</a-button>
|
| 624 |
+
</a-popconfirm> -->
|
| 625 |
+
<a-button danger type="dashed" shape="circle" size="middle"
|
| 626 |
+
@click="handleDeleteSession(summary)" style="margin-left: 8px;">
|
| 627 |
+
<template #icon>
|
| 628 |
+
<DeleteOutlined />
|
| 629 |
+
</template>
|
| 630 |
+
</a-button>
|
| 631 |
+
<a-button type="dashed" shape="circle" size="middle"
|
| 632 |
+
@click="handleDownloadSession(summary)">
|
| 633 |
+
<template #icon>
|
| 634 |
+
<DownloadOutlined />
|
| 635 |
+
</template>
|
| 636 |
+
</a-button>
|
| 637 |
+
</div>
|
| 638 |
+
</div>
|
| 639 |
+
</div>
|
| 640 |
+
</div>
|
| 641 |
+
</a-modal>
|
| 642 |
+
<!-- <a-popconfirm title="download session text content?" @confirm="downloadClick"
|
| 643 |
+
ok-text="Yes" cancel-text="No">
|
| 644 |
+
<template #icon>
|
| 645 |
+
<FileMarkdownOutlined style="color: red" />
|
| 646 |
+
</template>
|
| 647 |
+
<a-button type="dashed" shape="circle" size="middle">
|
| 648 |
+
<template #icon>
|
| 649 |
+
<DownloadOutlined />
|
| 650 |
+
</template>
|
| 651 |
+
</a-button>
|
| 652 |
+
</a-popconfirm> -->
|
| 653 |
+
</div>
|
| 654 |
+
|
| 655 |
+
<a-switch key="switcher" size="large" type="danger" checked-children="ON"
|
| 656 |
+
un-checked-children="OFF" v-model:checked="isRecording" @change="handleRecordingSwitch">
|
| 657 |
+
</a-switch>
|
| 658 |
+
<!-- <div class="right-actions">
|
| 659 |
+
<a-button type="dashed" shape="circle" size="middle" @click="downloadClick">
|
| 660 |
+
<template #icon>
|
| 661 |
+
<DownloadOutlined />
|
| 662 |
+
</template>
|
| 663 |
+
</a-button>
|
| 664 |
+
<a-switch key="switcher" size="large" type="danger" checked-children="ON"
|
| 665 |
+
un-checked-children="OFF" v-model:checked="isRecording"
|
| 666 |
+
@change="handleRecordingSwitch">
|
| 667 |
+
</a-switch>
|
| 668 |
+
</div> -->
|
| 669 |
+
|
| 670 |
+
</div>
|
| 671 |
+
</template>
|
| 672 |
+
</a-card>
|
| 673 |
+
</div>
|
| 674 |
+
</div>
|
| 675 |
+
</div>
|
| 676 |
+
</template>
|
| 677 |
+
|
| 678 |
+
<style lang="scss" scoped>
|
| 679 |
+
.config-content {
|
| 680 |
+
width: 420px;
|
| 681 |
+
margin:12px;
|
| 682 |
+
|
| 683 |
+
.config-block {
|
| 684 |
+
margin: 12px;
|
| 685 |
+
padding-bottom: 12px;
|
| 686 |
+
}
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.sessions {
|
| 690 |
+
width: 100%;
|
| 691 |
+
height: 100%;
|
| 692 |
+
min-height: 50vh;
|
| 693 |
+
max-height: 80vh;
|
| 694 |
+
overflow-y: scroll;
|
| 695 |
+
margin-top:24px;
|
| 696 |
+
display: flex;
|
| 697 |
+
flex-direction: column;
|
| 698 |
+
justify-content: flex-start;
|
| 699 |
+
|
| 700 |
+
.session-node {
|
| 701 |
+
width: 100%;
|
| 702 |
+
height: 100%;
|
| 703 |
+
display: flex;
|
| 704 |
+
justify-content: space-between;
|
| 705 |
+
align-items: center;
|
| 706 |
+
padding: 12px;
|
| 707 |
+
margin-bottom: 12px;
|
| 708 |
+
background-color: rgba(240, 241, 247, 1);
|
| 709 |
+
border-radius: 4px;
|
| 710 |
+
|
| 711 |
+
.content {
|
| 712 |
+
display: flex;
|
| 713 |
+
flex-direction: column;
|
| 714 |
+
justify-content: center;
|
| 715 |
+
align-items: self-start;
|
| 716 |
+
|
| 717 |
+
.content-title {
|
| 718 |
+
font-size: 18px;
|
| 719 |
+
font-weight: bold;
|
| 720 |
+
color: #2e2f33;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.content-text {
|
| 724 |
+
font-size: 18px;
|
| 725 |
+
font-weight: 500;
|
| 726 |
+
color: #2e2f33;
|
| 727 |
+
}
|
| 728 |
+
.content-outline {
|
| 729 |
+
width: 100%;
|
| 730 |
+
|
| 731 |
+
.outline-line {
|
| 732 |
+
font-size: 16px;
|
| 733 |
+
font-weight: 500;
|
| 734 |
+
color: #909299;
|
| 735 |
+
margin: 8px 0 4px 0;
|
| 736 |
+
}
|
| 737 |
+
}
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
.session-action {
|
| 741 |
+
width: 96px;
|
| 742 |
+
display: flex;
|
| 743 |
+
justify-content: space-around;
|
| 744 |
+
align-items: center;
|
| 745 |
+
|
| 746 |
+
.ant-btn-primary {
|
| 747 |
+
background-color: #1890ff !important;
|
| 748 |
+
border-color: #1890ff !important;
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
.view-wrapper {
|
| 755 |
+
width: 100%;
|
| 756 |
+
height: 100%;
|
| 757 |
+
background-color: #fff;
|
| 758 |
+
|
| 759 |
+
.wrapper-width-fixed {
|
| 760 |
+
width: 1280px;
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
.wrapper-width-auto {
|
| 764 |
+
width: 100vw;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
.content-wrapper {
|
| 768 |
+
text-align: left;
|
| 769 |
+
max-width: 100vw;
|
| 770 |
+
min-width: 320px;
|
| 771 |
+
margin-bottom: 64px;
|
| 772 |
+
min-height: calc(100vh - 438px);
|
| 773 |
+
|
| 774 |
+
.chat-box {
|
| 775 |
+
width: 100%;
|
| 776 |
+
height: 54vh;
|
| 777 |
+
|
| 778 |
+
// border: solid 1px lightgray;
|
| 779 |
+
border-radius: 4px;
|
| 780 |
+
padding: 12px;
|
| 781 |
+
color: #2e2f33;
|
| 782 |
+
font-size: 18px;
|
| 783 |
+
}
|
| 784 |
+
.chat-box-placeholder {
|
| 785 |
+
width: 100%;
|
| 786 |
+
height: 58vh;
|
| 787 |
+
border-radius: 4px;
|
| 788 |
+
padding: 12px;
|
| 789 |
+
font-size: 18px;
|
| 790 |
+
color: #a4a6ac;
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
.actions-box {
|
| 794 |
+
display: flex;
|
| 795 |
+
align-items: center;
|
| 796 |
+
justify-content: space-between;
|
| 797 |
+
margin: 0 24px;
|
| 798 |
+
height: 48px;
|
| 799 |
+
|
| 800 |
+
.left-actions {
|
| 801 |
+
display: flex;
|
| 802 |
+
align-items: center;
|
| 803 |
+
justify-content: space-between;
|
| 804 |
+
// width: 288px;
|
| 805 |
+
width: 332px;
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
.right-actions {
|
| 809 |
+
display: flex;
|
| 810 |
+
align-items: center;
|
| 811 |
+
justify-content: space-between;
|
| 812 |
+
width: 108px;
|
| 813 |
+
}
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.trans-list {
|
| 817 |
+
overflow-y: auto;
|
| 818 |
+
width: 100%;
|
| 819 |
+
height: 58vh;
|
| 820 |
+
|
| 821 |
+
scrollbar-width: none;
|
| 822 |
+
-ms-overflow-style: none;
|
| 823 |
+
&::-webkit-scrollbar {
|
| 824 |
+
display: none;
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
.node {
|
| 828 |
+
margin-bottom: 36px;
|
| 829 |
+
width: 100% !important;
|
| 830 |
+
transition: all 0.3s ease;
|
| 831 |
+
|
| 832 |
+
.trans-time {
|
| 833 |
+
font-size: 14px;
|
| 834 |
+
color: #c4c6cc;
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
.trans-font-size-16 {
|
| 838 |
+
font-size: 16px;
|
| 839 |
+
}
|
| 840 |
+
.trans-font-size-18 {
|
| 841 |
+
font-size: 18px;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
.trans-font-size-20 {
|
| 845 |
+
font-size: 20px;
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
.trans-font-size-22 {
|
| 849 |
+
font-size: 22px;
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
.trans-font-size-24 {
|
| 853 |
+
font-size: 24px;
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
.trans-src-lang {
|
| 858 |
+
// font-size: 18px;
|
| 859 |
+
color: #909299;
|
| 860 |
+
font-weight: 500;
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
.trans-dst-lang {
|
| 864 |
+
// font-size: 18px;
|
| 865 |
+
color: #2e2f33;
|
| 866 |
+
font-weight: 600;
|
| 867 |
+
}
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.current_node {
|
| 871 |
+
background-color: rgba(240, 241, 247, 1);
|
| 872 |
+
padding: 4px 8px;
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
}
|
| 876 |
+
}
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
// 动画关键帧定义 - 添加这部分
|
| 880 |
+
@keyframes highlight {
|
| 881 |
+
0% {
|
| 882 |
+
background-color: transparent;
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
50% {
|
| 886 |
+
background-color: rgba(255, 241, 206, 0.5);
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
100% {
|
| 890 |
+
background-color: transparent;
|
| 891 |
+
}
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
@keyframes slideIn {
|
| 895 |
+
from {
|
| 896 |
+
opacity: 0;
|
| 897 |
+
transform: translateY(10px);
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
to {
|
| 901 |
+
opacity: 1;
|
| 902 |
+
transform: translateY(0);
|
| 903 |
+
}
|
| 904 |
+
}
|
| 905 |
+
</style>
|
frontend/src/views/Settings/index.vue
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup lang="ts">
|
| 2 |
+
|
| 3 |
+
import router from "@/router.ts";
|
| 4 |
+
import { useSettingsStore } from "@/stores/config.ts";
|
| 5 |
+
import {onMounted, ref} from "vue";
|
| 6 |
+
import {SettingTwoTone} from "@ant-design/icons-vue";
|
| 7 |
+
|
| 8 |
+
const settingsStore = useSettingsStore()
|
| 9 |
+
|
| 10 |
+
onMounted(() => {
|
| 11 |
+
console.log('config', settingsStore.$state)
|
| 12 |
+
})
|
| 13 |
+
const backAction = () => {
|
| 14 |
+
router.replace('/')
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// const inputType = ref<string>(settingsStore.$state.file_type);
|
| 18 |
+
// const role = ref<string>(settingsStore.$state.role_name);
|
| 19 |
+
// const onTypeChange = (e: any) => {
|
| 20 |
+
// console.log('onTypeChange', e.target.value)
|
| 21 |
+
// settingsStore.$state.file_type = e.target.value
|
| 22 |
+
// }
|
| 23 |
+
|
| 24 |
+
// const onRoleChange = (e: any) => {
|
| 25 |
+
// console.log('onRoleChange', e.target.value)
|
| 26 |
+
// settingsStore.$state.role_name = e.target.value
|
| 27 |
+
// stateStore.changeRole(e.target.value)
|
| 28 |
+
// console.log('role_name', settingsStore.$state.role_name)
|
| 29 |
+
// }
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
</script>
|
| 33 |
+
|
| 34 |
+
<template>
|
| 35 |
+
<div class="content-wrapper">
|
| 36 |
+
<a-result style="width: 100%;" title="Settings">
|
| 37 |
+
<!-- <template #icon>
|
| 38 |
+
<img
|
| 39 |
+
alt="logo"
|
| 40 |
+
src="/logo.webp"
|
| 41 |
+
style="width: 128px; border-radius: 24px;"
|
| 42 |
+
/>
|
| 43 |
+
</template>
|
| 44 |
+
<template #extra>
|
| 45 |
+
<div class="content-box">
|
| 46 |
+
<a-form layout="vertical">
|
| 47 |
+
<a-form-item
|
| 48 |
+
label="Choose your input type:"
|
| 49 |
+
name="inputType"
|
| 50 |
+
>
|
| 51 |
+
<a-radio-group v-model:value="inputType" @change="onTypeChange">
|
| 52 |
+
<a-radio :value="'file'">Audio File</a-radio>
|
| 53 |
+
<a-radio :value="'audio'">Speak</a-radio>
|
| 54 |
+
</a-radio-group>
|
| 55 |
+
</a-form-item>
|
| 56 |
+
|
| 57 |
+
<a-form-item
|
| 58 |
+
label="Choose your desire role:"
|
| 59 |
+
name="role"
|
| 60 |
+
>
|
| 61 |
+
<a-radio-group v-model:value="role" @change="onRoleChange">
|
| 62 |
+
<a-radio :value="'trump'">Trump</a-radio>
|
| 63 |
+
<a-radio :value="'ellen'">Ellen</a-radio>
|
| 64 |
+
</a-radio-group>
|
| 65 |
+
</a-form-item>
|
| 66 |
+
</a-form>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<a-button @click="backAction" type="primary">Back</a-button>
|
| 70 |
+
</template> -->
|
| 71 |
+
|
| 72 |
+
</a-result>
|
| 73 |
+
</div>
|
| 74 |
+
</template>
|
| 75 |
+
|
| 76 |
+
<style lang="scss" scoped>
|
| 77 |
+
|
| 78 |
+
.content-wrapper {
|
| 79 |
+
text-align: left;
|
| 80 |
+
max-width: 800px;
|
| 81 |
+
min-width: 320px;
|
| 82 |
+
margin-bottom: 64px;
|
| 83 |
+
min-height: calc(100vh - 438px);
|
| 84 |
+
|
| 85 |
+
.content-box {
|
| 86 |
+
padding: 24px;
|
| 87 |
+
height: 240px;
|
| 88 |
+
background-color: #e8e8e8;
|
| 89 |
+
border-radius: 16px;
|
| 90 |
+
width: 50%;
|
| 91 |
+
margin: 48px auto;
|
| 92 |
+
min-width: 300px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.video-box {
|
| 96 |
+
max-width: 800px;
|
| 97 |
+
min-width: 320px;
|
| 98 |
+
width: 90vw;
|
| 99 |
+
height: auto;
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
</style>
|
frontend/src/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: [
|
| 4 |
+
"./index.html",
|
| 5 |
+
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
| 6 |
+
],
|
| 7 |
+
theme: {
|
| 8 |
+
extend: {},
|
| 9 |
+
},
|
| 10 |
+
plugins: [],
|
| 11 |
+
}
|
| 12 |
+
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
|
| 9 |
+
/* Bundler mode */
|
| 10 |
+
"moduleResolution": "bundler",
|
| 11 |
+
"allowImportingTsExtensions": true,
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"noEmit": true,
|
| 15 |
+
"jsx": "preserve",
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"strict": true,
|
| 19 |
+
"noUnusedLocals": false,
|
| 20 |
+
"noUnusedParameters": false,
|
| 21 |
+
"noFallthroughCasesInSwitch": true,
|
| 22 |
+
"baseUrl": ".",
|
| 23 |
+
"paths": {
|
| 24 |
+
"@/*": ["src/*"]
|
| 25 |
+
},
|
| 26 |
+
"allowSyntheticDefaultImports": true
|
| 27 |
+
},
|
| 28 |
+
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
| 29 |
+
"references": [{ "path": "./tsconfig.node.json" }]
|
| 30 |
+
}
|
frontend/tsconfig.node.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"composite": true,
|
| 4 |
+
"skipLibCheck": true,
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"moduleResolution": "bundler",
|
| 7 |
+
"allowSyntheticDefaultImports": true
|
| 8 |
+
},
|
| 9 |
+
"include": ["vite.config.ts"]
|
| 10 |
+
}
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import vue from '@vitejs/plugin-vue'
|
| 3 |
+
import { resolve } from 'path'
|
| 4 |
+
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
| 5 |
+
|
| 6 |
+
const absPath = (fp: string): string => {
|
| 7 |
+
return resolve(__dirname, fp)
|
| 8 |
+
}
|
| 9 |
+
// https://vitejs.dev/config/
|
| 10 |
+
export default defineConfig({
|
| 11 |
+
define: {
|
| 12 |
+
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true'
|
| 13 |
+
},
|
| 14 |
+
base: "./",
|
| 15 |
+
build: {
|
| 16 |
+
outDir: 'www',
|
| 17 |
+
},
|
| 18 |
+
plugins: [vue({
|
| 19 |
+
script: {
|
| 20 |
+
defineModel: true
|
| 21 |
+
}
|
| 22 |
+
}),
|
| 23 |
+
// viteStaticCopy({
|
| 24 |
+
// targets: [
|
| 25 |
+
// {
|
| 26 |
+
// src: 'node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js',
|
| 27 |
+
// dest: './assets/'
|
| 28 |
+
// },
|
| 29 |
+
// {
|
| 30 |
+
// src: 'node_modules/@ricky0123/vad-web/dist/silero_vad.onnx',
|
| 31 |
+
// dest: './assets/'
|
| 32 |
+
// },
|
| 33 |
+
// {
|
| 34 |
+
// src: 'node_modules/onnxruntime-web/dist/*.wasm',
|
| 35 |
+
// dest: './assets/'
|
| 36 |
+
// },
|
| 37 |
+
// {
|
| 38 |
+
// src: 'node_modules/onnxruntime-web/dist/*.mjs',
|
| 39 |
+
// dest: './assets/'
|
| 40 |
+
// }
|
| 41 |
+
// ]
|
| 42 |
+
// })
|
| 43 |
+
],
|
| 44 |
+
assetsInclude: [
|
| 45 |
+
"**/*.txt",
|
| 46 |
+
],
|
| 47 |
+
resolve: {
|
| 48 |
+
alias: {
|
| 49 |
+
// @ is an alias to /src
|
| 50 |
+
'@': absPath('src'),
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
})
|
main.py
CHANGED
|
@@ -39,11 +39,11 @@ async def lifespan(app:FastAPI):
|
|
| 39 |
yield
|
| 40 |
|
| 41 |
|
| 42 |
-
FRONTEND_DIR = os.path.join(BASE_DIR, "
|
| 43 |
|
| 44 |
|
| 45 |
app = FastAPI(lifespan=lifespan)
|
| 46 |
-
app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="
|
| 47 |
pipe = None
|
| 48 |
|
| 49 |
@app.get("/")
|
|
|
|
| 39 |
yield
|
| 40 |
|
| 41 |
|
| 42 |
+
FRONTEND_DIR = os.path.join(BASE_DIR, "web")
|
| 43 |
|
| 44 |
|
| 45 |
app = FastAPI(lifespan=lifespan)
|
| 46 |
+
app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="web")
|
| 47 |
pipe = None
|
| 48 |
|
| 49 |
@app.get("/")
|
{frontend → web}/assets/index-b1f15c01.css
RENAMED
|
File without changes
|
{frontend → web}/assets/index-fc3a0f87.js
RENAMED
|
File without changes
|
web/favicon.ico
ADDED
|
|
web/index.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="./favicon.ico" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Translator</title>
|
| 8 |
+
<script type="module" crossorigin src="./assets/index-fc3a0f87.js"></script>
|
| 9 |
+
<link rel="stylesheet" href="./assets/index-b1f15c01.css">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="app"></div>
|
| 13 |
+
|
| 14 |
+
</body>
|
| 15 |
+
</html>
|