From 39fff8c63cf2aa77e1904599a830269de1f820d6 Mon Sep 17 00:00:00 2001 From: wuxichen <17301714657@163.com> Date: Wed, 3 Sep 2025 15:06:16 +0800 Subject: [PATCH] init --- .env | 1 + .gitignore | 1 + .vscode/settings.json | 3 + LICENSE | 201 ++ README.md | 170 + index.html | 39 + package.json | 50 + pnpm-lock.yaml | 2906 +++++++++++++++++ postcss.config.js | 13 + public/null.svg | 0 src/api/mock.ts | 37 + src/assets/translate/cat.svg | 64 + src/assets/translate/dog.svg | 42 + src/assets/translate/microphone.svg | 6 + .../translate/microphoneDisabledSvg.svg | 4 + src/assets/translate/pig.svg | 49 + src/assets/translate/playing.svg | 24 + src/component/audioRecorder/archives.tsx | 5 + .../audioRecorder/deviceCompatibility.tsx | 139 + src/component/audioRecorder/index copy.tsx | 278 ++ src/component/audioRecorder/index.less | 774 +++++ src/component/audioRecorder/index.tsx | 275 ++ .../audioRecorder/recordingStatusBar.tsx | 54 + src/component/audioRecorder/translateItem.tsx | 11 + src/component/audioRecorder/voiceMessage.tsx | 322 ++ .../audioRecorder/voiceRecordButton.less | 350 ++ .../audioRecorder/voiceRecordButton.tsx | 248 ++ src/component/carousel/index.less | 7 + src/component/carousel/index.tsx | 49 + src/component/floatingMenu/index.less | 42 + src/component/floatingMenu/index.tsx | 196 ++ .../petVoiceTranslator copy/index.less | 662 ++++ .../petVoiceTranslator copy/index.tsx | 916 ++++++ src/component/petVoiceTranslator/index.less | 660 ++++ src/component/petVoiceTranslator/index.tsx | 1104 +++++++ src/component/qr-scanner/index.tsx | 88 + src/component/voiceIcon/index.less | 69 + src/component/voiceIcon/index.tsx | 22 + src/component/xpopup/index.less | 11 + src/component/xpopup/index.tsx | 28 + src/composables/authorization.ts | 42 + src/composables/language.ts | 21 + src/enum/http-enum.ts | 21 + src/hooks/i18n.ts | 12 + src/hooks/location.ts | 60 + src/hooks/session.ts | 28 + src/hooks/useAudioControl.ts | 55 + src/hooks/useFileUpload.ts | 198 ++ src/hooks/usePetTranslator.ts | 153 + src/hooks/useVoiceRecorder.ts | 125 + src/http/axios-instance.ts | 49 + src/index.css | 103 + src/layout/main/index.less | 20 + src/layout/main/mainLayout.tsx | 89 + src/locales/en_US.json | 3 + src/locales/zh_CN.json | 3 + src/main.tsx | 13 + src/route/auth.tsx | 29 + src/route/render-routes.tsx | 27 + src/route/routes.tsx | 19 + src/store/i18n.ts | 21 + src/store/user.ts | 22 + src/types/chat.ts | 41 + src/types/http.d.ts | 14 + src/types/store.d.ts | 19 + src/types/upload.ts | 34 + src/utils/amount.ts | 3 + src/utils/audioManager.ts | 208 ++ src/utils/audioPlayer.ts | 246 ++ src/utils/audioRecorder.ts | 362 ++ src/utils/index.ts | 2 + src/utils/js-audio-recorder.d.ts | 22 + src/utils/location.ts | 3 + src/utils/voice.ts | 186 ++ src/view/app/App.css | 0 src/view/app/App.tsx | 24 + src/view/archives/index.less | 0 src/view/archives/index.tsx | 14 + src/view/error/page404.tsx | 18 + src/view/home/component/index.less | 0 src/view/home/component/message/index.less | 46 + src/view/home/component/message/index.tsx | 176 + src/view/home/component/search/index.less | 22 + src/view/home/component/search/index.tsx | 87 + src/view/home/component/voice/index.less | 54 + src/view/home/component/voice/index.tsx | 277 ++ src/view/home/detail.tsx | 5 + src/view/home/index.less | 39 + src/view/home/index.tsx | 31 + src/view/home/translate.tsx | 154 + src/view/home/types.ts | 12 + src/view/setting/index.tsx | 13 + src/vite-env.d.ts | 9 + tsconfig.json | 30 + tsconfig.node.json | 10 + vite.config.ts | 21 + 96 files changed, 13215 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.html create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.js create mode 100644 public/null.svg create mode 100644 src/api/mock.ts create mode 100644 src/assets/translate/cat.svg create mode 100644 src/assets/translate/dog.svg create mode 100644 src/assets/translate/microphone.svg create mode 100644 src/assets/translate/microphoneDisabledSvg.svg create mode 100644 src/assets/translate/pig.svg create mode 100644 src/assets/translate/playing.svg create mode 100644 src/component/audioRecorder/archives.tsx create mode 100644 src/component/audioRecorder/deviceCompatibility.tsx create mode 100644 src/component/audioRecorder/index copy.tsx create mode 100644 src/component/audioRecorder/index.less create mode 100644 src/component/audioRecorder/index.tsx create mode 100644 src/component/audioRecorder/recordingStatusBar.tsx create mode 100644 src/component/audioRecorder/translateItem.tsx create mode 100644 src/component/audioRecorder/voiceMessage.tsx create mode 100644 src/component/audioRecorder/voiceRecordButton.less create mode 100644 src/component/audioRecorder/voiceRecordButton.tsx create mode 100644 src/component/carousel/index.less create mode 100644 src/component/carousel/index.tsx create mode 100644 src/component/floatingMenu/index.less create mode 100644 src/component/floatingMenu/index.tsx create mode 100644 src/component/petVoiceTranslator copy/index.less create mode 100644 src/component/petVoiceTranslator copy/index.tsx create mode 100644 src/component/petVoiceTranslator/index.less create mode 100644 src/component/petVoiceTranslator/index.tsx create mode 100644 src/component/qr-scanner/index.tsx create mode 100644 src/component/voiceIcon/index.less create mode 100644 src/component/voiceIcon/index.tsx create mode 100644 src/component/xpopup/index.less create mode 100644 src/component/xpopup/index.tsx create mode 100644 src/composables/authorization.ts create mode 100644 src/composables/language.ts create mode 100644 src/enum/http-enum.ts create mode 100644 src/hooks/i18n.ts create mode 100644 src/hooks/location.ts create mode 100644 src/hooks/session.ts create mode 100644 src/hooks/useAudioControl.ts create mode 100644 src/hooks/useFileUpload.ts create mode 100644 src/hooks/usePetTranslator.ts create mode 100644 src/hooks/useVoiceRecorder.ts create mode 100644 src/http/axios-instance.ts create mode 100644 src/index.css create mode 100644 src/layout/main/index.less create mode 100644 src/layout/main/mainLayout.tsx create mode 100644 src/locales/en_US.json create mode 100644 src/locales/zh_CN.json create mode 100644 src/main.tsx create mode 100644 src/route/auth.tsx create mode 100644 src/route/render-routes.tsx create mode 100644 src/route/routes.tsx create mode 100644 src/store/i18n.ts create mode 100644 src/store/user.ts create mode 100644 src/types/chat.ts create mode 100644 src/types/http.d.ts create mode 100644 src/types/store.d.ts create mode 100644 src/types/upload.ts create mode 100644 src/utils/amount.ts create mode 100644 src/utils/audioManager.ts create mode 100644 src/utils/audioPlayer.ts create mode 100644 src/utils/audioRecorder.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/js-audio-recorder.d.ts create mode 100644 src/utils/location.ts create mode 100644 src/utils/voice.ts create mode 100644 src/view/app/App.css create mode 100644 src/view/app/App.tsx create mode 100644 src/view/archives/index.less create mode 100644 src/view/archives/index.tsx create mode 100644 src/view/error/page404.tsx create mode 100644 src/view/home/component/index.less create mode 100644 src/view/home/component/message/index.less create mode 100644 src/view/home/component/message/index.tsx create mode 100644 src/view/home/component/search/index.less create mode 100644 src/view/home/component/search/index.tsx create mode 100644 src/view/home/component/voice/index.less create mode 100644 src/view/home/component/voice/index.tsx create mode 100644 src/view/home/detail.tsx create mode 100644 src/view/home/index.less create mode 100644 src/view/home/index.tsx create mode 100644 src/view/home/translate.tsx create mode 100644 src/view/home/types.ts create mode 100644 src/view/setting/index.tsx create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env b/.env new file mode 100644 index 0000000..38aee11 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +HTTPS=true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..44aeb40 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..be30227 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +
+ +

Antd Mobile H5 Template

+ +

+ Vite + React + Antd Mobile 组件库的H5初始化模版(脚手架) +

+ +
+ +## 功能 +1. 使用了`postcss`实现`px to rem` +2. 使用`zustand`作为全局缓存库,并依赖其功能实现了基本的`i18n`国际化 +3. 配置了`icon-park`,即字节旗下`ico`库 +4. 对`react-router-dom`进行了功能封装 +5. 使用`Ant Design Mobile`与`Layout`实现了带有`Tabbar`的公共模版 +6. 使用`axios-hooks`库作为请求库,并对`axios`进行少量封装 +7. 自定义`hooks`: `useLocation`、`useI18n`、`useSessionStorage` +8. 基于`js-qr`实现了浏览器扫码功能组件`QRScanner` + +## 说明 +1. 配置`axios`的基地址直接修改`env`配置字段`VITE_BASE_URL` +2. 全局根字体大小断点(`src/index.css`) + +```html + html { + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-size: 100%; + } + + /* 小屏幕设备(如手机)*/ + @media (max-width: 600px) { + html { + font-size: 90%; /* 字体稍微小一点 */ + } + } + + /* 中等屏幕设备(如平板)*/ + @media (min-width: 601px) and (max-width: 1024px) { + html { + font-size: 100%; /* 标准大小 */ + } + } + + /* 大屏幕设备(如桌面)*/ + @media (min-width: 1025px) { + html { + font-size: 110%; /* 字体稍微大一点 */ + } + } +``` + +3. 组件库全局配色(`src/index.css`) + +```html + :root { + --primary-color: #FFC300; + } + + :root:root { + --adm-color-primary: #FFC300; + --adm-color-success: #00b578; + --adm-color-warning: #ff8f1f; + --adm-color-danger: #ff3141; + + --adm-color-white: #ffffff; + --adm-color-text: #333333; + --adm-color-text-secondary: #666666; + --adm-color-weak: #999999; + --adm-color-light: #cccccc; + --adm-color-border: #eeeeee; + --adm-color-box: #f5f5f5; + --adm-color-background: #ffffff; + + --adm-font-size-main: var(--adm-font-size-5); + + --adm-font-family: -apple-system, blinkmacsystemfont, 'Helvetica Neue', + helvetica, segoe ui, arial, roboto, 'PingFang SC', 'miui', + 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif; + } +``` +4. 修改语言 + +```js +const i18nStore = useI18nStore() +i18nStore.changeLanguage('en_US') +i18nStore.changeLanguage('zh_CN') +``` +其他语言可自行扩展,在`App.tsx`中,使用`useI18nStore`hooks同步修改了组件库的国际化配置 +```jsx +const i18nStore = useI18nStore() + return ( + <> + + + + + ) +``` +5. 使用i18n + +```js +const t = useI18n(); + +

t('index.title"')

+``` +国际化文字映射在`src/locales`文件夹下 + +6. 发送请求 + +首先在`src/api`中新增请求文件`xxx.ts`,并定义返回值与参数的`ts interface`,利用 `axios-hooks`发送对应的`http`请求 +```js +import useAxios from 'axios-hooks'; +import {Page, Result} from "@/types/http"; + +export interface MockResult { + id: number; +} + +export interface MockPage { + id: number; +} + +/** + * fetch the data + * 详细使用可以查看 useAxios 的文档 + */ +export const useFetchXXX = () => { + // set the url + const url = `/xxx/xxx`; + // fetch the data + const [{data, loading, error}, refetch] = useAxios>(url); + // to do something + return {data, loading, error, refetch}; +} + + +/** + * fetch the data with page + * 详细使用可以查看 useAxios 的文档 + */ +export const useFetchPageXXX = (page: number, size: number) => { + // set the url + const url = `/xxx/xxx?page=${page}&size=${size}`; + // fetch the data + const [{data, loading, error}, refetch] = useAxios>(url); + // to do something + return {data, loading, error, refetch}; +} +``` +`useAxios`返回值分别为`数据`、`状态`、`错误`、`操作对象`(`refetch`用于中断请求) + +7. 路由与缓存配置可直接参考代码 + +## 补充 +简单的封装,方便构建新项目时直接复用,没有复杂的操作,如果你想使用`react`构建一个`h5`或者是`响应式`的`web`项目,可以直接使用这个模版。 + + +```shell +git clone git@github.com:JanYork/react-h5-template.git + +cd react-h5-template + +pnpm i + +pnpm run dev +``` \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..237e845 --- /dev/null +++ b/index.html @@ -0,0 +1,39 @@ + + + + + + + Antd Mobile H5 Templet + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..8bf8314 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "ec-web-h5", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:https": "vite --https", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@icon-park/react": "^1.4.2", + "@types/node": "^20.10.0", + "@types/react-router-dom": "^5.3.3", + "@uidotdev/usehooks": "^2.4.1", + "@vitejs/plugin-basic-ssl": "^2.1.0", + "antd-mobile": "^5.33.0", + "antd-mobile-icons": "^0.3.0", + "axios": "^1.6.2", + "axios-hooks": "^5.0.2", + "js-audio-recorder": "^1.0.7", + "jsqr": "^1.4.0", + "less": "^4.2.0", + "query-string": "^8.1.0", + "react": "^18.2.0", + "react-audio-voice-recorder": "^2.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "react-slick": "^0.29.0", + "slick-carousel": "^1.8.1", + "weixin-js-sdk": "^1.6.5", + "zustand": "^4.4.6" + }, + "devDependencies": { + "@types/lodash.isequal": "^4.5.8", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@types/react-slick": "^0.23.12", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "eslint": "^9.34.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "postcss-pxtorem": "^6.0.0", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9a8419a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2906 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@icon-park/react': + specifier: ^1.4.2 + version: 1.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^20.10.0 + version: 20.19.11 + '@types/react-router-dom': + specifier: ^5.3.3 + version: 5.3.3 + '@uidotdev/usehooks': + specifier: ^2.4.1 + version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@vitejs/plugin-basic-ssl': + specifier: ^2.1.0 + version: 2.1.0(vite@5.4.19(@types/node@20.19.11)(less@4.4.1)) + antd-mobile: + specifier: ^5.33.0 + version: 5.39.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + antd-mobile-icons: + specifier: ^0.3.0 + version: 0.3.0 + axios: + specifier: ^1.6.2 + version: 1.11.0 + axios-hooks: + specifier: ^5.0.2 + version: 5.1.1(axios@1.11.0)(react@18.3.1) + js-audio-recorder: + specifier: ^1.0.7 + version: 1.0.7 + jsqr: + specifier: ^1.4.0 + version: 1.4.0 + less: + specifier: ^4.2.0 + version: 4.4.1 + query-string: + specifier: ^8.1.0 + version: 8.2.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-audio-voice-recorder: + specifier: ^2.2.0 + version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.20.0 + version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-slick: + specifier: ^0.29.0 + version: 0.29.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + slick-carousel: + specifier: ^1.8.1 + version: 1.8.1(jquery@3.7.1) + weixin-js-sdk: + specifier: ^1.6.5 + version: 1.6.5 + zustand: + specifier: ^4.4.6 + version: 4.5.7(@types/react@18.3.24)(react@18.3.1) + devDependencies: + '@types/lodash.isequal': + specifier: ^4.5.8 + version: 4.5.8 + '@types/react': + specifier: ^18.3.24 + version: 18.3.24 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.24) + '@types/react-slick': + specifier: ^0.23.12 + version: 0.23.13 + '@typescript-eslint/eslint-plugin': + specifier: ^6.10.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^6.10.0 + version: 6.21.0(eslint@9.34.0)(typescript@5.9.2) + '@vitejs/plugin-react-swc': + specifier: ^3.5.0 + version: 3.11.0(vite@5.4.19(@types/node@20.19.11)(less@4.4.1)) + eslint: + specifier: ^9.34.0 + version: 9.34.0 + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.34.0) + eslint-plugin-react-refresh: + specifier: ^0.4.20 + version: 0.4.20(eslint@9.34.0) + postcss-pxtorem: + specifier: ^6.0.0 + version: 6.1.0(postcss@8.5.6) + typescript: + specifier: ^5.2.2 + version: 5.9.2 + vite: + specifier: ^5.0.0 + version: 5.4.19(@types/node@20.19.11)(less@4.4.1) + +packages: + + '@babel/runtime@7.26.10': + resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.28.3': + resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.7.0': + resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.3.1': + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.15.2': + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.34.0': + resolution: {integrity: sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.3.5': + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ffmpeg/ffmpeg@0.11.6': + resolution: {integrity: sha512-uN8J8KDjADEavPhNva6tYO9Fj0lWs9z82swF3YXnTxWMBoFLGq3LZ6FLlIldRKEzhOBKnkVfA8UnFJuvGvNxcA==} + engines: {node: '>=12.16.1'} + + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@icon-park/react@1.4.2': + resolution: {integrity: sha512-+MtQLjNiRuia3fC/NfpSCTIy5KH5b+NkMB9zYd7p3R4aAIK61AjK0OSraaICJdkKooU9jpzk8m0fY4g9A3JqhQ==} + engines: {node: '>= 8.0.0', npm: '>= 5.0.0'} + peerDependencies: + react: '>=16.9' + react-dom: '>=16.9' + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rc-component/mini-decimal@1.1.0': + resolution: {integrity: sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==} + engines: {node: '>=8.x'} + + '@react-spring/animated@9.6.1': + resolution: {integrity: sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@react-spring/core@9.6.1': + resolution: {integrity: sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@react-spring/rafz@9.6.1': + resolution: {integrity: sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==} + + '@react-spring/shared@9.6.1': + resolution: {integrity: sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@react-spring/types@9.6.1': + resolution: {integrity: sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==} + + '@react-spring/web@9.6.1': + resolution: {integrity: sha512-X2zR6q2Z+FjsWfGAmAXlQaoUHbPmfuCaXpuM6TcwXPpLE1ZD4A1eys/wpXboFQmDkjnrlTmKvpVna1MjWpZ5Hw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@remix-run/router@1.23.0': + resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.50.0': + resolution: {integrity: sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.50.0': + resolution: {integrity: sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.50.0': + resolution: {integrity: sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.50.0': + resolution: {integrity: sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.50.0': + resolution: {integrity: sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.50.0': + resolution: {integrity: sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.50.0': + resolution: {integrity: sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.50.0': + resolution: {integrity: sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.50.0': + resolution: {integrity: sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.50.0': + resolution: {integrity: sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loongarch64-gnu@4.50.0': + resolution: {integrity: sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-gnu@4.50.0': + resolution: {integrity: sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.50.0': + resolution: {integrity: sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.50.0': + resolution: {integrity: sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.50.0': + resolution: {integrity: sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.50.0': + resolution: {integrity: sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.50.0': + resolution: {integrity: sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openharmony-arm64@4.50.0': + resolution: {integrity: sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.0': + resolution: {integrity: sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.50.0': + resolution: {integrity: sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.50.0': + resolution: {integrity: sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==} + cpu: [x64] + os: [win32] + + '@swc/core-darwin-arm64@1.13.5': + resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.13.5': + resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.13.5': + resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.13.5': + resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.13.5': + resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-x64-gnu@1.13.5': + resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.13.5': + resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.13.5': + resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.13.5': + resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.13.5': + resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.13.5': + resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.24': + resolution: {integrity: sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/history@4.7.11': + resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash.isequal@4.5.8': + resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} + + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + + '@types/node@20.19.11': + resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react-router-dom@5.3.3': + resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} + + '@types/react-router@5.1.20': + resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} + + '@types/react-slick@0.23.13': + resolution: {integrity: sha512-bNZfDhe/L8t5OQzIyhrRhBr/61pfBcWaYJoq6UDqFtv5LMwfg4NsVDD2J8N01JqdAdxLjOt66OZEp6PX+dGs/A==} + + '@types/react@18.3.24': + resolution: {integrity: sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==} + + '@types/semver@7.7.0': + resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@uidotdev/usehooks@2.4.1': + resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==} + engines: {node: '>=16'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@use-gesture/core@10.3.0': + resolution: {integrity: sha512-rh+6MND31zfHcy9VU3dOZCqGY511lvGcfyJenN4cWZe0u1BH6brBpBddLVXhF2r4BMqWbvxfsbL7D287thJU2A==} + + '@use-gesture/react@10.3.0': + resolution: {integrity: sha512-3zc+Ve99z4usVP6l9knYVbVnZgfqhKah7sIG+PS2w+vpig2v2OLct05vs+ZXMzwxdNCMka8B+8WlOo0z6Pn6DA==} + peerDependencies: + react: '>= 16.8.0' + + '@vitejs/plugin-basic-ssl@2.1.0': + resolution: {integrity: sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 + + '@vitejs/plugin-react-swc@3.11.0': + resolution: {integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ahooks@3.9.5: + resolution: {integrity: sha512-TrjXie49Q8HuHKTa84Fm9A+famMDAG1+7a9S9Gq6RQ0h90Jgqmiq3CkObuRjWT/C4d6nRZCw35Y2k2fmybb5eA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + antd-mobile-icons@0.3.0: + resolution: {integrity: sha512-rqINQpJWZWrva9moCd1Ye695MZYWmqLPE+bY8d2xLRy7iSQwPsinCdZYjpUPp2zL/LnKYSyXxP2ut2A+DC+whQ==} + + antd-mobile-v5-count@1.0.1: + resolution: {integrity: sha512-YGsiEDCPUDz3SzfXi6gLZn/HpeSMW+jgPc4qiYUr1fSopg3hkUie2TnooJdExgfiETHefH3Ggs58He0OVfegLA==} + + antd-mobile@5.39.0: + resolution: {integrity: sha512-x0cr1KYcYEOzLzD8r5S3NYtViTxTkHSh8krjM5q6RxphjabvEFQTZuf3i7gJzICprirJ4GO/F7K3m8qldCiEjw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios-hooks@5.1.1: + resolution: {integrity: sha512-ti27vL2ttZUdOoBSwLzR63sW5zu0cC12jgOOw3PPhG6D8wajt4wfi9t9xUVpvK+6zHawcS2rHo9fKDTiOzOlgg==} + peerDependencies: + axios: '>=1.0.0' + react: ^16.8.0-0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + copy-anything@2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-uri-component@0.4.1: + resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + engines: {node: '>=14.16'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + enquire.js@2.1.6: + resolution: {integrity: sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==} + + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.20: + resolution: {integrity: sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.34.0: + resolution: {integrity: sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + intersection-observer@0.12.2: + resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + + is-what@3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jquery@3.7.1: + resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} + + js-audio-recorder@1.0.7: + resolution: {integrity: sha512-JiDODCElVHGrFyjGYwYyNi7zCbKk9va9C77w+zCPMmi4C6ix7zsX2h3ddHugmo4dOTOTCym9++b/wVW9nC0IaA==} + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + less@4.4.1: + resolution: {integrity: sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA==} + engines: {node: '>=14'} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nano-memoize@3.0.16: + resolution: {integrity: sha512-JyK96AKVGAwVeMj3MoMhaSXaUNqgMbCRSQB3trUV8tYZfWEzqUBKdK1qJpfuNXgKeHOx1jv/IEYTM659ly7zUA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + needle@3.3.1: + resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} + engines: {node: '>= 4.4.x'} + hasBin: true + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + postcss-pxtorem@6.1.0: + resolution: {integrity: sha512-ROODSNci9ADal3zUcPHOF/K83TiCgNSPXQFSbwyPHNV8ioHIE4SaC+FPOufd8jsr5jV2uIz29v1Uqy1c4ov42g==} + peerDependencies: + postcss: ^8.0.0 + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + query-string@8.2.0: + resolution: {integrity: sha512-tUZIw8J0CawM5wyGBiDOAp7ObdRQh4uBor/fUR9ZjmbZVvw95OD9If4w3MQxr99rg0DJZ/9CIORcpEqU5hQG7g==} + engines: {node: '>=14.16'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + rc-field-form@1.44.0: + resolution: {integrity: sha512-el7w87fyDUsca63Y/s8qJcq9kNkf/J5h+iTdqG5WsSHLH0e6Usl7QuYSmSVzJMgtp40mOVZIY/W/QP9zwrp1FA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-motion@2.9.5: + resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-segmented@2.4.1: + resolution: {integrity: sha512-KUi+JJFdKnumV9iXlm+BJ00O4NdVBp2TEexLCk6bK1x/RH83TvYKQMzIz/7m3UTRPD08RM/8VG/JNjWgWbd4cw==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-util@5.44.4: + resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + react-audio-visualize@1.2.0: + resolution: {integrity: sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ==} + peerDependencies: + react: '>=16.2.0' + react-dom: '>=16.2.0' + + react-audio-voice-recorder@2.2.0: + resolution: {integrity: sha512-Hq+143Zs99vJojT/uFvtpxUuiIKoLbMhxhA7qgxe5v8hNXrh5/qTnvYP92hFaE5V+GyoCXlESONa0ufk7t5kHQ==} + peerDependencies: + react: '>=16.2.0' + react-dom: '>=16.2.0' + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-router-dom@6.30.1: + resolution: {integrity: sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.1: + resolution: {integrity: sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react-slick@0.29.0: + resolution: {integrity: sha512-TGdOKE+ZkJHHeC4aaoH85m8RnFyWqdqRfAGkhd6dirmATXMZWAxOpTLmw2Ll/jPTQ3eEG7ercFr/sbzdeYCJXA==} + peerDependencies: + react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-url@0.2.1: + resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==} + deprecated: https://github.com/lydell/resolve-url#deprecated + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.50.0: + resolution: {integrity: sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + runes2@1.1.4: + resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + screenfull@5.2.0: + resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} + engines: {node: '>=0.10.0'} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slick-carousel@1.8.1: + resolution: {integrity: sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==} + peerDependencies: + jquery: '>=1.8.0' + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} + + staged-components@1.1.3: + resolution: {integrity: sha512-9EIswzDqjwlEu+ymkV09TTlJfzSbKgEnNteUnZSTxkpMgr5Wx2CzzA9WcMFWBNCldqVPsHVnRGGrApduq2Se5A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.5.0: + resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@5.4.19: + resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + weixin-js-sdk@1.6.5: + resolution: {integrity: sha512-Gph1WAWB2YN/lMOFB/ymb+hbU/wYazzJgu6PMMktCy9cSCeW5wA6Zwt0dpahJbJ+RJEwtTv2x9iIu0U4enuVSQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@babel/runtime@7.26.10': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/runtime@7.28.3': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.7.0(eslint@9.34.0)': + dependencies: + eslint: 9.34.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/config-array@0.21.0': + dependencies: + '@eslint/object-schema': 2.1.6 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.3.1': {} + + '@eslint/core@0.15.2': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.1 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.34.0': {} + + '@eslint/object-schema@2.1.6': {} + + '@eslint/plugin-kit@0.3.5': + dependencies: + '@eslint/core': 0.15.2 + levn: 0.4.1 + + '@ffmpeg/ffmpeg@0.11.6': + dependencies: + is-url: 1.2.4 + node-fetch: 2.7.0 + regenerator-runtime: 0.13.11 + resolve-url: 0.2.1 + transitivePeerDependencies: + - encoding + + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@icon-park/react@1.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@rc-component/mini-decimal@1.1.0': + dependencies: + '@babel/runtime': 7.28.3 + + '@react-spring/animated@9.6.1(react@18.3.1)': + dependencies: + '@react-spring/shared': 9.6.1(react@18.3.1) + '@react-spring/types': 9.6.1 + react: 18.3.1 + + '@react-spring/core@9.6.1(react@18.3.1)': + dependencies: + '@react-spring/animated': 9.6.1(react@18.3.1) + '@react-spring/rafz': 9.6.1 + '@react-spring/shared': 9.6.1(react@18.3.1) + '@react-spring/types': 9.6.1 + react: 18.3.1 + + '@react-spring/rafz@9.6.1': {} + + '@react-spring/shared@9.6.1(react@18.3.1)': + dependencies: + '@react-spring/rafz': 9.6.1 + '@react-spring/types': 9.6.1 + react: 18.3.1 + + '@react-spring/types@9.6.1': {} + + '@react-spring/web@9.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-spring/animated': 9.6.1(react@18.3.1) + '@react-spring/core': 9.6.1(react@18.3.1) + '@react-spring/shared': 9.6.1(react@18.3.1) + '@react-spring/types': 9.6.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@remix-run/router@1.23.0': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.50.0': + optional: true + + '@rollup/rollup-android-arm64@4.50.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.50.0': + optional: true + + '@rollup/rollup-darwin-x64@4.50.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.50.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.50.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.50.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.50.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.50.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.50.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.50.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.50.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.50.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.50.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.0': + optional: true + + '@swc/core-darwin-arm64@1.13.5': + optional: true + + '@swc/core-darwin-x64@1.13.5': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.13.5': + optional: true + + '@swc/core-linux-arm64-gnu@1.13.5': + optional: true + + '@swc/core-linux-arm64-musl@1.13.5': + optional: true + + '@swc/core-linux-x64-gnu@1.13.5': + optional: true + + '@swc/core-linux-x64-musl@1.13.5': + optional: true + + '@swc/core-win32-arm64-msvc@1.13.5': + optional: true + + '@swc/core-win32-ia32-msvc@1.13.5': + optional: true + + '@swc/core-win32-x64-msvc@1.13.5': + optional: true + + '@swc/core@1.13.5': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.24 + optionalDependencies: + '@swc/core-darwin-arm64': 1.13.5 + '@swc/core-darwin-x64': 1.13.5 + '@swc/core-linux-arm-gnueabihf': 1.13.5 + '@swc/core-linux-arm64-gnu': 1.13.5 + '@swc/core-linux-arm64-musl': 1.13.5 + '@swc/core-linux-x64-gnu': 1.13.5 + '@swc/core-linux-x64-musl': 1.13.5 + '@swc/core-win32-arm64-msvc': 1.13.5 + '@swc/core-win32-ia32-msvc': 1.13.5 + '@swc/core-win32-x64-msvc': 1.13.5 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.24': + dependencies: + '@swc/counter': 0.1.3 + + '@types/estree@1.0.8': {} + + '@types/history@4.7.11': {} + + '@types/js-cookie@3.0.6': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash.isequal@4.5.8': + dependencies: + '@types/lodash': 4.17.20 + + '@types/lodash@4.17.20': {} + + '@types/node@20.19.11': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.24)': + dependencies: + '@types/react': 18.3.24 + + '@types/react-router-dom@5.3.3': + dependencies: + '@types/history': 4.7.11 + '@types/react': 18.3.24 + '@types/react-router': 5.1.20 + + '@types/react-router@5.1.20': + dependencies: + '@types/history': 4.7.11 + '@types/react': 18.3.24 + + '@types/react-slick@0.23.13': + dependencies: + '@types/react': 18.3.24 + + '@types/react@18.3.24': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.1.3 + + '@types/semver@7.7.0': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 6.21.0(eslint@9.34.0)(typescript@5.9.2) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@9.34.0)(typescript@5.9.2) + '@typescript-eslint/utils': 6.21.0(eslint@9.34.0)(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.1 + eslint: 9.34.0 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + semver: 7.7.2 + ts-api-utils: 1.4.3(typescript@5.9.2) + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@9.34.0)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.1 + eslint: 9.34.0 + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@9.34.0)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) + '@typescript-eslint/utils': 6.21.0(eslint@9.34.0)(typescript@5.9.2) + debug: 4.4.1 + eslint: 9.34.0 + ts-api-utils: 1.4.3(typescript@5.9.2) + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.1 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.7.2 + ts-api-utils: 1.4.3(typescript@5.9.2) + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@9.34.0)(typescript@5.9.2)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.0 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) + eslint: 9.34.0 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@uidotdev/usehooks@2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@use-gesture/core@10.3.0': {} + + '@use-gesture/react@10.3.0(react@18.3.1)': + dependencies: + '@use-gesture/core': 10.3.0 + react: 18.3.1 + + '@vitejs/plugin-basic-ssl@2.1.0(vite@5.4.19(@types/node@20.19.11)(less@4.4.1))': + dependencies: + vite: 5.4.19(@types/node@20.19.11)(less@4.4.1) + + '@vitejs/plugin-react-swc@3.11.0(vite@5.4.19(@types/node@20.19.11)(less@4.4.1))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.27 + '@swc/core': 1.13.5 + vite: 5.4.19(@types/node@20.19.11)(less@4.4.1) + transitivePeerDependencies: + - '@swc/helpers' + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ahooks@3.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.3 + '@types/js-cookie': 3.0.6 + dayjs: 1.11.18 + intersection-observer: 0.12.2 + js-cookie: 3.0.5 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-fast-compare: 3.2.2 + resize-observer-polyfill: 1.5.1 + screenfull: 5.2.0 + tslib: 2.8.1 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + antd-mobile-icons@0.3.0: {} + + antd-mobile-v5-count@1.0.1: {} + + antd-mobile@5.39.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@floating-ui/dom': 1.7.4 + '@rc-component/mini-decimal': 1.1.0 + '@react-spring/web': 9.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@use-gesture/react': 10.3.0(react@18.3.1) + ahooks: 3.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + antd-mobile-icons: 0.3.0 + antd-mobile-v5-count: 1.0.1 + classnames: 2.5.1 + dayjs: 1.11.18 + deepmerge: 4.3.1 + nano-memoize: 3.0.16 + rc-field-form: 1.44.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-segmented: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-fast-compare: 3.2.2 + react-is: 18.3.1 + runes2: 1.1.4 + staged-components: 1.1.3(react@18.3.1) + tslib: 2.8.1 + use-sync-external-store: 1.5.0(react@18.3.1) + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios-hooks@5.1.1(axios@1.11.0)(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.10 + axios: 1.11.0 + dequal: 2.0.3 + lru-cache: 11.1.0 + react: 18.3.1 + + axios@1.11.0: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + classnames@2.5.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + copy-anything@2.0.6: + dependencies: + is-what: 3.14.1 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.1.3: {} + + dayjs@1.11.18: {} + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + decode-uri-component@0.4.1: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + enquire.js@2.1.6: {} + + errno@0.1.8: + dependencies: + prr: 1.0.1 + optional: true + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.34.0): + dependencies: + eslint: 9.34.0 + + eslint-plugin-react-refresh@0.4.20(eslint@9.34.0): + dependencies: + eslint: 9.34.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.34.0: + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.3.1 + '@eslint/core': 0.15.2 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.34.0 + '@eslint/plugin-kit': 0.3.5 + '@humanfs/node': 0.16.6 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@5.1.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: + optional: true + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + ignore@5.3.2: {} + + image-size@0.5.5: + optional: true + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + intersection-observer@0.12.2: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-url@1.2.4: {} + + is-what@3.14.1: {} + + isexe@2.0.0: {} + + jquery@3.7.1: {} + + js-audio-recorder@1.0.7: {} + + js-cookie@3.0.5: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + + jsqr@1.4.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + less@4.4.1: + dependencies: + copy-anything: 2.0.6 + parse-node-version: 1.0.1 + tslib: 2.8.1 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + needle: 3.3.1 + source-map: 0.6.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.debounce@4.0.8: {} + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@11.1.0: {} + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + optional: true + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: + optional: true + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + nano-memoize@3.0.16: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + needle@3.3.1: + dependencies: + iconv-lite: 0.6.3 + sax: 1.4.1 + optional: true + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-node-version@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pify@4.0.1: + optional: true + + postcss-pxtorem@6.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + proxy-from-env@1.1.0: {} + + prr@1.0.1: + optional: true + + punycode@2.3.1: {} + + query-string@8.2.0: + dependencies: + decode-uri-component: 0.4.1 + filter-obj: 5.1.0 + split-on-first: 3.0.0 + + queue-microtask@1.2.3: {} + + rc-field-form@1.44.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.3 + async-validator: 4.2.5 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-motion@2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.3 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-segmented@2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.3 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-util@5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + + react-audio-visualize@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react-audio-voice-recorder@2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@ffmpeg/ffmpeg': 0.11.6 + react: 18.3.1 + react-audio-visualize: 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - encoding + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-fast-compare@3.2.2: {} + + react-is@18.3.1: {} + + react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.1(react@18.3.1) + + react-router@6.30.1(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.0 + react: 18.3.1 + + react-slick@0.29.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + classnames: 2.5.1 + enquire.js: 2.1.6 + json2mq: 0.2.0 + lodash.debounce: 4.0.8 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + resize-observer-polyfill: 1.5.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + regenerator-runtime@0.13.11: {} + + regenerator-runtime@0.14.1: {} + + resize-observer-polyfill@1.5.1: {} + + resolve-from@4.0.0: {} + + resolve-url@0.2.1: {} + + reusify@1.1.0: {} + + rollup@4.50.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.50.0 + '@rollup/rollup-android-arm64': 4.50.0 + '@rollup/rollup-darwin-arm64': 4.50.0 + '@rollup/rollup-darwin-x64': 4.50.0 + '@rollup/rollup-freebsd-arm64': 4.50.0 + '@rollup/rollup-freebsd-x64': 4.50.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.0 + '@rollup/rollup-linux-arm-musleabihf': 4.50.0 + '@rollup/rollup-linux-arm64-gnu': 4.50.0 + '@rollup/rollup-linux-arm64-musl': 4.50.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.0 + '@rollup/rollup-linux-ppc64-gnu': 4.50.0 + '@rollup/rollup-linux-riscv64-gnu': 4.50.0 + '@rollup/rollup-linux-riscv64-musl': 4.50.0 + '@rollup/rollup-linux-s390x-gnu': 4.50.0 + '@rollup/rollup-linux-x64-gnu': 4.50.0 + '@rollup/rollup-linux-x64-musl': 4.50.0 + '@rollup/rollup-openharmony-arm64': 4.50.0 + '@rollup/rollup-win32-arm64-msvc': 4.50.0 + '@rollup/rollup-win32-ia32-msvc': 4.50.0 + '@rollup/rollup-win32-x64-msvc': 4.50.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + runes2@1.1.4: {} + + safer-buffer@2.1.2: + optional: true + + sax@1.4.1: + optional: true + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + screenfull@5.2.0: {} + + semver@5.7.2: + optional: true + + semver@7.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + slash@3.0.0: {} + + slick-carousel@1.8.1(jquery@3.7.1): + dependencies: + jquery: 3.7.1 + + source-map-js@1.2.1: {} + + source-map@0.6.1: + optional: true + + split-on-first@3.0.0: {} + + staged-components@1.1.3(react@18.3.1): + dependencies: + react: 18.3.1 + + string-convert@0.2.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + ts-api-utils@1.4.3(typescript@5.9.2): + dependencies: + typescript: 5.9.2 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@5.9.2: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.5.0(react@18.3.1): + dependencies: + react: 18.3.1 + + vite@5.4.19(@types/node@20.19.11)(less@4.4.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.50.0 + optionalDependencies: + '@types/node': 20.19.11 + fsevents: 2.3.3 + less: 4.4.1 + + webidl-conversions@3.0.1: {} + + weixin-js-sdk@1.6.5: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} + + zustand@4.5.7(@types/react@18.3.24)(react@18.3.1): + dependencies: + use-sync-external-store: 1.5.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.24 + react: 18.3.1 diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..fa5e842 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,13 @@ +export default { + plugins: { + 'postcss-pxtorem': { + rootValue: 16, + unitPrecision: 5, + propList: ['*'], + selectorBlackList: [], + replace: true, + mediaQuery: false, + minPixelValue: 0, + }, + }, +}; diff --git a/public/null.svg b/public/null.svg new file mode 100644 index 0000000..e69de29 diff --git a/src/api/mock.ts b/src/api/mock.ts new file mode 100644 index 0000000..0e65ac0 --- /dev/null +++ b/src/api/mock.ts @@ -0,0 +1,37 @@ +import useAxios from 'axios-hooks'; +import {Page, Result} from "@/types/http"; + +export interface MockResult { + id: number; +} + +export interface MockPage { + id: number; +} + +/** + * fetch the data + * 详细使用可以查看 useAxios 的文档 + */ +export const useFetchXXX = () => { + // set the url + const url = `/xxx/xxx`; + // fetch the data + const [{data, loading, error}, refetch] = useAxios>(url); + // to do something + return {data, loading, error, refetch}; +} + + +/** + * fetch the data with page + * 详细使用可以查看 useAxios 的文档 + */ +export const useFetchPageXXX = (page: number, size: number) => { + // set the url + const url = `/xxx/xxx?page=${page}&size=${size}`; + // fetch the data + const [{data, loading, error}, refetch] = useAxios>(url); + // to do something + return {data, loading, error, refetch}; +} \ No newline at end of file diff --git a/src/assets/translate/cat.svg b/src/assets/translate/cat.svg new file mode 100644 index 0000000..3457da1 --- /dev/null +++ b/src/assets/translate/cat.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/translate/dog.svg b/src/assets/translate/dog.svg new file mode 100644 index 0000000..aa4b8b8 --- /dev/null +++ b/src/assets/translate/dog.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/translate/microphone.svg b/src/assets/translate/microphone.svg new file mode 100644 index 0000000..6499bd9 --- /dev/null +++ b/src/assets/translate/microphone.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/translate/microphoneDisabledSvg.svg b/src/assets/translate/microphoneDisabledSvg.svg new file mode 100644 index 0000000..463ff15 --- /dev/null +++ b/src/assets/translate/microphoneDisabledSvg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/translate/pig.svg b/src/assets/translate/pig.svg new file mode 100644 index 0000000..b1ece1b --- /dev/null +++ b/src/assets/translate/pig.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/translate/playing.svg b/src/assets/translate/playing.svg new file mode 100644 index 0000000..191166a --- /dev/null +++ b/src/assets/translate/playing.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/component/audioRecorder/archives.tsx b/src/component/audioRecorder/archives.tsx new file mode 100644 index 0000000..1a09fbe --- /dev/null +++ b/src/component/audioRecorder/archives.tsx @@ -0,0 +1,5 @@ +function Index() { + return <>档案; +} + +export default Index; diff --git a/src/component/audioRecorder/deviceCompatibility.tsx b/src/component/audioRecorder/deviceCompatibility.tsx new file mode 100644 index 0000000..a599eb7 --- /dev/null +++ b/src/component/audioRecorder/deviceCompatibility.tsx @@ -0,0 +1,139 @@ +// components/DeviceCompatibility.tsx +import React, { useEffect, useState } from "react"; +import { UniversalAudioRecorder } from "@/utils/audioRecorder"; + +interface DeviceInfo { + isIOS: boolean; + isSafari: boolean; + supportedFormats: string[]; + hasMediaRecorder: boolean; + hasGetUserMedia: boolean; +} + +const DeviceCompatibility: React.FC = () => { + const [deviceInfo, setDeviceInfo] = useState(null); + const [showDetails, setShowDetails] = useState(false); + + useEffect(() => { + const checkCompatibility = () => { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + const isSafari = /^((?!chrome|android).)*safari/i.test( + navigator.userAgent + ); + const hasMediaRecorder = typeof MediaRecorder !== "undefined"; + const hasGetUserMedia = !!( + navigator.mediaDevices && navigator.mediaDevices.getUserMedia + ); + + let supportedFormats: string[] = []; + if (hasMediaRecorder) { + supportedFormats = UniversalAudioRecorder.getSupportedFormats(); + } + + setDeviceInfo({ + isIOS, + isSafari, + supportedFormats, + hasMediaRecorder, + hasGetUserMedia, + }); + }; + + checkCompatibility(); + }, []); + + if (!deviceInfo) return null; + + const getCompatibilityStatus = (): "good" | "warning" | "error" => { + if (!deviceInfo.hasMediaRecorder || !deviceInfo.hasGetUserMedia) { + return "error"; + } + if (deviceInfo.supportedFormats.length === 0) { + return "warning"; + } + return "good"; + }; + + const status = getCompatibilityStatus(); + + return ( +
+
setShowDetails(!showDetails)} + > + + {status === "good" && "✅"} + {status === "warning" && "⚠️"} + {status === "error" && "❌"} + + + {status === "good" && "设备兼容"} + {status === "warning" && "部分兼容"} + {status === "error" && "不兼容"} + + {showDetails ? "▼" : "▶"} +
+ + {showDetails && ( +
+
+

+ 设备信息: +

+
    +
  • iOS设备: {deviceInfo.isIOS ? "是" : "否"}
  • +
  • Safari浏览器: {deviceInfo.isSafari ? "是" : "否"}
  • +
  • + 支持MediaRecorder: {deviceInfo.hasMediaRecorder ? "是" : "否"} +
  • +
  • + 支持getUserMedia: {deviceInfo.hasGetUserMedia ? "是" : "否"} +
  • +
+
+ +
+

+ 支持的音频格式: +

+ {deviceInfo.supportedFormats.length > 0 ? ( +
    + {deviceInfo.supportedFormats.map((format, index) => ( +
  • {format}
  • + ))} +
+ ) : ( +

未检测到支持的格式

+ )} +
+ + {status === "error" && ( +
+

+ 错误: 您的设备不支持录音功能 +

+

请尝试:

+
    +
  • 使用最新版本的浏览器
  • +
  • 确保在HTTPS环境下访问
  • +
  • 检查浏览器权限设置
  • +
+
+ )} + + {status === "warning" && ( +
+

+ 警告: 录音功能可能不稳定 +

+

建议使用Chrome、Safari或Firefox最新版本

+
+ )} +
+ )} +
+ ); +}; + +export default DeviceCompatibility; diff --git a/src/component/audioRecorder/index copy.tsx b/src/component/audioRecorder/index copy.tsx new file mode 100644 index 0000000..ad1c8ff --- /dev/null +++ b/src/component/audioRecorder/index copy.tsx @@ -0,0 +1,278 @@ +// components/PetTranslatorChat.tsx (添加音频控制) +import React, { useRef, useEffect } from "react"; +import { usePetTranslator } from "@/hooks/usePetTranslator"; +import { useFileUpload } from "@/hooks/useFileUpload"; +import { useAudioControl } from "@/hooks/useAudioControl"; +import { VoiceMessage, ChatMessage } from "@/types/chat"; +import { UploadConfig } from "@/types/upload"; +import VoiceMessageComponent from "./voiceMessage"; +import VoiceRecordButton from "./voiceRecordButton"; +import RecordingStatusBar from "./recordingStatusBar"; +import AudioManager from "@/utils/audioManager"; +import { useVoiceRecorder } from "@/hooks/useVoiceRecorder"; +import "./index.less"; +import { CapsuleTabs } from "antd-mobile"; + +const PetTranslatorChat: React.FC = () => { + const { + messages, + currentPet, + translateVoice, + addMessage, + updateMessage, + clearMessages, + } = usePetTranslator(); + + const { isRecording, isPaused, recordingTime } = useVoiceRecorder(); + const { uploadFile } = useFileUpload(); + const { currentPlayingId, stopAllAudio, pauseAllAudio } = useAudioControl(); + const messagesEndRef = useRef(null); + + // 上传配置 + const uploadConfig: UploadConfig = { + url: "/api/upload/voice", + method: "POST", + fieldName: "voiceFile", + maxFileSize: 10 * 1024 * 1024, // 10MB + allowedTypes: [ + "audio/webm", + "audio/mp4", + "audio/aac", + "audio/wav", + "audio/ogg", + "audio/mpeg", + "audio/x-m4a", + ], + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }; + + // 组件卸载时清理所有音频 + useEffect(() => { + return () => { + AudioManager.getInstance().cleanup(); + }; + }, []); + + // 开始录音时暂停所有音频播放 + useEffect(() => { + if (isRecording) { + pauseAllAudio(); + } + }, [isRecording, pauseAllAudio]); + + const handleRecordComplete = ( + audioBlob: Blob, + duration: number, + uploadResponse?: any + ) => { + const audioUrl = URL.createObjectURL(audioBlob); + + const voiceMessage: VoiceMessage = { + id: `voice_${Date.now()}`, + type: "voice", + content: { + duration, + url: audioUrl, + blob: audioBlob, + uploadStatus: uploadResponse ? "success" : undefined, + fileId: uploadResponse?.data?.fileId, + fileName: uploadResponse?.data?.fileName, + serverUrl: uploadResponse?.data?.fileUrl, + }, + sender: "user", + timestamp: Date.now(), + }; + + addMessage(voiceMessage); + + // 自动开始翻译 + setTimeout(() => { + translateVoice(voiceMessage); + }, 500); + }; + + const handleRetryUpload = async (messageId: string) => { + const message = messages.find( + (msg) => msg.id === messageId + ) as VoiceMessage; + if (!message || !message.content.blob) return; + + try { + // 更新上传状态 + updateMessage(messageId, { + ...message, + content: { + ...message.content, + uploadStatus: "uploading", + uploadProgress: 0, + }, + }); + + const fileName = `voice_${Date.now()}.wav`; + const uploadResponse = await uploadFile( + message.content.blob, + fileName, + uploadConfig + ); + + // 更新成功状态 + updateMessage(messageId, { + ...message, + content: { + ...message.content, + uploadStatus: "success", + fileId: uploadResponse.data?.fileId, + fileName: uploadResponse.data?.fileName, + serverUrl: uploadResponse.data?.fileUrl, + }, + }); + } catch (error) { + // 更新失败状态 + updateMessage(messageId, { + ...message, + content: { + ...message.content, + uploadStatus: "error", + }, + }); + + console.error("重新上传失败:", error); + } + }; + + const handleRecordError = (error: Error) => { + alert(error.message); + }; + + const formatTime = (timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + }; + + return ( + <> + {/* 录音状态栏 */} + + + {/* 头部 */} + {/*
+
+
{currentPet.avatar}
+
+
{currentPet.name}
+
+ {isRecording + ? "正在听你说话..." + : currentPlayingId + ? "正在播放语音..." + : "在线 · 等待翻译"} +
+
+
*/} + + {/*
+ + {currentPlayingId && ( + + )} + + */} + {/*
+
*/} +
+ + + 宠物翻译 + + + 宠物档案 + + +
+ + {/* 消息列表 */} +
+ {messages.length === 0 ? ( +
+
🐾
+
开始和{currentPet.name}对话吧!
+
+ 点击下方按钮开始录音,我会帮你翻译 +
+
+ ) : ( + messages.map((message) => ( +
+
+ {message.sender === "pet" && ( +
{currentPet.avatar}
+ )} + +
+ {message.type === "voice" ? ( + translateVoice(message)} + onRetryUpload={() => handleRetryUpload(message.id)} + /> + ) : ( +
{message.content}
+ )} + +
+ {formatTime(message.timestamp)} +
+
+ + {message.sender === "user" && ( +
👤
+ )} +
+
+ )) + )} +
+
+ + {/* 输入区域 */} + + + ); +}; + +export default PetTranslatorChat; diff --git a/src/component/audioRecorder/index.less b/src/component/audioRecorder/index.less new file mode 100644 index 0000000..d8a03a5 --- /dev/null +++ b/src/component/audioRecorder/index.less @@ -0,0 +1,774 @@ +/* PetTranslatorChat.css */ +.pet-translator-chat { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 400px; + margin: 0 auto; + background: #f5f5f5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +/* 头部样式 */ +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: #fff; + color: rgba(0, 0, 0, 0.25); + .lef { + display: flex; + gap: 12px; + + h2 { + font-size: 20px; + &.active { + color: #000; + } + } + } +} + +.pet-info { + display: flex; + align-items: center; + gap: 12px; +} + +.pet-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.pet-name { + font-weight: 600; + color: #333; + font-size: 16px; +} + +.pet-status { + font-size: 12px; + color: #999; +} + +.clear-button { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + padding: 8px; + border-radius: 50%; + transition: background 0.3s; +} + +.clear-button:hover { + background: #f0f0f0; +} + +/* 消息容器 */ +.messages-container { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: #999; +} + +.empty-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.empty-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; + color: #666; +} + +.empty-subtitle { + font-size: 14px; + line-height: 1.4; +} + +/* 消息样式 */ +.message { + display: flex; + gap: 8px; + max-width: 80%; +} + +.message.own { + align-self: flex-end; + flex-direction: row-reverse; +} + +.message.other { + align-self: flex-start; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.user-avatar { + background: #007bff; + color: white; +} + +.message-content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.message-time { + font-size: 11px; + color: #999; + text-align: center; +} + +/* 语音消息样式 */ +.voice-message { + background: #fff; + border-radius: 18px; + padding: 12px 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + min-width: 120px; +} + +.voice-message.own { + background: #007bff; + color: white; +} + +.voice-content { + display: flex; + align-items: center; + gap: 8px; +} + +.play-button { + width: 24px; + height: 24px; + border: none; + background: none; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background 0.3s; +} + +.play-button:hover { + background: rgba(0, 0, 0, 0.1); +} + +.voice-message.own .play-button:hover { + background: rgba(255, 255, 255, 0.2); +} + +.waveform { + display: flex; + align-items: center; + gap: 2px; + height: 20px; + flex: 1; +} + +.waveform-bar { + width: 3px; + background: #ddd; + border-radius: 2px; + transition: all 0.3s; +} + +.voice-message.own .waveform-bar { + background: rgba(255, 255, 255, 0.6); +} + +.waveform-bar.active { + background: #007bff; +} + +.voice-message.own .waveform-bar.active { + background: white; +} + +.duration { + font-size: 12px; + color: #666; + min-width: 30px; + text-align: right; +} + +.voice-message.own .duration { + color: rgba(255, 255, 255, 0.8); +} + +/* 翻译部分 */ +.translation-section { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #eee; +} + +.translate-button { + background: #28a745; + color: white; + border: none; + border-radius: 12px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + transition: background 0.3s; +} + +.translate-button:hover { + background: #218838; +} + +.translating { + display: flex; + align-items: center; + gap: 8px; + color: #666; + font-size: 12px; +} + +.loading-dots { + display: flex; + gap: 2px; +} + +.loading-dots span { + width: 4px; + height: 4px; + background: #666; + border-radius: 50%; + animation: loading 1.4s infinite ease-in-out; +} + +.loading-dots span:nth-child(1) { + animation-delay: -0.32s; +} +.loading-dots span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes loading { + 0%, + 80%, + 100% { + transform: scale(0); + } + 40% { + transform: scale(1); + } +} + +.translation-result { + display: flex; + align-items: flex-start; + gap: 8px; + background: rgba(40, 167, 69, 0.1); + padding: 8px; + border-radius: 8px; + border-left: 3px solid #28a745; +} + +.translation-icon { + font-size: 14px; +} + +.translation-text { + font-size: 13px; + line-height: 1.4; + color: #333; +} + +/* 文本消息 */ +.text-message { + background: #fff; + border-radius: 18px; + padding: 12px 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + font-size: 14px; + line-height: 1.4; +} + +.message.other .text-message { + background: #e8f5e8; +} + +/* 输入区域 */ +.input-area { + padding: 16px; + background: #fff; + border-top: 1px solid #e5e5e5; +} + +/* 录音按钮 */ +.voice-record-container { + position: relative; +} + +.voice-record-button { + width: 100%; + height: 50px; + background: #007bff; + color: white; + border: none; + border-radius: 25px; + font-size: 16px; + cursor: pointer; + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + user-select: none; +} + +.voice-record-button:hover { + background: #0056b3; +} + +.voice-record-button.pressed { + background: #dc3545; + transform: scale(0.95); +} + +.microphone-icon { + font-size: 18px; +} + +.button-text { + font-weight: 500; +} + +/* 录音覆盖层 */ +.recording-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.recording-modal { + background: white; + border-radius: 20px; + padding: 40px; + text-align: center; + max-width: 300px; + width: 90%; +} + +.recording-animation { + margin-bottom: 20px; +} + +.sound-wave { + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + height: 40px; +} + +.wave-bar { + width: 4px; + background: #007bff; + border-radius: 2px; + animation: wave 1s infinite ease-in-out; +} + +.wave-bar:nth-child(1) { + animation-delay: -0.4s; +} +.wave-bar:nth-child(2) { + animation-delay: -0.3s; +} +.wave-bar:nth-child(3) { + animation-delay: -0.2s; +} +.wave-bar:nth-child(4) { + animation-delay: -0.1s; +} +.wave-bar:nth-child(5) { + animation-delay: 0s; +} + +@keyframes wave { + 0%, + 40%, + 100% { + height: 10px; + } + 20% { + height: 30px; + } +} + +.recording-time { + font-size: 24px; + font-weight: 600; + color: #333; + margin-bottom: 16px; +} + +.recording-hint { + font-size: 14px; + color: #666; +} + +.cancel-hint { + color: #dc3545; + font-weight: 600; +} + +.normal-hint { + color: #666; +} + +/* 响应式 */ +@media (max-width: 480px) { + .pet-translator-chat { + height: 100vh; + } + + .message { + max-width: 85%; + } + + .recording-modal { + padding: 30px 20px; + } +} + +/* 上传进度样式 */ +.upload-progress { + margin: 12px 0; + padding: 8px; + background: rgba(0, 123, 255, 0.1); + border-radius: 8px; + border: 1px solid rgba(0, 123, 255, 0.2); +} + +.upload-status { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 13px; + color: #007bff; +} + +.upload-icon { + font-size: 14px; +} + +.upload-progress-bar { + width: 100%; + height: 4px; + background: rgba(0, 123, 255, 0.2); + border-radius: 2px; + overflow: hidden; +} + +.upload-progress-fill { + height: 100%; + background: #007bff; + border-radius: 2px; + transition: width 0.3s ease; +} + +/* 语音消息上传状态 */ +.upload-status-section { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.2); +} + +.voice-message.own .upload-status-section { + border-top-color: rgba(255, 255, 255, 0.3); +} + +.upload-status-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + margin-bottom: 4px; +} + +.upload-status-indicator.uploading { + color: #007bff; +} + +.upload-status-indicator.success { + color: #28a745; +} + +.upload-status-indicator.error { + color: #dc3545; +} + +.retry-upload-button { + background: #dc3545; + color: white; + border: none; + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + cursor: pointer; + margin-left: 4px; + transition: background 0.3s; +} + +.retry-upload-button:hover { + background: #c82333; +} + +.file-info { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 10px; + color: rgba(255, 255, 255, 0.7); +} + +.voice-message:not(.own) .file-info { + color: #666; +} + +.file-name, +.file-size { + display: flex; + align-items: center; + gap: 4px; +} + +/* 处理中状态 */ +.processing-hint { + color: #007bff; + font-weight: 600; +} + +.upload-hint { + color: #28a745; + font-weight: 600; +} + +/* 按钮禁用状态 */ +.control-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +.voice-start-button:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none !important; +} + +/* 上传动画 */ +@keyframes uploadPulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.6; + } + 100% { + opacity: 1; + } +} + +.upload-status-indicator.uploading .upload-icon { + animation: uploadPulse 1s infinite; +} + +/* DeviceCompatibility.css */ +.device-compatibility { + margin: 10px 0; + border-radius: 8px; + overflow: hidden; + font-size: 12px; +} + +.device-compatibility.good { + background: #d4edda; + border: 1px solid #c3e6cb; +} + +.device-compatibility.warning { + background: #fff3cd; + border: 1px solid #ffeaa7; +} + +.device-compatibility.error { + background: #f8d7da; + border: 1px solid #f5c6cb; +} + +.compatibility-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + user-select: none; +} + +.status-icon { + font-size: 14px; +} + +.status-text { + flex: 1; + font-weight: 600; +} + +.toggle-icon { + font-size: 10px; + transition: transform 0.3s; +} + +.compatibility-details { + padding: 12px; + border-top: 1px solid rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.5); +} + +.device-info ul, +.supported-formats ul { + margin: 8px 0; + padding-left: 20px; +} + +.device-info li, +.supported-formats li { + margin: 4px 0; +} + +.no-formats { + color: #dc3545; + font-style: italic; +} + +.error-message, +.warning-message { + margin-top: 12px; + padding: 8px; + border-radius: 4px; +} + +.error-message { + background: rgba(220, 53, 69, 0.1); + border-left: 3px solid #dc3545; +} + +.warning-message { + background: rgba(255, 193, 7, 0.1); + border-left: 3px solid #ffc107; +} + +/* 播放错误样式 */ +.play-error { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 6px 8px; + background: rgba(220, 53, 69, 0.1); + border: 1px solid rgba(220, 53, 69, 0.3); + border-radius: 6px; + font-size: 11px; +} + +.error-icon { + color: #dc3545; +} + +.error-text { + flex: 1; + color: #dc3545; +} + +.retry-play-button { + background: #dc3545; + color: white; + border: none; + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + cursor: pointer; + transition: background 0.3s; +} + +.retry-play-button:hover { + background: #c82333; +} + +.play-button.error { + background: rgba(220, 53, 69, 0.1); + color: #dc3545; +} + +.play-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 安卓特定样式优化 */ +@media screen and (max-width: 768px) { + .voice-content { + min-height: 40px; + } + + .play-button { + min-width: 32px; + min-height: 32px; + } + + .waveform { + min-height: 24px; + } +} diff --git a/src/component/audioRecorder/index.tsx b/src/component/audioRecorder/index.tsx new file mode 100644 index 0000000..cd86b08 --- /dev/null +++ b/src/component/audioRecorder/index.tsx @@ -0,0 +1,275 @@ +// components/PetTranslatorChat.tsx (添加音频控制) +import React, { useRef, useEffect, useState } from "react"; +import { usePetTranslator } from "@/hooks/usePetTranslator"; +import { useFileUpload } from "@/hooks/useFileUpload"; +import { useAudioControl } from "@/hooks/useAudioControl"; +import { VoiceMessage, ChatMessage } from "@/types/chat"; +import { UploadConfig } from "@/types/upload"; +import VoiceMessageComponent from "./voiceMessage"; +import VoiceRecordButton from "./voiceRecordButton"; +import RecordingStatusBar from "./recordingStatusBar"; +import AudioManager from "@/utils/audioManager"; +import { useVoiceRecorder } from "@/hooks/useVoiceRecorder"; +import TranslateItem from "./translateItem"; +import ArchivesItem from "./archives"; +import "./index.less"; +import { CapsuleTabs } from "antd-mobile"; + +const PetTranslatorChat: React.FC = () => { + const { + messages, + currentPet, + translateVoice, + addMessage, + updateMessage, + clearMessages, + } = usePetTranslator(); + + const { isRecording, isPaused, recordingTime } = useVoiceRecorder(); + const { uploadFile } = useFileUpload(); + const { currentPlayingId, stopAllAudio, pauseAllAudio } = useAudioControl(); + const messagesEndRef = useRef(null); + const [tabValue, setTabValue] = useState("translate"); + + // 上传配置 + const uploadConfig: UploadConfig = { + url: "/api/upload/voice", + method: "POST", + fieldName: "voiceFile", + maxFileSize: 10 * 1024 * 1024, // 10MB + allowedTypes: [ + "audio/webm", + "audio/mp4", + "audio/aac", + "audio/wav", + "audio/ogg", + "audio/mpeg", + "audio/x-m4a", + ], + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }; + + // 组件卸载时清理所有音频 + useEffect(() => { + return () => { + AudioManager.getInstance().cleanup(); + }; + }, []); + + // 开始录音时暂停所有音频播放 + useEffect(() => { + if (isRecording) { + pauseAllAudio(); + } + }, [isRecording, pauseAllAudio]); + + const handleRecordComplete = ( + audioBlob: Blob, + duration: number, + uploadResponse?: any + ) => { + const audioUrl = URL.createObjectURL(audioBlob); + + const voiceMessage: VoiceMessage = { + id: `voice_${Date.now()}`, + type: "voice", + content: { + duration, + url: audioUrl, + blob: audioBlob, + uploadStatus: uploadResponse ? "success" : undefined, + fileId: uploadResponse?.data?.fileId, + fileName: uploadResponse?.data?.fileName, + serverUrl: uploadResponse?.data?.fileUrl, + }, + sender: "user", + timestamp: Date.now(), + }; + + addMessage(voiceMessage); + + // 自动开始翻译 + setTimeout(() => { + translateVoice(voiceMessage); + }, 500); + }; + + const handleRetryUpload = async (messageId: string) => { + const message = messages.find( + (msg) => msg.id === messageId + ) as VoiceMessage; + if (!message || !message.content.blob) return; + + try { + // 更新上传状态 + updateMessage(messageId, { + ...message, + content: { + ...message.content, + uploadStatus: "uploading", + uploadProgress: 0, + }, + }); + + const fileName = `voice_${Date.now()}.wav`; + const uploadResponse = await uploadFile( + message.content.blob, + fileName, + uploadConfig + ); + + // 更新成功状态 + updateMessage(messageId, { + ...message, + content: { + ...message.content, + uploadStatus: "success", + fileId: uploadResponse.data?.fileId, + fileName: uploadResponse.data?.fileName, + serverUrl: uploadResponse.data?.fileUrl, + }, + }); + } catch (error) { + // 更新失败状态 + updateMessage(messageId, { + ...message, + content: { + ...message.content, + uploadStatus: "error", + }, + }); + + console.error("重新上传失败:", error); + } + }; + + const handleRecordError = (error: Error) => { + alert(error.message); + }; + + const formatTime = (timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + }; + + return ( + <> + {/* 录音状态栏 */} + + + {/* 头部 */} +
+
+

setTabValue("translate")} + className={`${tabValue === "translate" ? "active" : ""}`} + > + 宠物翻译 +

+

setTabValue("archives")} + className={`${tabValue === "archives" ? "active" : ""}`} + > + 宠物档案 +

+
+ + {/*
+
{currentPet.avatar}
*/} + {/*
*/} + {/*
{currentPet.name}
*/} + {/*
+ {isRecording + ? "正在听你说话..." + : currentPlayingId + ? "正在播放语音..." + : "在线 · 等待翻译"} +
*/} + {/*
*/} + {/*
*/} + + {/*
+ {currentPlayingId && ( + + )} + + +
*/} +
+ {tabValue == "translate" ? : } + {/* 消息列表 */} +
+ {messages.map((message) => ( +
+
+ {message.sender === "pet" && ( +
{currentPet.avatar}
+ )} + +
+ {message.type === "voice" ? ( + translateVoice(message)} + onRetryUpload={() => handleRetryUpload(message.id)} + /> + ) : ( +
{message.content}
+ )} + +
+ {formatTime(message.timestamp)} +
+
+ + {message.sender === "user" && ( +
👤
+ )} +
+
+ ))} +
+
+ + {/* 输入区域 */} + + + ); +}; + +export default PetTranslatorChat; diff --git a/src/component/audioRecorder/recordingStatusBar.tsx b/src/component/audioRecorder/recordingStatusBar.tsx new file mode 100644 index 0000000..e7d6df0 --- /dev/null +++ b/src/component/audioRecorder/recordingStatusBar.tsx @@ -0,0 +1,54 @@ +// components/RecordingStatusBar.tsx +import React from "react"; + +interface RecordingStatusBarProps { + isRecording: boolean; + isPaused: boolean; + duration: number; + maxDuration: number; +} + +const RecordingStatusBar: React.FC = ({ + isRecording, + isPaused, + duration, + maxDuration, +}) => { + if (!isRecording) return null; + + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, "0")}:${secs + .toString() + .padStart(2, "0")}`; + }; + + const getStatusColor = (): string => { + if (isPaused) return "#ffc107"; + if (duration >= maxDuration * 0.9) return "#dc3545"; + return "#28a745"; + }; + + return ( +
+
+
+
+ + {isPaused ? "录音暂停中" : "正在录音"} + +
+ +
+ {formatTime(duration)} / {formatTime(maxDuration)} +
+
+
+ ); +}; + +export default RecordingStatusBar; diff --git a/src/component/audioRecorder/translateItem.tsx b/src/component/audioRecorder/translateItem.tsx new file mode 100644 index 0000000..ec6390b --- /dev/null +++ b/src/component/audioRecorder/translateItem.tsx @@ -0,0 +1,11 @@ +function Index() { + return <> + + + + + + ; +} + +export default Index; diff --git a/src/component/audioRecorder/voiceMessage.tsx b/src/component/audioRecorder/voiceMessage.tsx new file mode 100644 index 0000000..3b684d3 --- /dev/null +++ b/src/component/audioRecorder/voiceMessage.tsx @@ -0,0 +1,322 @@ +// 消息列表 +import React, { useState, useRef, useEffect } from "react"; +import { VoiceMessage as VoiceMessageType } from "@/types/chat"; +import { UniversalAudioPlayer } from "@/utils/audioPlayer"; +import AudioManager from "@/utils/audioManager"; + +interface VoiceMessageProps { + message: VoiceMessageType; + isOwn: boolean; + onTranslate?: () => void; + onRetryUpload?: () => void; +} + +const VoiceMessage: React.FC = ({ + message, + isOwn, + onTranslate, + onRetryUpload, +}) => { + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [playError, setPlayError] = useState(null); + + const playerRef = useRef(null); + const timerRef = useRef(null); + const audioManager = AudioManager.getInstance(); + + // 使用消息ID作为音频实例的唯一标识 + const audioId = `voice_${message.id}`; + + useEffect(() => { + return () => { + // 组件卸载时清理 + stopTimer(); + audioManager.unregisterAudio(audioId); + }; + }, [audioId]); + + const startTimer = () => { + timerRef.current = setInterval(() => { + if (audioManager.isPlaying(audioId)) { + const current = audioManager.getCurrentTime(audioId); + setCurrentTime(current); + } else { + // 播放结束 + stopTimer(); + setIsPlaying(false); + setCurrentTime(0); + } + }, 100); + }; + + const stopTimer = () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + + const loadAudio = async (): Promise => { + if (playerRef.current) { + return; // 已经加载过了 + } + + setIsLoading(true); + setPlayError(null); + + try { + const player = new UniversalAudioPlayer(); + + // 优先使用blob,其次使用URL + let audioBlob: Blob | null = null; + + if (message.content.blob) { + audioBlob = message.content.blob; + } else if (message.content.url || message.content.serverUrl) { + // 从URL获取blob + const audioUrl = message.content.serverUrl || message.content.url; + const response = await fetch(audioUrl!); + audioBlob = await response.blob(); + } + + if (!audioBlob) { + throw new Error("无法获取音频数据"); + } + + await player.loadAudio(audioBlob); + playerRef.current = player; + + // 注册到全局音频管理器 + audioManager.registerAudio(audioId, player, { + onPlay: () => { + setIsPlaying(true); + startTimer(); + }, + onPause: () => { + setIsPlaying(false); + stopTimer(); + }, + onStop: () => { + setIsPlaying(false); + setCurrentTime(0); + stopTimer(); + }, + }); + + console.log("音频加载成功:", { + id: audioId, + size: audioBlob.size, + type: audioBlob.type, + duration: player.getDuration(), + }); + } catch (error) { + console.error("音频加载失败:", error); + setPlayError(error instanceof Error ? error.message : "音频加载失败"); + } finally { + setIsLoading(false); + } + }; + + const togglePlay = async () => { + try { + if (!playerRef.current) { + await loadAudio(); + if (!playerRef.current) { + return; + } + } + + if (isPlaying) { + // 暂停当前音频 + audioManager.pauseAudio(audioId); + } else { + // 播放音频(会自动停止其他正在播放的音频) + await audioManager.playAudio(audioId); + setPlayError(null); + } + } catch (error) { + console.error("播放控制失败:", error); + setPlayError(error instanceof Error ? error.message : "播放失败"); + setIsPlaying(false); + stopTimer(); + } + }; + + const formatTime = (seconds: number): string => { + return `${Math.floor(seconds)}''`; + }; + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; + }; + + const getWaveformBars = (): JSX.Element[] => { + const bars = []; + const barCount = Math.min( + Math.max(Math.floor(message.content.duration), 3), + 20 + ); + const duration = + audioManager.getDuration(audioId) || message.content.duration; + + for (let i = 0; i < barCount; i++) { + const height = Math.random() * 20 + 10; + const isActive = + isPlaying && duration > 0 && (currentTime / duration) * barCount > i; + + bars.push( +
+ ); + } + + return bars; + }; + + const getUploadStatusIcon = () => { + switch (message.content.uploadStatus) { + case "uploading": + return "📤"; + case "success": + return "✅"; + case "error": + return "❌"; + default: + return null; + } + }; + + const getPlayButtonContent = () => { + if (isLoading) return "⏳"; + if (playError) return "❌"; + if (isPlaying) return "⏸️"; + return "▶️"; + }; + + const isPlayDisabled = + isLoading || + (!message.content.url && + !message.content.serverUrl && + !message.content.blob); + + return ( +
+
+ + +
{getWaveformBars()}
+ + + {formatTime(isPlaying ? currentTime : message.content.duration)} + +
+ + {/* 播放错误提示 */} + {playError && ( +
+ ⚠️ + {playError} + +
+ )} + + {/* 上传状态显示 */} + {isOwn && message.content.uploadStatus && ( +
+
+ {getUploadStatusIcon()} + + {message.content.uploadStatus === "uploading" && + `上传中 ${message.content.uploadProgress || 0}%`} + {message.content.uploadStatus === "success" && "已上传"} + {message.content.uploadStatus === "error" && "上传失败"} + + + {message.content.uploadStatus === "error" && onRetryUpload && ( + + )} +
+ + {/* 文件信息 */} + {message.content.uploadStatus === "success" && ( +
+ {message.content.fileName && ( + 📁 {message.content.fileName} + )} + {message.content.blob && ( + + 📊 {formatFileSize(message.content.blob.size)} + + )} +
+ )} +
+ )} + + {/* 翻译按钮和结果 */} + {isOwn && ( +
+ {!message.translation && !message.translating && ( + + )} + + {message.translating && ( +
+
+ + + +
+ 正在翻译中... +
+ )} + + {message.translation && ( +
+
🗣️
+
{message.translation}
+
+ )} +
+ )} +
+ ); +}; + +export default VoiceMessage; diff --git a/src/component/audioRecorder/voiceRecordButton.less b/src/component/audioRecorder/voiceRecordButton.less new file mode 100644 index 0000000..46917f2 --- /dev/null +++ b/src/component/audioRecorder/voiceRecordButton.less @@ -0,0 +1,350 @@ +/* VoiceRecordButton.css */ +.voice-input-container { + padding: 16px; + background: #fff; + border-top: 1px solid #e5e5e5; +} + +.voice-start-button { + width: 100%; + height: 50px; + background: #007bff; + color: white; + border: none; + border-radius: 25px; + font-size: 16px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + user-select: none; + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); +} + +.voice-start-button:hover { + background: #0056b3; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4); +} + +.voice-start-button:active { + transform: translateY(0); +} + +.microphone-icon { + font-size: 20px; +} + +.button-text { + font-weight: 600; +} + +/* 录音中的容器 */ +.voice-recording-container { + padding: 20px; + background: #fff; + border-top: 1px solid #e5e5e5; + border-radius: 16px 16px 0 0; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1); +} + +/* 录音状态 */ +.recording-status { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.recording-indicator { + display: flex; + align-items: center; + gap: 8px; +} + +.recording-dot { + width: 12px; + height: 12px; + background: #dc3545; + border-radius: 50%; + animation: pulse-recording 1s infinite; +} + +.recording-dot.paused { + background: #ffc107; + animation: none; +} + +@keyframes pulse-recording { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.2); + opacity: 0.7; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.recording-text { + font-size: 14px; + font-weight: 600; + color: #333; +} + +.recording-time { + font-size: 18px; + font-weight: 700; + color: #007bff; +} + +.max-time { + font-size: 12px; + color: #999; + font-weight: 400; +} + +/* 进度条 */ +.recording-progress { + width: 100%; + height: 4px; + background: #e9ecef; + border-radius: 2px; + margin-bottom: 20px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, #007bff, #0056b3); + border-radius: 2px; + transition: width 0.3s ease; +} + +/* 波形动画 */ +.sound-wave-container { + display: flex; + justify-content: center; + margin-bottom: 24px; +} + +.sound-wave { + display: flex; + align-items: center; + gap: 4px; + height: 40px; +} + +.wave-bar { + width: 4px; + background: #007bff; + border-radius: 2px; + animation: wave-animation 1.2s infinite ease-in-out; +} + +.sound-wave.paused .wave-bar { + animation-play-state: paused; +} + +.wave-bar:nth-child(1) { + animation-delay: -0.4s; +} +.wave-bar:nth-child(2) { + animation-delay: -0.3s; +} +.wave-bar:nth-child(3) { + animation-delay: -0.2s; +} +.wave-bar:nth-child(4) { + animation-delay: -0.1s; +} +.wave-bar:nth-child(5) { + animation-delay: 0s; +} + +@keyframes wave-animation { + 0%, + 40%, + 100% { + height: 8px; + opacity: 0.5; + } + 20% { + height: 32px; + opacity: 1; + } +} + +/* 控制按钮 */ +.recording-controls { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.control-button { + flex: 1; + height: 48px; + border: none; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + font-size: 12px; + font-weight: 600; + min-width: 0; +} + +.control-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.control-button:not(:disabled):hover { + transform: translateY(-2px); +} + +.control-button:not(:disabled):active { + transform: translateY(0); +} + +.cancel-button { + background: #f8f9fa; + color: #dc3545; + border: 2px solid #dc3545; +} + +.cancel-button:not(:disabled):hover { + background: #dc3545; + color: white; +} + +.pause-button { + background: #f8f9fa; + color: #ffc107; + border: 2px solid #ffc107; +} + +.pause-button:not(:disabled):hover { + background: #ffc107; + color: white; +} + +.send-button { + background: #28a745; + color: white; + border: 2px solid #28a745; +} + +.send-button:not(:disabled):hover { + background: #218838; + border-color: #218838; +} + +.button-icon { + font-size: 16px; +} + +.button-label { + font-size: 11px; + line-height: 1; +} + +/* 提示文字 */ +.recording-hint { + text-align: center; + font-size: 13px; + color: #666; +} + +.warning-hint { + color: #dc3545; + font-weight: 600; +} + +.normal-hint { + color: #28a745; +} + +/* 响应式设计 */ +@media (max-width: 480px) { + .voice-recording-container { + padding: 16px; + } + + .recording-controls { + gap: 8px; + } + + .control-button { + height: 44px; + font-size: 11px; + } + + .button-icon { + font-size: 14px; + } + + .button-label { + font-size: 10px; + } + + .recording-time { + font-size: 16px; + } +} + +/* 动画效果 */ +.voice-recording-container { + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + .voice-recording-container { + background: #2c2c2e; + border-top-color: #3a3a3c; + } + + .recording-text { + color: #fff; + } + + .recording-progress { + background: #3a3a3c; + } + + .wave-bar { + background: #0a84ff; + } + + .control-button { + background: #3a3a3c; + } + + .recording-hint { + color: #8e8e93; + } +} diff --git a/src/component/audioRecorder/voiceRecordButton.tsx b/src/component/audioRecorder/voiceRecordButton.tsx new file mode 100644 index 0000000..451c1d6 --- /dev/null +++ b/src/component/audioRecorder/voiceRecordButton.tsx @@ -0,0 +1,248 @@ +// components/VoiceRecordButton.tsx (更新) +import React, { useState, useEffect } from "react"; +import { useVoiceRecorder } from "@/hooks/useVoiceRecorder"; +import { useFileUpload } from "@/hooks/useFileUpload"; +import { UploadConfig } from "@/types/upload"; +import "./index.less"; +interface VoiceRecordButtonProps { + onRecordComplete: ( + audioBlob: Blob, + duration: number, + uploadResponse?: any + ) => void; + onError?: (error: Error) => void; + maxDuration?: number; + uploadConfig?: UploadConfig; + autoUpload?: boolean; +} + +const VoiceRecordButton: React.FC = ({ + onRecordComplete, + onError, + maxDuration = 60, + uploadConfig, + autoUpload = true, +}) => { + const { + isRecording, + recordingTime, + isPaused, + startRecording, + stopRecording, + pauseRecording, + resumeRecording, + cancelRecording, + } = useVoiceRecorder(); + + const { uploadStatus, uploadFile, resetUpload } = useFileUpload(); + const [showControls, setShowControls] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + + // 自动停止录音当达到最大时长 + useEffect(() => { + if (recordingTime >= maxDuration && isRecording) { + handleSendRecording(); + } + }, [recordingTime, maxDuration, isRecording]); + + const handleStartRecording = async () => { + try { + resetUpload(); + await startRecording(); + setShowControls(true); + } catch (error) { + onError?.(error as Error); + } + }; + + const handleSendRecording = async () => { + if (recordingTime < 1) { + onError?.(new Error("录音时间太短,至少需要1秒")); + cancelRecording(); + setShowControls(false); + return; + } + + setIsProcessing(true); + + try { + const audioBlob = await stopRecording(); + if (!audioBlob) { + throw new Error("录音数据获取失败"); + } + + let uploadResponse; + + // 如果配置了上传且启用自动上传 + if (uploadConfig && autoUpload) { + const fileName = `voice_${Date.now()}.wav`; + uploadResponse = await uploadFile(audioBlob, fileName, uploadConfig); + } + + onRecordComplete(audioBlob, recordingTime, uploadResponse); + setShowControls(false); + } catch (error) { + onError?.(error as Error); + } finally { + setIsProcessing(false); + } + }; + + const handleCancelRecording = () => { + cancelRecording(); + resetUpload(); + setShowControls(false); + }; + + const handlePauseResume = () => { + if (isPaused) { + resumeRecording(); + } else { + pauseRecording(); + } + }; + + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, "0")}:${secs + .toString() + .padStart(2, "0")}`; + }; + + const getProgressPercentage = (): number => { + return Math.min((recordingTime / maxDuration) * 100, 100); + }; + + const isUploading = uploadStatus.status === "uploading"; + const uploadProgress = uploadStatus.progress?.percentage || 0; + + if (!showControls) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 录音状态指示器 */} +
+
+
+ + {isProcessing + ? "处理中..." + : isPaused + ? "录音已暂停" + : "正在录音..."} + +
+ +
+ {formatTime(recordingTime)} + /{formatTime(maxDuration)} +
+
+ + {/* 进度条 */} +
+
+
+ + {/* 上传进度 */} + {isUploading && ( +
+
+ 📤 + 上传中... {uploadProgress}% +
+
+
+
+
+ )} + + {/* 波形动画 */} +
+
+ {[...Array(5)].map((_, index) => ( +
+ ))} +
+
+ + {/* 控制按钮 */} +
+ + + + + +
+ + {/* 提示文字 */} + {/*
+ {isProcessing ? ( + 正在处理录音... + ) : isUploading ? ( + 正在上传到服务器... + ) : recordingTime < 1 ? ( + 录音时间至少需要1秒 + ) : ( + 点击发送按钮完成录音 + )} +
*/} +
+ ); +}; + +export default VoiceRecordButton; diff --git a/src/component/carousel/index.less b/src/component/carousel/index.less new file mode 100644 index 0000000..f1685d9 --- /dev/null +++ b/src/component/carousel/index.less @@ -0,0 +1,7 @@ +.carousel { + .carousel-item { + .carousel-image { + border-radius: 15px; + } + } +} \ No newline at end of file diff --git a/src/component/carousel/index.tsx b/src/component/carousel/index.tsx new file mode 100644 index 0000000..932a50e --- /dev/null +++ b/src/component/carousel/index.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import Slider, {Settings} from 'react-slick'; +import 'slick-carousel/slick/slick.css'; +import 'slick-carousel/slick/slick-theme.css'; +import './index.less'; + +export interface CarouselComponentProps { + images: string[]; + height?: number; +} + +/** + * 轮播图组件 + * @param images 图片地址数组 + * @param height 图片高度 + * @constructor Carousel + */ +const Carousel: React.FC = ({images, height = 180}) => { + const settings: Settings = { + dots: false, + infinite: true, + speed: 3000, + slidesToShow: 1, + slidesToScroll: 1, + autoplay: true, + autoplaySpeed: 2000, + responsive: [ + { + breakpoint: 768, + settings: { + arrows: false, + } + } + ] + }; + + return ( + + {images.map((image, index) => ( +
+ {`Slide +
+ ))} +
+ ); +}; + +export default Carousel; \ No newline at end of file diff --git a/src/component/floatingMenu/index.less b/src/component/floatingMenu/index.less new file mode 100644 index 0000000..be92e5f --- /dev/null +++ b/src/component/floatingMenu/index.less @@ -0,0 +1,42 @@ +/* FloatingFanMenu.css */ + +@keyframes menuItemPop { + 0% { + opacity: 0; + transform: scale(0) rotate(-180deg); + } + 70% { + opacity: 1; + transform: scale(1.1) rotate(-10deg); + } + 100% { + opacity: 1; + transform: scale(1) rotate(0deg); + } +} + +/* 悬停效果 */ +.menu-item:hover { + transform: scale(1.1) !important; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3) !important; + transition: all 0.2s ease !important; +} +.adm-floating-bubble-button { + z-index: 999; + width: 72px; + height: 72px; + overflow: visible; + .cat { + position: absolute; + width: 70px; + font-size: 12px; + + bottom: -10px; + background: rgba(255, 204, 199, 1); + padding: 4px 0px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/component/floatingMenu/index.tsx b/src/component/floatingMenu/index.tsx new file mode 100644 index 0000000..9fdb2b9 --- /dev/null +++ b/src/component/floatingMenu/index.tsx @@ -0,0 +1,196 @@ +import React, { useState, useRef } from "react"; +import { FloatingBubble, Image } from "antd-mobile"; +import { + AddOutline, + MessageOutline, + UserOutline, + SetOutline, + HeartOutline, + CheckOutline, +} from "antd-mobile-icons"; +import { createPortal } from "react-dom"; +import dogSvg from "@/assets/translate/dog.svg"; +import catSvg from "@/assets/translate/cat.svg"; +import pigSvg from "@/assets/translate/pig.svg"; +import "./index.less"; +import { MoreTwo } from "@icon-park/react"; + +export interface FloatMenuItemConfig { + icon: React.ReactNode; + type?: string; +} + +const FloatingFanMenu: React.FC<{ + menuItems: FloatMenuItemConfig[]; + value?: FloatMenuItemConfig; + onChange?: (item: FloatMenuItemConfig) => void; +}> = (props) => { + const { menuItems = [] } = props; + const [visible, setVisible] = useState(false); + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + const bubbleRef = useRef(null); + + // 点击时获取FloatingBubble的位置 + const handleMainClick = () => { + if (!visible) { + // 显示菜单时,获取当前FloatingBubble的位置 + if (bubbleRef.current) { + const bubble = bubbleRef.current.querySelector( + ".adm-floating-bubble-button" + ); + if (bubble) { + const rect = bubble.getBoundingClientRect(); + setMenuPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }); + } + } + } + setVisible(!visible); + }; + + const handleItemClick = (item: FloatMenuItemConfig) => { + props.onChange?.(item); + setVisible(false); + }; + + // 计算菜单项位置 + const getMenuItemPosition = (index: number) => { + const positions = [ + { x: 0, y: -80 }, // 上方 + { x: -60, y: -60 }, // 左上 + { x: -80, y: 0 }, // 左方 + { x: -60, y: 60 }, // 左下 + ]; + + const pos = positions[index] || { x: 0, y: 0 }; + let x = menuPosition.x + pos.x; + let y = menuPosition.y + pos.y; + + // // 边界检测 + // const itemSize = 48; + // const margin = 20; + // 边界检测 + const itemSize = 48; + // const margin = 0; + + // x = Math.max( + // // margin + itemSize / 2, + // Math.min(window.innerWidth - margin - itemSize / 2, x) + // ); + // y = Math.max( + // // margin + itemSize / 2, + // Math.min(window.innerHeight - margin - itemSize / 2, y) + // ); + + return { + left: x - itemSize / 2, + top: y - itemSize / 2, + }; + }; + + // 菜单组件 + const MenuComponent = () => ( + <> + {/* 背景遮罩 */} +
setVisible(false)} + /> + + {/* 菜单项 */} + {menuItems.map((item, index) => { + const position = getMenuItemPosition(index); + return ( +
handleItemClick(item)} + // title={item.label} + > + {item.icon} +
+ ); + })} + + ); + + return ( + <> + {/* 主按钮 */} +
+ +
+ {props.value?.icon} +
+ {/* {!visible && } */} + 切换语言 +
+
+
+
+ + {/* 菜单 - 只在有位置信息时渲染 */} + {visible && + menuPosition.x > 0 && + menuPosition.y > 0 && + createPortal(, document.body)} + + ); +}; + +export default FloatingFanMenu; diff --git a/src/component/petVoiceTranslator copy/index.less b/src/component/petVoiceTranslator copy/index.less new file mode 100644 index 0000000..aa64c44 --- /dev/null +++ b/src/component/petVoiceTranslator copy/index.less @@ -0,0 +1,662 @@ +// PetVoiceTranslator.less +.pet-voice-translator { + display: flex; + flex-direction: column; + height: 100vh; + background: #ededed; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, + sans-serif; + position: relative; + overflow: hidden; + + // 处理iOS安全区域 + padding-top: var(--safe-area-inset-top, 0); + padding-bottom: var(--safe-area-inset-bottom, 0); + + // Safari 特殊处理 + -webkit-overflow-scrolling: touch; + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + + .chat-header { + display: flex; + align-items: center; + padding: 12px 16px; + background: #f7f7f7; + border-bottom: 1px solid #e5e5e5; + flex-shrink: 0; + position: relative; + z-index: 10; + + // 确保头部不被NavBar遮挡 + min-height: 64px; + + .pet-avatar { + width: 40px; + height: 40px; + border-radius: 20px; + background: linear-gradient(45deg, #ff9a9e, #fecfef); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + margin-right: 12px; + } + + .chat-title { + flex: 1; + + .pet-name { + font-size: 16px; + font-weight: 500; + color: #333; + margin-bottom: 2px; + } + + .chat-subtitle { + font-size: 12px; + color: #999; + } + } + } + + .messages-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 12px; + position: relative; + + // Safari 滚动优化 + -webkit-overflow-scrolling: touch; + -webkit-transform: translateZ(0); + transform: translateZ(0); + + // 确保可以滚动 + overscroll-behavior: contain; + scroll-behavior: smooth; + + // 滚动条样式 + &::-webkit-scrollbar { + width: 0; + background: transparent; + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #999; + min-height: 200px; + + .empty-icon { + font-size: 64px; + margin-bottom: 16px; + opacity: 0.6; + } + + .empty-text { + font-size: 16px; + margin-bottom: 8px; + } + + .empty-subtitle { + font-size: 14px; + opacity: 0.8; + } + } + + .message-wrapper { + margin-bottom: 16px; + animation: messageSlideIn 0.3s ease-out; + + -webkit-user-select: none; + user-select: none; + + .message-item { + display: flex; + align-items: flex-start; + + .avatar { + width: 40px; + height: 40px; + border-radius: 6px; + background: linear-gradient(45deg, #ff9a9e, #fecfef); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + margin-right: 8px; + flex-shrink: 0; + } + + .message-content { + flex: 1; + max-width: calc(100% - 80px); + + .voice-bubble { + background: #95ec69; + border-radius: 8px; + padding: 8px 12px; + margin-bottom: 6px; + position: relative; + display: inline-block; + min-width: 120px; + cursor: pointer; + user-select: none; + + // 防止长按选择 + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + + // 优化点击响应 + touch-action: manipulation; + + &::before { + content: ""; + position: absolute; + left: -8px; + top: 12px; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 8px solid #95ec69; + } + + &:active { + background: #8de354; + transform: scale(0.98); + transition: all 0.1s ease; + } + + .voice-content { + display: flex; + align-items: center; + + .voice-icon { + margin-right: 8px; + font-size: 16px; + color: #333; + + .playing-animation { + display: flex; + align-items: center; + gap: 2px; + + span { + width: 3px; + height: 12px; + background: #333; + border-radius: 1px; + animation: voiceWave 1s infinite ease-in-out; + + &:nth-child(2) { + animation-delay: 0.1s; + } + + &:nth-child(3) { + animation-delay: 0.2s; + } + } + } + } + + .voice-duration { + font-size: 14px; + color: #333; + font-weight: 500; + } + } + } + + .translation-text { + font-size: 13px; + color: #666; + margin-left: 4px; + margin-bottom: 4px; + line-height: 1.4; + word-wrap: break-word; + word-break: break-word; + + &.translating { + .translating-content { + display: flex; + align-items: center; + + .loading-dots { + margin-right: 8px; + + &::after { + content: "..."; + animation: loadingDots 1.5s infinite; + } + } + + .typing-indicator { + display: flex; + gap: 3px; + + span { + width: 4px; + height: 4px; + background: #999; + border-radius: 50%; + animation: typingBounce 1.4s infinite ease-in-out; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } + } + } + + .translation-label { + font-size: 11px; + color: #999; + margin-bottom: 2px; + } + + .translation-content { + color: #333; + word-wrap: break-word; + word-break: break-word; + } + } + + .message-time { + font-size: 11px; + color: #999; + margin-left: 4px; + } + } + } + } + } + + .recording-controls { + background: #f7f7f7; + border-top: 1px solid #e5e5e5; + padding: 12px 16px 20px; + flex-shrink: 0; + position: relative; + z-index: 10; + + // 确保控制区域不被底部安全区域影响 + min-height: 100px; + + // 防止在Safari中被下拉刷新影响 + -webkit-user-select: none; + user-select: none; + + .recorder-wrapper { + display: none; + } + + .control-area { + .recording-info { + text-align: center; + margin-bottom: 12px; + + .recording-indicator { + display: flex; + align-items: center; + justify-content: center; + color: #ff4d4f; + font-size: 14px; + margin-bottom: 4px; + + .recording-dot { + width: 8px; + height: 8px; + background: #ff4d4f; + border-radius: 50%; + margin-right: 8px; + animation: recordingPulse 1s infinite; + } + } + + .recording-tip { + font-size: 12px; + color: #999; + } + } + + .input-area { + display: flex; + flex-direction: column; + align-items: center; + + .button-group { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 8px; + + .cancel-button { + width: 40px; + height: 40px; + border-radius: 20px; + border: none; + background: #ff4d4f; + color: white; + cursor: pointer; + transition: all 0.1s ease; + outline: none; + -webkit-tap-highlight-color: transparent; + -webkit-appearance: none; + -webkit-user-select: none; + display: flex; + align-items: center; + justify-content: center; + animation: slideInLeft 0.3s ease-out; + + // 优化点击响应 + touch-action: manipulation; + + &:active { + transform: scale(0.9); + background: #ff7875; + } + + .cancel-icon { + font-size: 18px; + } + } + + .record-button { + width: 60px; + height: 60px; + border-radius: 30px; + border: none; + background: #1890ff; + color: white; + cursor: pointer; + transition: all 0.1s ease; + outline: none; + -webkit-tap-highlight-color: transparent; + -webkit-appearance: none; + -webkit-user-select: none; + display: flex; + align-items: center; + justify-content: center; + + // 优化点击响应 + touch-action: manipulation; + + &:active { + transform: scale(0.9); + } + + &:disabled { + background: #d9d9d9; + color: #999; + cursor: not-allowed; + } + + &.processing { + background: #faad14; + + cursor: wait; + + .processing-animation { + display: flex; + + align-items: center; + + gap: 3 px; + + span { + width: 3 px; + + height: 16 px; + + background: white; + + border-radius: 1 px; + + animation: processingWave 1.5 s infinite ease - in - out; + + &:nth-child(2) { + animation-delay: 0.2 s; + } + + &:nth-child(3) { + animation-delay: 0.4 s; + } + } + } + } + + &.recording { + background: #ff4d4f; + animation: recordingButtonPulse 1s infinite; + + .recording-animation { + display: flex; + align-items: center; + gap: 3px; + + span { + width: 3px; + height: 16px; + background: white; + border-radius: 1px; + animation: recordingWave 1s infinite ease-in-out; + + &:nth-child(2) { + animation-delay: 0.1s; + } + + &:nth-child(3) { + animation-delay: 0.2s; + } + } + } + } + + .mic-icon { + font-size: 24px; + } + } + } + + .record-hint { + font-size: 12px; + color: #999; + } + } + } + } +} + +// 全局样式,确保页面不会滚动 +html, +body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + -webkit-overflow-scrolling: touch; + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + + // 防止iOS Safari的橡皮筋效果 + position: fixed; + width: 100%; +} + +// 动画定义 +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes voiceWave { + 0%, + 40%, + 100% { + transform: scaleY(0.4); + } + 20% { + transform: scaleY(1); + } +} + +@keyframes loadingDots { + 0%, + 20% { + content: "."; + } + 40% { + content: ".."; + } + 60%, + 100% { + content: "..."; + } +} + +@keyframes typingBounce { + 0%, + 60%, + 100% { + transform: translateY(0); + } + 30% { + transform: translateY(-6px); + } +} + +@keyframes recordingPulse { + 0% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.2); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes recordingButtonPulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(255, 77, 79, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); + } +} + +@keyframes recordingWave { + 0%, + 40%, + 100% { + transform: scaleY(0.4); + } + 20% { + transform: scaleY(1); + } +} + +// 移动端适配 +// @media (max-width: 768px) { +// .pet-voice-translator { +// .messages-container { +// padding: 12px 8px; + +// .message-wrapper .message-item .message-content { +// max-width: calc(100% - 60px); +// } +// } + +// .recording-controls { +// padding: 10px 12px 16px; +// } +// } +// } + +// iOS Safari 特殊处理 +@supports (-webkit-touch-callout: none) { + .pet-voice-translator { + // 确保在iOS Safari中正确显示 + // height: 100vh; + height: -webkit-fill-available; + + .messages-container { + -webkit-overflow-scrolling: touch; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + } +} + +// 深色模式适配 +@media (prefers-color-scheme: dark) { + .pet-voice-translator { + background: #1e1e1e; + + .chat-header { + background: #2a2a2a; + border-bottom-color: #3a3a3a; + + .chat-title .pet-name { + color: #fff; + } + } + + .messages-container { + .message-wrapper .message-item .message-content .translation-text { + color: #ccc; + + .translation-content { + color: #fff; + } + } + } + + .recording-controls { + background: #2a2a2a; + border-top-color: #3a3a3a; + } + } +} diff --git a/src/component/petVoiceTranslator copy/index.tsx b/src/component/petVoiceTranslator copy/index.tsx new file mode 100644 index 0000000..0b99e4a --- /dev/null +++ b/src/component/petVoiceTranslator copy/index.tsx @@ -0,0 +1,916 @@ +// PetVoiceTranslator.tsx +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { AudioRecorder, useAudioRecorder } from "react-audio-voice-recorder"; +import { Button, Toast, Dialog } from "antd-mobile"; +import { SoundOutline, CloseOutline } from "antd-mobile-icons"; +import "./index.less"; + +interface Message { + id: string; + type: "voice"; + audioUrl: string; + duration: number; + timestamp: number; + translatedText?: string; + isTranslating?: boolean; + isPlaying?: boolean; +} + +const PetVoiceTranslator: React.FC = () => { + const [messages, setMessages] = useState([]); + const [isRecording, setIsRecording] = useState(false); + const [hasPermission, setHasPermission] = useState(null); + const [currentPlayingId, setCurrentPlayingId] = useState(null); + const [recordingDuration, setRecordingDuration] = useState(0); + const [isInitialized, setIsInitialized] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [permissionChecking, setPermissionChecking] = useState(false); + const [dialogShowing, setDialogShowing] = useState(false); + + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({}); + const recordingTimerRef = useRef(); + const isRecordingRef = useRef(false); + const processingTimeoutRef = useRef(); + const permissionCheckTimeoutRef = useRef(); + + // 新增:防止重复初始化的标志 + const initializingRef = useRef(false); + const initializedRef = useRef(false); + + const recorderControls = useAudioRecorder( + { + noiseSuppression: true, + echoCancellation: true, + autoGainControl: true, + }, + (err) => { + console.error("录音错误:", err); + Toast.show("录音失败,请重试"); + resetRecordingState(); + } + ); + + // 使用useEffect的依赖数组为空,确保只执行一次 + useEffect(() => { + console.log("useEffect执行,初始化状态:", { + initializing: initializingRef.current, + initialized: initializedRef.current, + }); + + // 防止重复初始化 + if (initializingRef.current || initializedRef.current) { + console.log("已经初始化或正在初始化,跳过"); + return; + } + + initializeApp(); + + // 清理函数 + return () => { + console.log("组件卸载,执行清理"); + cleanup(); + }; + }, []); // 空依赖数组确保只执行一次 + + useEffect(() => { + isRecordingRef.current = isRecording; + }, [isRecording]); + + // 优化的初始化函数 + const initializeApp = useCallback(async () => { + // 防止重复执行 + if (initializingRef.current || initializedRef.current) { + console.log("初始化已执行或正在执行,跳过重复调用"); + return; + } + + initializingRef.current = true; + console.log("=== 开始初始化应用 ==="); + + try { + console.log("1. 检查麦克风权限..."); + await checkMicrophonePermission(); + + // console.log("2. 设置Safari兼容性..."); + // setupSafariCompatibility(); + + console.log("3. 标记初始化完成..."); + setIsInitialized(true); + initializedRef.current = true; + + console.log("=== 应用初始化完成 ==="); + } catch (error) { + console.error("应用初始化失败:", error); + Toast.show("应用初始化失败"); + // 即使失败也标记为已初始化,避免无限重试 + setIsInitialized(true); + initializedRef.current = true; + } finally { + initializingRef.current = false; + } + }, []); // 空依赖数组,函数只创建一次 + + const cleanup = useCallback(() => { + console.log("执行清理操作..."); + + // 重置初始化标志 + initializingRef.current = false; + initializedRef.current = false; + + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + } + if (processingTimeoutRef.current) { + clearTimeout(processingTimeoutRef.current); + } + if (permissionCheckTimeoutRef.current) { + clearTimeout(permissionCheckTimeoutRef.current); + } + + Object.values(audioRefs.current).forEach((audio) => { + audio.pause(); + audio.src = ""; + }); + + cleanupSafariCompatibility(); + }, []); + + const resetRecordingState = useCallback(() => { + console.log("重置录音状态"); + setIsRecording(false); + setIsProcessing(false); + setRecordingDuration(0); + // isRecordingRef.current = false; + + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = undefined; + } + + if (processingTimeoutRef.current) { + clearTimeout(processingTimeoutRef.current); + processingTimeoutRef.current = undefined; + } + }, []); + + // 优化的权限检查函数 + const checkMicrophonePermission = useCallback( + async (showToast = false) => { + console.log("检查麦克风权限...", { + permissionChecking, + dialogShowing, + hasPermission, + }); + + // 防止重复检查 + if (permissionChecking) { + console.log("权限检查正在进行中,跳过"); + return hasPermission; + } + + setPermissionChecking(true); + + try { + // 检查浏览器支持 + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + console.error("浏览器不支持录音功能"); + setHasPermission(false); + if (showToast) { + Toast.show("浏览器不支持录音功能"); + } + return false; + } + + // 先检查权限状态(如果浏览器支持) + if (navigator.permissions && navigator.permissions.query) { + try { + const permissionStatus = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + + // alert(permissionStatus.state); + // console.log("权限状态:", permissionStatus.state); + + if (permissionStatus.state === "denied") { + console.log("权限被拒绝"); + setHasPermission(false); + + if (showToast && !dialogShowing) { + showPermissionDialog(); + } + return false; + } + + if (permissionStatus.state === "granted") { + console.log("权限已授予"); + setHasPermission(true); + return true; + } + } catch (permError) { + console.log("权限查询不支持,继续使用getUserMedia检查"); + } + } + + // 尝试获取媒体流 + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + console.log("麦克风权限获取成功"); + stream.getTracks().forEach((track) => track.stop()); + setHasPermission(true); + + if (showToast) { + Toast.show("麦克风权限已获取"); + } + return true; + } catch (error) { + console.error("权限检查失败:", error); + setHasPermission(false); + showPermissionDialog(); + return false; + } finally { + setPermissionChecking(false); + } + }, + [permissionChecking, dialogShowing, hasPermission] + ); + + const showPermissionDialog = useCallback(() => { + if (dialogShowing) { + console.log("弹窗已显示,跳过"); + return; + } + + console.log("显示权限弹窗"); + setDialogShowing(true); + + Dialog.confirm({ + title: "需要录音权限", + content: + '为了使用录音翻译功能,需要您授权麦克风权限。请在浏览器设置中允许访问麦克风,然后点击"重新获取权限"。', + confirmText: "重新获取权限", + cancelText: "取消", + onConfirm: async () => { + console.log("用户点击重新获取权限"); + setDialogShowing(false); + + setTimeout(async () => { + await checkMicrophonePermission(true); + }, 500); + }, + onCancel: () => { + console.log("用户取消权限获取"); + setDialogShowing(false); + Toast.show("需要麦克风权限才能使用录音功能"); + }, + onClose: () => { + console.log("弹窗关闭"); + setDialogShowing(false); + }, + }); + }, [dialogShowing, checkMicrophonePermission]); + + const setupSafariCompatibility = useCallback(() => { + console.log("设置Safari兼容性..."); + + const root = document.documentElement; + const isIPhoneX = + /iPhone/.test(navigator.userAgent) && window.screen.height >= 812; + + if (isIPhoneX) { + root.style.setProperty( + "--safe-area-inset-top", + "env(safe-area-inset-top, 44px)" + ); + root.style.setProperty( + "--safe-area-inset-bottom", + "env(safe-area-inset-bottom, 34px)" + ); + } else { + root.style.setProperty( + "--safe-area-inset-top", + "env(safe-area-inset-top, 20px)" + ); + root.style.setProperty( + "--safe-area-inset-bottom", + "env(safe-area-inset-bottom, 0px)" + ); + } + + document.body.style.overflow = "hidden"; + document.body.style.position = "fixed"; + document.body.style.width = "100%"; + document.body.style.height = "100%"; + document.body.style.top = "0"; + document.body.style.left = "0"; + + document.addEventListener("touchstart", preventPullToRefresh, { + passive: false, + }); + document.addEventListener("touchmove", preventPullToRefresh, { + passive: false, + }); + document.addEventListener("touchend", preventDoubleTapZoom, { + passive: false, + }); + + let viewport = document.querySelector("meta[name=viewport]"); + if (!viewport) { + viewport = document.createElement("meta"); + viewport.setAttribute("name", "viewport"); + document.head.appendChild(viewport); + } + viewport.setAttribute( + "content", + "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" + ); + }, []); + + const cleanupSafariCompatibility = useCallback(() => { + console.log("清理Safari兼容性设置..."); + + document.body.style.overflow = ""; + document.body.style.position = ""; + document.body.style.width = ""; + document.body.style.height = ""; + document.body.style.top = ""; + document.body.style.left = ""; + + document.removeEventListener("touchstart", preventPullToRefresh); + document.removeEventListener("touchmove", preventPullToRefresh); + document.removeEventListener("touchend", preventDoubleTapZoom); + }, []); + + const preventPullToRefresh = (e: TouchEvent) => { + const target = e.target as HTMLElement; + const messagesContainer = messagesContainerRef.current; + + if (messagesContainer && messagesContainer.contains(target)) { + return; + } + + if (e.touches.length > 1) { + e.preventDefault(); + return; + } + + const touch = e.touches[0]; + const startY = touch.clientY; + + if (startY < 100 && e.type === "touchstart") { + e.preventDefault(); + } + }; + + let lastTouchEnd = 0; + const preventDoubleTapZoom = (e: TouchEvent) => { + const now = Date.now(); + if (now - lastTouchEnd <= 300) { + e.preventDefault(); + } + lastTouchEnd = now; + }; + + const stopAllAudio = () => { + if (currentPlayingId && audioRefs.current[currentPlayingId]) { + audioRefs.current[currentPlayingId].pause(); + audioRefs.current[currentPlayingId].currentTime = 0; + + setMessages((prev) => + prev.map((msg) => + msg.id === currentPlayingId ? { ...msg, isPlaying: false } : msg + ) + ); + setCurrentPlayingId(null); + } + + Object.values(audioRefs.current).forEach((audio) => { + if (!audio.paused) { + audio.pause(); + audio.currentTime = 0; + } + }); + }; + + const startRecording = useCallback(async () => { + console.log("=== 开始录音函数调用 ==="); + + if (isProcessing || isRecordingRef.current) { + console.log("正在处理中或已在录音,忽略此次调用"); + return; + } + + if (!isInitialized || !initializedRef.current) { + console.log("应用未初始化完成"); + Toast.show("应用正在初始化,请稍后重试"); + return; + } + + if (!hasPermission) { + console.log("没有录音权限,尝试获取权限"); + const granted = await checkMicrophonePermission(true); + if (!granted) { + console.log("权限获取失败"); + return; + } + } + + try { + setIsProcessing(true); + setIsRecording(true); + setRecordingDuration(0); + + stopAllAudio(); + + await recorderControls.startRecording(); + + recordingTimerRef.current = setInterval(() => { + setRecordingDuration((prev) => prev + 1); + }, 1000); + + Toast.show("开始录音..."); + + processingTimeoutRef.current = setTimeout(() => { + setIsProcessing(false); + }, 1000); + } catch (error) { + console.error("开始录音失败:", error); + resetRecordingState(); + + if ( + error.name === "NotAllowedError" || + error.name === "PermissionDeniedError" + ) { + setHasPermission(false); + if (!dialogShowing) { + showPermissionDialog(); + } + } else { + Toast.show(`录音失败: ${error.message || "未知错误"}`); + } + } + }, [ + isProcessing, + hasPermission, + isInitialized, + recorderControls, + checkMicrophonePermission, + dialogShowing, + showPermissionDialog, + ]); + + const stopRecording = useCallback(() => { + console.log("=== 停止录音函数调用 ==="); + + if (isProcessing || !isRecordingRef.current) { + console.log("正在处理中或未在录音,忽略此次调用"); + return; + } + + try { + setIsProcessing(true); + setIsRecording(false); + + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = undefined; + } + + recorderControls.stopRecording(); + + processingTimeoutRef.current = setTimeout(() => { + setIsProcessing(false); + }, 2000); + } catch (error) { + console.error("停止录音失败:", error); + resetRecordingState(); + Toast.show("停止录音失败"); + } + }, [isProcessing, recorderControls]); + + const onRecordingComplete = useCallback( + (blob: Blob) => { + console.log("=== 录音完成回调 ==="); + + setIsProcessing(false); + if (processingTimeoutRef.current) { + clearTimeout(processingTimeoutRef.current); + } + + if (recordingDuration < 1) { + Toast.show("录音时间太短,请重新录音"); + setRecordingDuration(0); + return; + } + + const audioUrl = URL.createObjectURL(blob); + const newMessage: Message = { + id: Date.now().toString(), + type: "voice", + audioUrl, + duration: recordingDuration, + timestamp: Date.now(), + isTranslating: true, + }; + + setMessages((prev) => [...prev, newMessage]); + setRecordingDuration(0); + + setTimeout(() => { + translateAudio(newMessage.id, blob); + }, 1000); + + Toast.show("语音已发送"); + }, + [recordingDuration] + ); + + const cancelRecording = useCallback(() => { + console.log("=== 取消录音 ==="); + + try { + if (isRecordingRef.current) { + recorderControls.stopRecording(); + } + } catch (error) { + console.error("取消录音失败:", error); + } + + resetRecordingState(); + Toast.show("已取消录音"); + }, [recorderControls, resetRecordingState]); + + const handleRecordButtonClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + console.log("=== 录音按钮点击 ==="); + + if (isProcessing || permissionChecking) { + console.log("正在处理中,忽略点击"); + return; + } + + if (isRecordingRef.current) { + stopRecording(); + } else { + startRecording(); + } + }, + [isProcessing, permissionChecking, startRecording, stopRecording] + ); + + const translateAudio = async (messageId: string, audioBlob: Blob) => { + try { + const translatedText = await mockTranslateAudio(audioBlob); + + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { ...msg, translatedText, isTranslating: false } + : msg + ) + ); + } catch (error) { + console.error("翻译失败:", error); + Toast.show("翻译失败,请重试"); + + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { + ...msg, + isTranslating: false, + translatedText: "翻译失败,请重试", + } + : msg + ) + ); + } + }; + + const mockTranslateAudio = async (audioBlob: Blob): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + const mockTranslations = [ + "汪汪汪!我饿了,想吃东西!🍖", + "喵喵~我想要抱抱!🤗", + "我想出去玩耍!🎾", + "我很开心!😊", + "我有点害怕...😰", + "我想睡觉了~😴", + "主人,陪我玩一会儿吧!🎮", + "我想喝水了💧", + "外面有什么声音?👂", + "我爱你,主人!❤️", + ]; + const randomTranslation = + mockTranslations[Math.floor(Math.random() * mockTranslations.length)]; + resolve(randomTranslation); + }, 2000 + Math.random() * 2000); + }); + }; + + const playAudio = (messageId: string, audioUrl: string) => { + if (isRecording) { + Toast.show("录音中,无法播放音频"); + return; + } + + if (currentPlayingId === messageId) { + if (audioRefs.current[messageId]) { + audioRefs.current[messageId].pause(); + audioRefs.current[messageId].currentTime = 0; + } + setCurrentPlayingId(null); + setMessages((prev) => + prev.map((msg) => + msg.id === messageId ? { ...msg, isPlaying: false } : msg + ) + ); + return; + } + + stopAllAudio(); + + if (!audioRefs.current[messageId]) { + audioRefs.current[messageId] = new Audio(audioUrl); + } + + const audio = audioRefs.current[messageId]; + audio.currentTime = 0; + + audio.onended = () => { + setCurrentPlayingId(null); + setMessages((prev) => + prev.map((msg) => + msg.id === messageId ? { ...msg, isPlaying: false } : msg + ) + ); + }; + + audio.onerror = (error) => { + console.error("音频播放错误:", error); + Toast.show("音频播放失败"); + setCurrentPlayingId(null); + setMessages((prev) => + prev.map((msg) => + msg.id === messageId ? { ...msg, isPlaying: false } : msg + ) + ); + }; + + audio + .play() + .then(() => { + setCurrentPlayingId(messageId); + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { ...msg, isPlaying: true } + : { ...msg, isPlaying: false } + ) + ); + }) + .catch((error) => { + console.error("音频播放失败:", error); + Toast.show("音频播放失败"); + }); + }; + + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + const formatTime = (timestamp: number) => { + return new Date(timestamp).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + }; + + return ( +
+ {/* 调试信息 */} + {process.env.NODE_ENV === "development" && ( +
+
初始化中: {initializingRef.current ? "⏳" : "✅"}
+
已初始化: {initializedRef.current ? "✅" : "❌"}
+
状态初始化: {isInitialized ? "✅" : "❌"}
+
+ 权限: {hasPermission === null ? "⏳" : hasPermission ? "✅" : "❌"} +
+
录音: {isRecording ? "🔴" : "⚪"}
+
处理中: {isProcessing ? "⏳" : "✅"}
+
权限检查: {permissionChecking ? "⏳" : "✅"}
+
弹窗: {dialogShowing ? "📱" : "❌"}
+
+ )} + + {/* 头部 */} +
+
🐾
+
+
我的宠物
+
+ {initializingRef.current + ? "初始化中..." + : !isInitialized + ? "等待初始化..." + : permissionChecking + ? "检查权限中..." + : hasPermission === null + ? "权限状态未知" + : hasPermission + ? "宠物语音翻译器" + : "需要麦克风权限"} +
+
+
+ + {/* 消息列表 */} +
+ {messages.length === 0 ? ( +
+
🎤
+
+ {initializingRef.current + ? "正在初始化..." + : !isInitialized + ? "等待初始化..." + : permissionChecking + ? "正在检查权限..." + : hasPermission === null + ? "权限状态未知" + : !hasPermission + ? "请授权麦克风权限" + : "点击下方按钮开始录音"} +
+
听听你的宠物想说什么~
+
+ ) : ( + messages.map((message) => ( +
+
+
🐾
+
+
+
playAudio(message.id, message.audioUrl)} + > +
+ {message.isPlaying ? ( +
+ + + +
+ ) : ( + + )} +
+
+ {formatDuration(message.duration)} +
+
+
+ + {message.isTranslating ? ( +
+
+ 翻译中 +
+ + + +
+
+
+ ) : message.translatedText ? ( +
+
🐾 宠物说:
+
+ {message.translatedText} +
+
+ ) : null} + +
+ {formatTime(message.timestamp)} +
+
+
+
+ )) + )} +
+
+ + {/* 录音控制区域 */} +
+
+ +
+ +
+ {isRecording && ( +
+
+
+ 录音中 {formatDuration(recordingDuration)} +
+
再次点击完成录音并发送
+
+ )} + +
+
+ {/* {isRecording && ( + + )} */} + + +
+ + {!isRecording && ( +
+ {initializingRef.current + ? "初始化中..." + : !isInitialized + ? "等待初始化..." + : permissionChecking + ? "检查权限中..." + : !hasPermission + ? "需要麦克风权限" + : isProcessing + ? "处理中..." + : "点击录音"} +
+ )} +
+
+
+
+ ); +}; + +export default PetVoiceTranslator; diff --git a/src/component/petVoiceTranslator/index.less b/src/component/petVoiceTranslator/index.less new file mode 100644 index 0000000..a9ce8d4 --- /dev/null +++ b/src/component/petVoiceTranslator/index.less @@ -0,0 +1,660 @@ +// PetVoiceTranslator.less +.pet-voice-translator { + display: flex; + flex-direction: column; + height: 100%; + background: #ededed; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, + sans-serif; + position: relative; + overflow: hidden; + // 处理iOS安全区域 + padding-bottom: env(safe-area-inset-bottom); + + // Safari 特殊处理 + -webkit-overflow-scrolling: touch; + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + + .chat-header { + display: flex; + align-items: center; + padding: 12px 16px; + background: #f7f7f7; + border-bottom: 1px solid #e5e5e5; + flex-shrink: 0; + position: relative; + z-index: 10; + + // 确保头部不被NavBar遮挡 + min-height: 64px; + + .pet-avatar { + width: 40px; + height: 40px; + border-radius: 20px; + background: linear-gradient(45deg, #ff9a9e, #fecfef); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + margin-right: 12px; + } + + .chat-title { + flex: 1; + + .pet-name { + font-size: 16px; + font-weight: 500; + color: #333; + margin-bottom: 2px; + } + + .chat-subtitle { + font-size: 12px; + color: #999; + } + } + } + + .messages-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 12px; + position: relative; + + // Safari 滚动优化 + -webkit-overflow-scrolling: touch; + -webkit-transform: translateZ(0); + transform: translateZ(0); + + // 确保可以滚动 + overscroll-behavior: contain; + scroll-behavior: smooth; + + // 滚动条样式 + &::-webkit-scrollbar { + width: 0; + background: transparent; + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #999; + min-height: 200px; + + .empty-icon { + font-size: 64px; + margin-bottom: 16px; + opacity: 0.6; + } + + .empty-text { + font-size: 16px; + margin-bottom: 8px; + } + + .empty-subtitle { + font-size: 14px; + opacity: 0.8; + } + } + + .message-wrapper { + margin-bottom: 16px; + animation: messageSlideIn 0.3s ease-out; + + -webkit-user-select: none; + user-select: none; + + .message-item { + display: flex; + align-items: flex-start; + + .avatar { + width: 40px; + height: 40px; + border-radius: 6px; + background: linear-gradient(45deg, #ff9a9e, #fecfef); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + margin-right: 8px; + flex-shrink: 0; + } + + .message-content { + flex: 1; + max-width: calc(100% - 80px); + + .voice-bubble { + background: #95ec69; + border-radius: 8px; + padding: 8px 12px; + margin-bottom: 6px; + position: relative; + display: inline-block; + min-width: 120px; + cursor: pointer; + user-select: none; + + // 防止长按选择 + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + + // 优化点击响应 + touch-action: manipulation; + + &::before { + content: ""; + position: absolute; + left: -8px; + top: 12px; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 8px solid #95ec69; + } + + &:active { + background: #8de354; + transform: scale(0.98); + transition: all 0.1s ease; + } + + .voice-content { + display: flex; + align-items: center; + + .voice-icon { + margin-right: 8px; + font-size: 16px; + color: #333; + + .playing-animation { + display: flex; + align-items: center; + gap: 2px; + + span { + width: 3px; + height: 12px; + background: #333; + border-radius: 1px; + animation: voiceWave 1s infinite ease-in-out; + + &:nth-child(2) { + animation-delay: 0.1s; + } + + &:nth-child(3) { + animation-delay: 0.2s; + } + } + } + } + + .voice-duration { + font-size: 14px; + color: #333; + font-weight: 500; + } + } + } + + .translation-text { + font-size: 13px; + color: #666; + margin-left: 4px; + margin-bottom: 4px; + line-height: 1.4; + word-wrap: break-word; + word-break: break-word; + + &.translating { + .translating-content { + display: flex; + align-items: center; + + .loading-dots { + margin-right: 8px; + + &::after { + content: "..."; + animation: loadingDots 1.5s infinite; + } + } + + .typing-indicator { + display: flex; + gap: 3px; + + span { + width: 4px; + height: 4px; + background: #999; + border-radius: 50%; + animation: typingBounce 1.4s infinite ease-in-out; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } + } + } + + .translation-label { + font-size: 11px; + color: #999; + margin-bottom: 2px; + } + + .translation-content { + color: #333; + word-wrap: break-word; + word-break: break-word; + } + } + + .message-time { + font-size: 11px; + color: #999; + margin-left: 4px; + } + } + } + } + } + + .recording-controls { + background: #f7f7f7; + border-top: 1px solid #e5e5e5; + padding: 12px 16px 20px; + flex-shrink: 0; + position: relative; + z-index: 10; + + // 确保控制区域不被底部安全区域影响 + min-height: 100px; + + // 防止在Safari中被下拉刷新影响 + -webkit-user-select: none; + user-select: none; + + .recorder-wrapper { + display: none; + } + + .control-area { + .recording-info { + text-align: center; + margin-bottom: 12px; + + .recording-indicator { + display: flex; + align-items: center; + justify-content: center; + color: #ff4d4f; + font-size: 14px; + margin-bottom: 4px; + + .recording-dot { + width: 8px; + height: 8px; + background: #ff4d4f; + border-radius: 50%; + margin-right: 8px; + animation: recordingPulse 1s infinite; + } + } + + .recording-tip { + font-size: 12px; + color: #999; + } + } + + .input-area { + display: flex; + flex-direction: column; + align-items: center; + + .button-group { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 8px; + + .cancel-button { + width: 40px; + height: 40px; + border-radius: 20px; + border: none; + background: #ff4d4f; + color: white; + cursor: pointer; + transition: all 0.1s ease; + outline: none; + -webkit-tap-highlight-color: transparent; + -webkit-appearance: none; + -webkit-user-select: none; + display: flex; + align-items: center; + justify-content: center; + animation: slideInLeft 0.3s ease-out; + + // 优化点击响应 + touch-action: manipulation; + + &:active { + transform: scale(0.9); + background: #ff7875; + } + + .cancel-icon { + font-size: 18px; + } + } + + .record-button { + width: 60px; + height: 60px; + border-radius: 30px; + border: none; + background: #1890ff; + color: white; + cursor: pointer; + transition: all 0.1s ease; + outline: none; + -webkit-tap-highlight-color: transparent; + -webkit-appearance: none; + -webkit-user-select: none; + display: flex; + align-items: center; + justify-content: center; + + // 优化点击响应 + touch-action: manipulation; + + &:active { + transform: scale(0.9); + } + + &:disabled { + background: #d9d9d9; + color: #999; + cursor: not-allowed; + } + + &.processing { + background: #faad14; + + cursor: wait; + + .processing-animation { + display: flex; + + align-items: center; + + gap: 3 px; + + span { + width: 3 px; + + height: 16 px; + + background: white; + + border-radius: 1 px; + + animation: processingWave 1.5 s infinite ease - in - out; + + &:nth-child(2) { + animation-delay: 0.2 s; + } + + &:nth-child(3) { + animation-delay: 0.4 s; + } + } + } + } + + &.recording { + background: #ff4d4f; + animation: recordingButtonPulse 1s infinite; + + .recording-animation { + display: flex; + align-items: center; + gap: 3px; + + span { + width: 3px; + height: 16px; + background: white; + border-radius: 1px; + animation: recordingWave 1s infinite ease-in-out; + + &:nth-child(2) { + animation-delay: 0.1s; + } + + &:nth-child(3) { + animation-delay: 0.2s; + } + } + } + } + + .mic-icon { + font-size: 24px; + } + } + } + + .record-hint { + font-size: 12px; + color: #999; + } + } + } + } +} + +// 全局样式,确保页面不会滚动 +html, +body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + -webkit-overflow-scrolling: touch; + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + + // 防止iOS Safari的橡皮筋效果 + position: fixed; + width: 100%; +} + +// 动画定义 +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes voiceWave { + 0%, + 40%, + 100% { + transform: scaleY(0.4); + } + 20% { + transform: scaleY(1); + } +} + +@keyframes loadingDots { + 0%, + 20% { + content: "."; + } + 40% { + content: ".."; + } + 60%, + 100% { + content: "..."; + } +} + +@keyframes typingBounce { + 0%, + 60%, + 100% { + transform: translateY(0); + } + 30% { + transform: translateY(-6px); + } +} + +@keyframes recordingPulse { + 0% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.2); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes recordingButtonPulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(255, 77, 79, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); + } +} + +@keyframes recordingWave { + 0%, + 40%, + 100% { + transform: scaleY(0.4); + } + 20% { + transform: scaleY(1); + } +} + +// 移动端适配 +// @media (max-width: 768px) { +// .pet-voice-translator { +// .messages-container { +// padding: 12px 8px; + +// .message-wrapper .message-item .message-content { +// max-width: calc(100% - 60px); +// } +// } + +// .recording-controls { +// padding: 10px 12px 16px; +// } +// } +// } + +// iOS Safari 特殊处理 +@supports (-webkit-touch-callout: none) { + .pet-voice-translator { + // 确保在iOS Safari中正确显示 + // height: 100vh; + height: -webkit-fill-available; + + .messages-container { + -webkit-overflow-scrolling: touch; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + } +} + +// 深色模式适配 +@media (prefers-color-scheme: dark) { + .pet-voice-translator { + background: #1e1e1e; + + .chat-header { + background: #2a2a2a; + border-bottom-color: #3a3a3a; + + .chat-title .pet-name { + color: #fff; + } + } + + .messages-container { + .message-wrapper .message-item .message-content .translation-text { + color: #ccc; + + .translation-content { + color: #fff; + } + } + } + + .recording-controls { + background: #2a2a2a; + border-top-color: #3a3a3a; + } + } +} diff --git a/src/component/petVoiceTranslator/index.tsx b/src/component/petVoiceTranslator/index.tsx new file mode 100644 index 0000000..a0a4949 --- /dev/null +++ b/src/component/petVoiceTranslator/index.tsx @@ -0,0 +1,1104 @@ +// // PetVoiceTranslator.tsx +// import React, { useState, useRef, useEffect, useCallback } from "react"; +// import { AudioRecorder, useAudioRecorder } from "react-audio-voice-recorder"; +// import { Button, Toast, Dialog } from "antd-mobile"; +// import { SoundOutline, CloseOutline } from "antd-mobile-icons"; +// import "./index.less"; + +// interface Message { +// id: string; +// type: "voice"; +// audioUrl: string; +// duration: number; +// timestamp: number; +// translatedText?: string; +// isTranslating?: boolean; +// isPlaying?: boolean; +// } + +// const PetVoiceTranslator: React.FC = () => { +// const [messages, setMessages] = useState([]); +// const [isRecording, setIsRecording] = useState(false); +// const [hasPermission, setHasPermission] = useState(null); +// const [currentPlayingId, setCurrentPlayingId] = useState(null); +// const [recordingDuration, setRecordingDuration] = useState(0); +// const [isInitialized, setIsInitialized] = useState(false); +// const [permissionChecking, setPermissionChecking] = useState(false); +// const [dialogShowing, setDialogShowing] = useState(false); + +// const messagesEndRef = useRef(null); +// const messagesContainerRef = useRef(null); +// const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({}); +// const recordingTimerRef = useRef(); +// const isRecordingRef = useRef(false); +// const initializingRef = useRef(false); +// const initializedRef = useRef(false); +// const isCancelledRef = useRef(false); +// const recordingStartTimeRef = useRef(0); + +// // 音效相关 +// const sendSoundRef = useRef(null); +// const startRecordSoundRef = useRef(null); + +// const recorderControls = useAudioRecorder( +// { +// noiseSuppression: true, +// echoCancellation: true, +// autoGainControl: true, +// }, +// (err) => { +// console.error("录音错误:", err); +// Toast.show("录音失败,请重试"); +// resetRecordingState(); +// } +// ); + +// useEffect(() => { +// if (initializingRef.current || initializedRef.current) { +// return; +// } +// initializeApp(); + +// return () => { +// cleanup(); +// }; +// }, []); + +// useEffect(() => { +// scrollToBottom(); +// }, [messages]); + +// useEffect(() => { +// isRecordingRef.current = isRecording; +// }, [isRecording]); + +// // 初始化音效 +// useEffect(() => { +// initializeSounds(); +// return () => { +// cleanupSounds(); +// }; +// }, []); + +// const initializeSounds = () => { +// try { +// // 发送音效 - 使用Web Audio API生成 +// sendSoundRef.current = createSendSound(); + +// // 开始录音音效 +// startRecordSoundRef.current = createStartRecordSound(); + +// console.log("音效初始化完成"); +// } catch (error) { +// console.error("音效初始化失败:", error); +// } +// }; + +// const cleanupSounds = () => { +// if (sendSoundRef.current) { +// sendSoundRef.current.pause(); +// sendSoundRef.current = null; +// } +// if (startRecordSoundRef.current) { +// startRecordSoundRef.current.pause(); +// startRecordSoundRef.current = null; +// } +// }; + +// // 创建发送音效 - 清脆的"叮"声 +// const createSendSound = () => { +// try { +// const audioContext = new (window.AudioContext || +// (window as any).webkitAudioContext)(); +// const oscillator = audioContext.createOscillator(); +// const gainNode = audioContext.createGain(); + +// oscillator.connect(gainNode); +// gainNode.connect(audioContext.destination); + +// // 设置音调 - 清脆的高音 +// oscillator.frequency.setValueAtTime(800, audioContext.currentTime); +// oscillator.frequency.exponentialRampToValueAtTime( +// 1200, +// audioContext.currentTime + 0.1 +// ); + +// // 设置音量包络 +// gainNode.gain.setValueAtTime(0, audioContext.currentTime); +// gainNode.gain.linearRampToValueAtTime( +// 0.3, +// audioContext.currentTime + 0.01 +// ); +// gainNode.gain.exponentialRampToValueAtTime( +// 0.01, +// audioContext.currentTime + 0.3 +// ); + +// oscillator.type = "sine"; + +// // 创建音频元素 +// const audio = new Audio(); + +// // 重写play方法来播放合成音效 +// audio.play = () => { +// return new Promise((resolve) => { +// try { +// const newOscillator = audioContext.createOscillator(); +// const newGainNode = audioContext.createGain(); + +// newOscillator.connect(newGainNode); +// newGainNode.connect(audioContext.destination); + +// newOscillator.frequency.setValueAtTime( +// 800, +// audioContext.currentTime +// ); +// newOscillator.frequency.exponentialRampToValueAtTime( +// 1200, +// audioContext.currentTime + 0.1 +// ); + +// newGainNode.gain.setValueAtTime(0, audioContext.currentTime); +// newGainNode.gain.linearRampToValueAtTime( +// 0.3, +// audioContext.currentTime + 0.01 +// ); +// newGainNode.gain.exponentialRampToValueAtTime( +// 0.01, +// audioContext.currentTime + 0.3 +// ); + +// newOscillator.type = "sine"; +// newOscillator.start(audioContext.currentTime); +// newOscillator.stop(audioContext.currentTime + 0.3); + +// setTimeout(() => resolve(undefined), 300); +// } catch (error) { +// console.error("播放发送音效失败:", error); +// resolve(undefined); +// } +// }); +// }; + +// return audio; +// } catch (error) { +// console.error("创建发送音效失败:", error); +// return new Audio(); // 返回空音频对象 +// } +// }; + +// // 创建开始录音音效 - 低沉的"嘟"声 +// const createStartRecordSound = () => { +// try { +// const audio = new Audio(); + +// audio.play = () => { +// return new Promise((resolve) => { +// try { +// const audioContext = new (window.AudioContext || +// (window as any).webkitAudioContext)(); +// const oscillator = audioContext.createOscillator(); +// const gainNode = audioContext.createGain(); + +// oscillator.connect(gainNode); +// gainNode.connect(audioContext.destination); + +// // 设置音调 - 低沉的音 +// oscillator.frequency.setValueAtTime(400, audioContext.currentTime); + +// // 设置音量包络 +// gainNode.gain.setValueAtTime(0, audioContext.currentTime); +// gainNode.gain.linearRampToValueAtTime( +// 0.2, +// audioContext.currentTime + 0.05 +// ); +// gainNode.gain.linearRampToValueAtTime( +// 0, +// audioContext.currentTime + 0.2 +// ); + +// oscillator.type = "sine"; +// oscillator.start(audioContext.currentTime); +// oscillator.stop(audioContext.currentTime + 0.2); + +// setTimeout(() => resolve(undefined), 200); +// } catch (error) { +// console.error("播放录音音效失败:", error); +// resolve(undefined); +// } +// }); +// }; + +// return audio; +// } catch (error) { +// console.error("创建录音音效失败:", error); +// return new Audio(); +// } +// }; + +// // 震动函数 +// const vibrate = (pattern: number | number[]) => { +// try { +// if ("vibrate" in navigator) { +// navigator.vibrate(pattern); +// console.log("震动:", pattern); +// } else { +// console.log("设备不支持震动"); +// } +// } catch (error) { +// console.error("震动失败:", error); +// } +// }; + +// // 播放音效 +// const playSound = async (soundRef: React.RefObject) => { +// try { +// if (soundRef.current) { +// await soundRef.current.play(); +// } +// } catch (error) { +// console.error("播放音效失败:", error); +// } +// }; + +// const initializeApp = useCallback(async () => { +// if (initializingRef.current || initializedRef.current) { +// return; +// } + +// initializingRef.current = true; +// console.log("=== 开始初始化应用 ==="); + +// try { +// await checkMicrophonePermission(); +// setupSafariCompatibility(); +// setIsInitialized(true); +// initializedRef.current = true; +// console.log("=== 应用初始化完成 ==="); +// } catch (error) { +// console.error("应用初始化失败:", error); +// Toast.show("应用初始化失败"); +// setIsInitialized(true); +// initializedRef.current = true; +// } finally { +// initializingRef.current = false; +// } +// }, []); + +// const cleanup = useCallback(() => { +// console.log("执行清理操作..."); + +// initializingRef.current = false; +// initializedRef.current = false; +// isCancelledRef.current = false; +// recordingStartTimeRef.current = 0; + +// if (recordingTimerRef.current) { +// clearInterval(recordingTimerRef.current); +// } + +// Object.values(audioRefs.current).forEach((audio) => { +// audio.pause(); +// audio.src = ""; +// }); + +// cleanupSounds(); +// cleanupSafariCompatibility(); +// }, []); + +// const resetRecordingState = useCallback(() => { +// console.log("重置录音状态"); +// setIsRecording(false); +// setRecordingDuration(0); +// isRecordingRef.current = false; +// isCancelledRef.current = false; +// recordingStartTimeRef.current = 0; + +// if (recordingTimerRef.current) { +// clearInterval(recordingTimerRef.current); +// recordingTimerRef.current = undefined; +// } +// }, []); + +// const checkMicrophonePermission = useCallback( +// async (showToast = false) => { +// if (permissionChecking) { +// return hasPermission; +// } + +// setPermissionChecking(true); + +// try { +// if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { +// setHasPermission(false); +// if (showToast) { +// Toast.show("浏览器不支持录音功能"); +// } +// return false; +// } + +// if (navigator.permissions && navigator.permissions.query) { +// try { +// const permissionStatus = await navigator.permissions.query({ +// name: "microphone" as PermissionName, +// }); + +// if (permissionStatus.state === "denied") { +// setHasPermission(false); +// if (showToast && !dialogShowing) { +// showPermissionDialog(); +// } +// return false; +// } + +// if (permissionStatus.state === "granted") { +// setHasPermission(true); +// return true; +// } +// } catch (permError) { +// console.log("权限查询不支持,继续使用getUserMedia检查"); +// } +// } + +// const stream = await navigator.mediaDevices.getUserMedia({ +// audio: { +// echoCancellation: true, +// noiseSuppression: true, +// autoGainControl: true, +// }, +// }); + +// stream.getTracks().forEach((track) => track.stop()); +// setHasPermission(true); + +// if (showToast) { +// Toast.show("麦克风权限已获取"); +// } +// return true; +// } catch (error) { +// console.error("权限检查失败:", error); +// setHasPermission(false); + +// if (showToast && !dialogShowing) { +// showPermissionDialog(); +// } +// return false; +// } finally { +// setPermissionChecking(false); +// } +// }, +// [permissionChecking, dialogShowing, hasPermission] +// ); + +// const showPermissionDialog = useCallback(() => { +// if (dialogShowing) { +// return; +// } + +// setDialogShowing(true); + +// Dialog.confirm({ +// title: "需要录音权限", +// content: +// '为了使用录音翻译功能,需要您授权麦克风权限。请在浏览器设置中允许访问麦克风,然后点击"重新获取权限"。', +// confirmText: "重新获取权限", +// cancelText: "取消", +// onConfirm: async () => { +// setDialogShowing(false); +// setTimeout(async () => { +// await checkMicrophonePermission(true); +// }, 500); +// }, +// onCancel: () => { +// setDialogShowing(false); +// Toast.show("需要麦克风权限才能使用录音功能"); +// }, +// onClose: () => { +// setDialogShowing(false); +// }, +// }); +// }, [dialogShowing, checkMicrophonePermission]); + +// const setupSafariCompatibility = useCallback(() => { +// const root = document.documentElement; +// const isIPhoneX = +// /iPhone/.test(navigator.userAgent) && window.screen.height >= 812; + +// if (isIPhoneX) { +// root.style.setProperty( +// "--safe-area-inset-top", +// "env(safe-area-inset-top, 44px)" +// ); +// root.style.setProperty( +// "--safe-area-inset-bottom", +// "env(safe-area-inset-bottom, 34px)" +// ); +// } else { +// root.style.setProperty( +// "--safe-area-inset-top", +// "env(safe-area-inset-top, 20px)" +// ); +// root.style.setProperty( +// "--safe-area-inset-bottom", +// "env(safe-area-inset-bottom, 0px)" +// ); +// } + +// document.body.style.overflow = "hidden"; +// document.body.style.position = "fixed"; +// document.body.style.width = "100%"; +// document.body.style.height = "100%"; +// document.body.style.top = "0"; +// document.body.style.left = "0"; + +// document.addEventListener("touchstart", preventPullToRefresh, { +// passive: false, +// }); +// document.addEventListener("touchmove", preventPullToRefresh, { +// passive: false, +// }); +// document.addEventListener("touchend", preventDoubleTapZoom, { +// passive: false, +// }); + +// let viewport = document.querySelector("meta[name=viewport]"); +// if (!viewport) { +// viewport = document.createElement("meta"); +// viewport.setAttribute("name", "viewport"); +// document.head.appendChild(viewport); +// } +// viewport.setAttribute( +// "content", +// "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" +// ); +// }, []); + +// const cleanupSafariCompatibility = useCallback(() => { +// document.body.style.overflow = ""; +// document.body.style.position = ""; +// document.body.style.width = ""; +// document.body.style.height = ""; +// document.body.style.top = ""; +// document.body.style.left = ""; + +// document.removeEventListener("touchstart", preventPullToRefresh); +// document.removeEventListener("touchmove", preventPullToRefresh); +// document.removeEventListener("touchend", preventDoubleTapZoom); +// }, []); + +// const preventPullToRefresh = (e: TouchEvent) => { +// const target = e.target as HTMLElement; +// const messagesContainer = messagesContainerRef.current; + +// if (messagesContainer && messagesContainer.contains(target)) { +// return; +// } + +// if (e.touches.length > 1) { +// e.preventDefault(); +// return; +// } + +// const touch = e.touches[0]; +// const startY = touch.clientY; + +// if (startY < 100 && e.type === "touchstart") { +// e.preventDefault(); +// } +// }; + +// let lastTouchEnd = 0; +// const preventDoubleTapZoom = (e: TouchEvent) => { +// const now = Date.now(); +// if (now - lastTouchEnd <= 300) { +// e.preventDefault(); +// } +// lastTouchEnd = now; +// }; + +// const scrollToBottom = () => { +// if (messagesContainerRef.current) { +// const container = messagesContainerRef.current; +// requestAnimationFrame(() => { +// container.scrollTop = container.scrollHeight; +// }); +// } +// }; + +// const stopAllAudio = () => { +// if (currentPlayingId && audioRefs.current[currentPlayingId]) { +// audioRefs.current[currentPlayingId].pause(); +// audioRefs.current[currentPlayingId].currentTime = 0; + +// setMessages((prev) => +// prev.map((msg) => +// msg.id === currentPlayingId ? { ...msg, isPlaying: false } : msg +// ) +// ); +// setCurrentPlayingId(null); +// } + +// Object.values(audioRefs.current).forEach((audio) => { +// if (!audio.paused) { +// audio.pause(); +// audio.currentTime = 0; +// } +// }); +// }; + +// const startRecording = useCallback(async () => { +// console.log("=== 开始录音 ==="); + +// if (isRecordingRef.current) { +// console.log("已在录音,忽略"); +// return; +// } + +// if (!isInitialized || !initializedRef.current) { +// Toast.show("应用正在初始化,请稍后重试"); +// return; +// } + +// if (!hasPermission) { +// const granted = await checkMicrophonePermission(true); +// if (!granted) { +// return; +// } +// } + +// try { +// // 震动反馈 - 开始录音时轻微震动 +// vibrate(50); + +// // 播放开始录音音效 +// playSound(startRecordSoundRef); + +// // 立即更新UI状态 +// setIsRecording(true); +// setRecordingDuration(0); +// isCancelledRef.current = false; +// recordingStartTimeRef.current = Date.now(); + +// stopAllAudio(); + +// // 使用react-audio-voice-recorder开始录音 +// recorderControls.startRecording(); + +// // 立即开始计时 +// recordingTimerRef.current = setInterval(() => { +// setRecordingDuration((prev) => prev + 1); +// }, 1000); + +// Toast.show("开始录音..."); +// } catch (error) { +// console.error("开始录音失败:", error); +// resetRecordingState(); + +// if ( +// error.name === "NotAllowedError" || +// error.name === "PermissionDeniedError" +// ) { +// setHasPermission(false); +// if (!dialogShowing) { +// showPermissionDialog(); +// } +// } else { +// Toast.show(`录音失败: ${error.message || "未知错误"}`); +// } +// } +// }, [ +// hasPermission, +// isInitialized, +// recorderControls, +// checkMicrophonePermission, +// dialogShowing, +// showPermissionDialog, +// ]); + +// const stopRecording = useCallback(() => { +// console.log("=== 停止录音并发送 ==="); + +// if (!isRecordingRef.current) { +// console.log("未在录音,忽略"); +// return; +// } + +// try { +// // 震动反馈 - 发送时的震动模式:短-停-短 +// vibrate([100, 50, 100]); + +// // 立即更新UI状态 +// setIsRecording(false); + +// if (recordingTimerRef.current) { +// clearInterval(recordingTimerRef.current); +// recordingTimerRef.current = undefined; +// } + +// // 停止录音并触发onRecordingComplete +// recorderControls.stopRecording(); +// } catch (error) { +// console.error("停止录音失败:", error); +// resetRecordingState(); +// Toast.show("停止录音失败"); +// } +// }, [recorderControls]); + +// // 在发送时检查录音时长 +// const onRecordingComplete = useCallback( +// (blob: Blob) => { +// console.log("=== 录音完成回调 ==="); +// console.log("录音完成状态:", { +// blobSize: blob.size, +// isCancelled: isCancelledRef.current, +// recordingDuration, +// }); + +// // 如果被取消,直接忽略,不做任何处理 +// if (isCancelledRef.current) { +// console.log("录音被取消,完全忽略结果"); +// return; +// } + +// // 计算实际录音时长 +// const actualDuration = Math.floor( +// (Date.now() - recordingStartTimeRef.current) / 1000 +// ); +// const finalDuration = Math.max(recordingDuration, actualDuration); + +// console.log("录音完成,准备发送:", { +// counterDuration: recordingDuration, +// actualDuration, +// finalDuration, +// }); + +// // 在这里检查录音时长 - 发送时拦截 +// if (finalDuration < 1) { +// console.log("录音时间太短,拒绝发送"); +// Toast.show("录音时间太短,请重新录音"); +// setRecordingDuration(0); +// return; +// } + +// // 检查blob有效性 +// if (!blob || blob.size === 0) { +// console.log("录音数据无效,拒绝发送"); +// Toast.show("录音数据无效,请重新录音"); +// setRecordingDuration(0); +// return; +// } + +// // 通过所有检查,发送消息 +// // 播放发送成功音效 +// playSound(sendSoundRef); + +// // 发送成功的震动反馈 - 长震动表示成功 +// vibrate(200); + +// const audioUrl = URL.createObjectURL(blob); +// const newMessage: Message = { +// id: Date.now().toString(), +// type: "voice", +// audioUrl, +// duration: finalDuration, +// timestamp: Date.now(), +// isTranslating: true, +// }; + +// console.log("发送语音消息:", newMessage); +// setMessages((prev) => [...prev, newMessage]); +// setRecordingDuration(0); + +// setTimeout(() => { +// translateAudio(newMessage.id, blob); +// }, 1000); + +// Toast.show("语音已发送"); +// }, +// [recordingDuration] +// ); + +// const cancelRecording = useCallback(() => { +// console.log("=== 取消录音 ==="); + +// // 取消时的震动反馈 - 三次短震动表示取消 +// vibrate([50, 50, 50, 50, 50]); + +// // 立即设置取消标志 - 这是最重要的 +// isCancelledRef.current = true; + +// // 立即显示取消提示 +// Toast.show("已取消录音"); + +// // 直接重置状态,不调用recorderControls.stopRecording() +// setIsRecording(false); +// setRecordingDuration(0); +// isRecordingRef.current = false; + +// if (recordingTimerRef.current) { +// clearInterval(recordingTimerRef.current); +// recordingTimerRef.current = undefined; +// } +// }, []); + +// const handleRecordButtonClick = useCallback( +// (e: React.MouseEvent) => { +// e.preventDefault(); +// e.stopPropagation(); + +// console.log("=== 录音按钮点击 ==="); + +// if (permissionChecking) { +// console.log("权限检查中,忽略点击"); +// return; +// } + +// if (isRecordingRef.current) { +// stopRecording(); +// } else { +// startRecording(); +// } +// }, +// [permissionChecking, startRecording, stopRecording] +// ); + +// const translateAudio = async (messageId: string, audioBlob: Blob) => { +// try { +// const translatedText = await mockTranslateAudio(audioBlob); + +// setMessages((prev) => +// prev.map((msg) => +// msg.id === messageId +// ? { ...msg, translatedText, isTranslating: false } +// : msg +// ) +// ); +// } catch (error) { +// console.error("翻译失败:", error); +// Toast.show("翻译失败,请重试"); + +// setMessages((prev) => +// prev.map((msg) => +// msg.id === messageId +// ? { +// ...msg, +// isTranslating: false, +// translatedText: "翻译失败,请重试", +// } +// : msg +// ) +// ); +// } +// }; + +// const mockTranslateAudio = async (audioBlob: Blob): Promise => { +// return new Promise((resolve) => { +// setTimeout(() => { +// const mockTranslations = [ +// "汪汪汪!我饿了,想吃东西!🍖", +// "喵喵~我想要抱抱!🤗", +// "我想出去玩耍!🎾", +// "我很开心!😊", +// "我有点害怕...😰", +// "我想睡觉了~😴", +// "主人,陪我玩一会儿吧!🎮", +// "我想喝水了💧", +// "外面有什么声音?👂", +// "我爱你,主人!❤️", +// ]; +// const randomTranslation = +// mockTranslations[Math.floor(Math.random() * mockTranslations.length)]; +// resolve(randomTranslation); +// }, 2000 + Math.random() * 2000); +// }); +// }; + +// const playAudio = (messageId: string, audioUrl: string) => { +// if (isRecording) { +// Toast.show("录音中,无法播放音频"); +// return; +// } + +// if (currentPlayingId === messageId) { +// if (audioRefs.current[messageId]) { +// audioRefs.current[messageId].pause(); +// audioRefs.current[messageId].currentTime = 0; +// } +// setCurrentPlayingId(null); +// setMessages((prev) => +// prev.map((msg) => +// msg.id === messageId ? { ...msg, isPlaying: false } : msg +// ) +// ); +// return; +// } + +// stopAllAudio(); + +// if (!audioRefs.current[messageId]) { +// audioRefs.current[messageId] = new Audio(audioUrl); +// } + +// const audio = audioRefs.current[messageId]; +// audio.currentTime = 0; + +// audio.onended = () => { +// setCurrentPlayingId(null); +// setMessages((prev) => +// prev.map((msg) => +// msg.id === messageId ? { ...msg, isPlaying: false } : msg +// ) +// ); +// }; + +// audio.onerror = (error) => { +// console.error("音频播放错误:", error); +// Toast.show("音频播放失败"); +// setCurrentPlayingId(null); +// setMessages((prev) => +// prev.map((msg) => +// msg.id === messageId ? { ...msg, isPlaying: false } : msg +// ) +// ); +// }; + +// audio +// .play() +// .then(() => { +// setCurrentPlayingId(messageId); +// setMessages((prev) => +// prev.map((msg) => +// msg.id === messageId +// ? { ...msg, isPlaying: true } +// : { ...msg, isPlaying: false } +// ) +// ); +// }) +// .catch((error) => { +// console.error("音频播放失败:", error); +// Toast.show("音频播放失败"); +// }); +// }; + +// const formatDuration = (seconds: number) => { +// const mins = Math.floor(seconds / 60); +// const secs = seconds % 60; +// return `${mins}:${secs.toString().padStart(2, "0")}`; +// }; + +// const formatTime = (timestamp: number) => { +// return new Date(timestamp).toLocaleTimeString("zh-CN", { +// hour: "2-digit", +// minute: "2-digit", +// }); +// }; + +// return ( +//
+// {/* 调试信息 */} +// {process.env.NODE_ENV === "development" && ( +//
+//
初始化中: {initializingRef.current ? "⏳" : "✅"}
+//
已初始化: {initializedRef.current ? "✅" : "❌"}
+//
状态初始化: {isInitialized ? "✅" : "❌"}
+//
+// 权限: {hasPermission === null ? "⏳" : hasPermission ? "✅" : "❌"} +//
+//
录音: {isRecording ? "🔴" : "⚪"}
+//
权限检查: {permissionChecking ? "⏳" : "✅"}
+//
弹窗: {dialogShowing ? "📱" : "❌"}
+//
已取消: {isCancelledRef.current ? "✅" : "❌"}
+//
录音时长: {recordingDuration}s
+//
震动支持: {"vibrate" in navigator ? "✅" : "❌"}
+//
+// )} + +// {/* 头部 */} +//
+//
🐾
+//
+//
我的宠物
+//
+// {initializingRef.current +// ? "初始化中..." +// : !isInitialized +// ? "等待初始化..." +// : permissionChecking +// ? "检查权限中..." +// : hasPermission === null +// ? "权限状态未知" +// : hasPermission +// ? "宠物语音翻译器" +// : "需要麦克风权限"} +//
+//
+//
+ +// {/* 消息列表 */} +//
+// {messages.length === 0 ? ( +//
+//
🎤
+//
+// {initializingRef.current +// ? "正在初始化..." +// : !isInitialized +// ? "等待初始化..." +// : permissionChecking +// ? "正在检查权限..." +// : hasPermission === null +// ? "权限状态未知" +// : !hasPermission +// ? "请授权麦克风权限" +// : "点击下方按钮开始录音"} +//
+//
听听你的宠物想说什么~
+//
+// ) : ( +// messages.map((message) => ( +//
+//
+//
🐾
+//
+//
+//
playAudio(message.id, message.audioUrl)} +// > +//
+// {message.isPlaying ? ( +//
+// +// +// +//
+// ) : ( +// +// )} +//
+//
+// {formatDuration(message.duration)} +//
+//
+//
+ +// {message.isTranslating ? ( +//
+//
+// 翻译中 +//
+// +// +// +//
+//
+//
+// ) : message.translatedText ? ( +//
+//
🐾 宠物说:
+//
+// {message.translatedText} +//
+//
+// ) : null} + +//
+// {formatTime(message.timestamp)} +//
+//
+//
+//
+// )) +// )} +//
+//
+ +// {/* 录音控制区域 */} +//
+//
+// +//
+ +//
+// {isRecording && ( +//
+//
+//
+// 录音中 {formatDuration(recordingDuration)} +//
+//
再次点击完成录音并发送
+//
+// )} + +//
+//
+// {isRecording && ( +// +// )} + +// +//
+ +// {!isRecording && ( +//
+// {initializingRef.current +// ? "初始化中..." +// : !isInitialized +// ? "等待初始化..." +// : permissionChecking +// ? "检查权限中..." +// : !hasPermission +// ? "需要麦克风权限" +// : "点击录音"} +//
+// )} +//
+//
+//
+//
+// ); +// }; + +// export default PetVoiceTranslator; diff --git a/src/component/qr-scanner/index.tsx b/src/component/qr-scanner/index.tsx new file mode 100644 index 0000000..3aebcde --- /dev/null +++ b/src/component/qr-scanner/index.tsx @@ -0,0 +1,88 @@ +import React, {useRef, useEffect, useState} from 'react'; +import jsQR from 'jsqr'; + +export interface QRScannerProps { + width?: number; + height?: number; + onScan: (data: string) => void; +} + +/** + * 二维码扫描组件 + * @param width 画布宽度 + * @param height 画布高度 + * @param onScan 扫描成功的回调函数 + * @constructor QRScanner + */ +const QRScanner: React.FC = ({width = 300, height = 300, onScan}) => { + const videoRef = useRef(null); + const canvasRef = useRef(null); + const [isScanning, setIsScanning] = useState(true); + const [scanSuccess, setScanSuccess] = useState(false); // 新的状态变量 + let streamRef: MediaStream | null = null; // 存储摄像头流的引用 + + useEffect(() => { + navigator.mediaDevices.getUserMedia({video: {facingMode: "environment"}}) + .then(stream => { + streamRef = stream; // 存储对摄像头流的引用 + if (videoRef.current) { + videoRef.current.srcObject = stream; + videoRef.current.addEventListener('loadedmetadata', () => { + videoRef.current?.play().then(() => { + requestAnimationFrame(tick); + }).catch(err => console.error("Error playing video: ", err)); + }); + } + }).catch(err => { + console.error("Error accessing media devices: ", err); + setIsScanning(false); + }); + }, []); + const tick = () => { + // 如果已经成功扫描,就不再继续执行tick + if (!isScanning) return; + + if (videoRef.current && canvasRef.current) { + if (videoRef.current.readyState === videoRef.current.HAVE_ENOUGH_DATA) { + let video = videoRef.current; + let canvas = canvasRef.current; + let ctx = canvas.getContext('2d'); + + if (ctx) { + canvas.height = height; + canvas.width = width; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + let code = jsQR(imageData.data, imageData.width, imageData.height); + + if (code) { + onScan(code.data); // 扫码成功 + setIsScanning(false); // 更新状态为不再扫描 + setScanSuccess(true); // 显示扫描成功的蒙层 + if (streamRef) { + let tracks = streamRef.getTracks(); + tracks.forEach(track => track.stop()); // 关闭摄像头 + } + return; // 直接返回,避免再次调用 requestAnimationFrame + } + } + } + requestAnimationFrame(tick); + } + }; + return ( +
+ + + {scanSuccess &&
识别成功!
} {/* 显示识别成功的蒙层 */} +
+ ); +} + +export default QRScanner; diff --git a/src/component/voiceIcon/index.less b/src/component/voiceIcon/index.less new file mode 100644 index 0000000..3e40484 --- /dev/null +++ b/src/component/voiceIcon/index.less @@ -0,0 +1,69 @@ +/* VoiceIcon.css */ +.voice-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + cursor: pointer; + gap: 2px; +} +.audio-recorder { + display: none !important; +} + +.wave { + width: 1px; + height: 13px; + background-color: rgba(0, 0, 0, 0.6); + border-radius: 2px; + transition: all 0.3s ease; +} + +.wave1 { + height: 4px; +} + +.wave2 { + height: 13px; +} + +.wave3 { + height: 4px; +} + +.wave3 { + height: 8px; +} +.wave4 { + height: 4px; +} + +/* 播放动画 */ +.voice-icon.playing .wave { + animation: voice-wave 1.2s ease-in-out infinite; +} + +.voice-icon.playing .wave1 { + animation-delay: 0s; +} + +.voice-icon.playing .wave2 { + animation-delay: 0.2s; +} + +.voice-icon.playing .wave3 { + animation-delay: 0.4s; +} + +@keyframes voice-wave { + 0%, + 100% { + transform: scaleY(0.3); + opacity: 0.5; + } + 50% { + transform: scaleY(1); + opacity: 1; + } +} diff --git a/src/component/voiceIcon/index.tsx b/src/component/voiceIcon/index.tsx new file mode 100644 index 0000000..09fb7a4 --- /dev/null +++ b/src/component/voiceIcon/index.tsx @@ -0,0 +1,22 @@ +import React, { useCallback, useState } from "react"; +import "./index.less"; + +const VoiceIcon = (props: { isPlaying: boolean; onChange?: () => void }) => { + const { isPlaying = false } = props; + const onChange = useCallback(() => { + props.onChange?.(); + }, [isPlaying]); + return ( +
+
+
+
+
+
+ ); +}; + +export default React.memo(VoiceIcon); diff --git a/src/component/xpopup/index.less b/src/component/xpopup/index.less new file mode 100644 index 0000000..b5b92ec --- /dev/null +++ b/src/component/xpopup/index.less @@ -0,0 +1,11 @@ +.xpopup { + .adm-popup-body { + background-color: rgba(245, 245, 245, 1); + padding: 16px; + .header { + font-size: 16px; + display: flex; + justify-content: space-between; + } + } +} diff --git a/src/component/xpopup/index.tsx b/src/component/xpopup/index.tsx new file mode 100644 index 0000000..723cc11 --- /dev/null +++ b/src/component/xpopup/index.tsx @@ -0,0 +1,28 @@ +import { Divider, Popup } from "antd-mobile"; +import { CloseOutline } from "antd-mobile-icons"; +import "./index.less"; + +interface DefinedProps { + visible: boolean; + title: string; + children: React.ReactNode; + onClose: () => void; +} +function XPopup(props: DefinedProps) { + const { visible, title, children, onClose } = props; + + return ( + +
+

{title}

+ + + +
+ +
{children}
+
+ ); +} + +export default XPopup; diff --git a/src/composables/authorization.ts b/src/composables/authorization.ts new file mode 100644 index 0000000..b622afa --- /dev/null +++ b/src/composables/authorization.ts @@ -0,0 +1,42 @@ +import {useState, useEffect} from 'react'; + +export const STORAGE_AUTHORIZE_KEY = 'token'; + +export const useAuthorization = () => { + const [currentToken, setCurrentToken] = useState(localStorage.getItem(STORAGE_AUTHORIZE_KEY)); + + // 同步 localStorage 变更 + useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === STORAGE_AUTHORIZE_KEY) { + setCurrentToken(localStorage.getItem(STORAGE_AUTHORIZE_KEY)); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, []); + + const login = (token: string) => { + localStorage.setItem(STORAGE_AUTHORIZE_KEY, token); + setCurrentToken(token); + }; + + const logout = () => { + localStorage.removeItem(STORAGE_AUTHORIZE_KEY); + setCurrentToken(null); + }; + + const isLogin = () => { + return !!currentToken; + }; + + return { + token: currentToken, + login, + logout, + isLogin + }; +}; diff --git a/src/composables/language.ts b/src/composables/language.ts new file mode 100644 index 0000000..9c808aa --- /dev/null +++ b/src/composables/language.ts @@ -0,0 +1,21 @@ +import {setDefaultConfig} from 'antd-mobile'; +import enUS from 'antd-mobile/es/locales/en-US'; +import zhCN from 'antd-mobile/es/locales/zh-CN'; + +function changeLanguage(language: string) { + let locale; + switch (language) { + case 'zh_CN': + locale = enUS; + break; + case 'en_US': + locale = zhCN; + break; + default: + locale = enUS; // 或者是你的默认语言 + } + + setDefaultConfig({locale: locale}); +} + +export default changeLanguage; diff --git a/src/enum/http-enum.ts b/src/enum/http-enum.ts new file mode 100644 index 0000000..03938d1 --- /dev/null +++ b/src/enum/http-enum.ts @@ -0,0 +1,21 @@ +/** + * @description: request method + */ +export enum RequestEnum { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', +} + +/** + * @description: contentType + */ +export enum ContentTypeEnum { + // json + JSON = 'application/json;charset=UTF-8', + // form-data qs + FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8', + // form-data upload + FORM_DATA = 'multipart/form-data;charset=UTF-8', +} diff --git a/src/hooks/i18n.ts b/src/hooks/i18n.ts new file mode 100644 index 0000000..ac3cd8c --- /dev/null +++ b/src/hooks/i18n.ts @@ -0,0 +1,12 @@ +import zhCN from '@/locales/zh_CN.json' +import enUS from '@/locales/en_US.json' +import {useI18nStore} from '@/store/i18n'; + +export default function useI18n() { + const {lang} = useI18nStore(); + const locales = lang === 'en_US' ? enUS : zhCN; + + return (name: string) => { + return locales[name as keyof typeof locales] || name; + } +} diff --git a/src/hooks/location.ts b/src/hooks/location.ts new file mode 100644 index 0000000..f549fc2 --- /dev/null +++ b/src/hooks/location.ts @@ -0,0 +1,60 @@ +import {useState, useEffect} from 'react'; +import useI18n from "@/hooks/i18n"; + +// 定义位置坐标的类型 +export interface Coordinates { + lat: number; + lng: number; +} + +// 定义返回的位置状态的类型 +export interface LocationState { + loaded: boolean; + coordinates: Coordinates | null; +} + +// 定义错误状态的类型 +export type ErrorState = string | null; + +export const useLocation = (): { location: LocationState; error: ErrorState } => { + const t = useI18n(); + + // 用于存储位置信息和加载状态的状态 + const [location, setLocation] = useState({ + loaded: false, + coordinates: null, + }); + + // 用于存储任何错误消息的状态 + const [error, setError] = useState(null); + + // 地理位置成功处理函数 + const onSuccess = (location: GeolocationPosition) => { + setLocation({ + loaded: true, + coordinates: { + lat: location.coords.latitude, + lng: location.coords.longitude, + }, + }); + }; + + // 地理位置错误处理函数 + const onError = (error: GeolocationPositionError) => { + setError(error.message); + }; + + // 使用 useEffect 在组件挂载时执行地理位置请求 + useEffect(() => { + // 检查浏览器是否支持地理位置 + if (!navigator.geolocation) { + setError(t('hooks.location.unsupported')); + return; + } + + // 请求用户的当前位置 + navigator.geolocation.getCurrentPosition(onSuccess, onError); + }, []); + + return {location, error}; +}; diff --git a/src/hooks/session.ts b/src/hooks/session.ts new file mode 100644 index 0000000..5595df5 --- /dev/null +++ b/src/hooks/session.ts @@ -0,0 +1,28 @@ +import {useState, useEffect} from 'react'; +import isEqual from 'lodash.isequal'; + +function useSessionStorage(key: string, initialValue: T): [T, (value: T) => void] { + // 初始化状态 + const [storedValue, setStoredValue] = useState(() => { + const item = sessionStorage.getItem(key); + if (item !== null) { + // 如果 sessionStorage 中有数据,则使用现有数据 + return JSON.parse(item); + } else { + // 当 sessionStorage 中没有相应的键时,使用 initialValue 初始化,并写入 sessionStorage + sessionStorage.setItem(key, JSON.stringify(initialValue)); + return initialValue; + } + }); + + // 监听并保存变化到 sessionStorage + useEffect(() => { + if (!isEqual(JSON.parse(sessionStorage.getItem(key) || 'null'), storedValue)) { + sessionStorage.setItem(key, JSON.stringify(storedValue)); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +} + +export default useSessionStorage; diff --git a/src/hooks/useAudioControl.ts b/src/hooks/useAudioControl.ts new file mode 100644 index 0000000..15245dd --- /dev/null +++ b/src/hooks/useAudioControl.ts @@ -0,0 +1,55 @@ +// hooks/useAudioControl.ts +import { useEffect, useState } from "react"; +import AudioManager from "../utils/audioManager"; + +interface UseAudioControlReturn { + currentPlayingId: string | null; + stopAllAudio: () => void; + pauseAllAudio: () => void; + getAudioStates: () => Record< + string, + { + isPlaying: boolean; + duration: number; + currentTime: number; + } + >; +} + +export const useAudioControl = (): UseAudioControlReturn => { + const [currentPlayingId, setCurrentPlayingId] = useState(null); + const audioManager = AudioManager.getInstance(); + + useEffect(() => { + // 定期检查当前播放状态 + const interval = setInterval(() => { + const currentId = audioManager.getCurrentAudioId(); + setCurrentPlayingId(currentId); + }, 500); + + return () => { + clearInterval(interval); + }; + }, [audioManager]); + + const stopAllAudio = () => { + audioManager.stopCurrent(); + setCurrentPlayingId(null); + }; + + const pauseAllAudio = () => { + audioManager.pauseCurrent(); + setCurrentPlayingId(null); + }; + + const getAudioStates = () => { + return audioManager.getAudioStates(); + }; + + return { + currentPlayingId, + stopAllAudio, + pauseAllAudio, + getAudioStates, + }; +}; diff --git a/src/hooks/useFileUpload.ts b/src/hooks/useFileUpload.ts new file mode 100644 index 0000000..a2f61f8 --- /dev/null +++ b/src/hooks/useFileUpload.ts @@ -0,0 +1,198 @@ +// hooks/useFileUpload.ts (更新) +import { useState, useCallback } from "react"; +import { + UploadConfig, + UploadProgress, + UploadResponse, + VoiceUploadStatus, +} from "../types/upload"; + +interface UseFileUploadReturn { + uploadStatus: VoiceUploadStatus; + uploadFile: ( + file: Blob, + fileName: string, + config: UploadConfig + ) => Promise; + resetUpload: () => void; +} + +export const useFileUpload = (): UseFileUploadReturn => { + const [uploadStatus, setUploadStatus] = useState({ + status: "idle", + }); + + // 检测文件类型并转换文件名 + const getFileExtension = (mimeType: string): string => { + const mimeToExt: Record = { + "audio/webm": ".webm", + "audio/mp4": ".m4a", + "audio/aac": ".aac", + "audio/wav": ".wav", + "audio/ogg": ".ogg", + "audio/mpeg": ".mp3", + }; + + // 处理带codecs的MIME类型 + const baseMimeType = mimeType.split(";")[0]; + return mimeToExt[baseMimeType] || ".webm"; + }; + + const uploadFile = useCallback( + async ( + file: Blob, + fileName: string, + config: UploadConfig + ): Promise => { + // 检查文件大小 + if (config.maxFileSize && file.size > config.maxFileSize) { + const error = `文件大小超过限制 (${Math.round( + config.maxFileSize / 1024 / 1024 + )}MB)`; + setUploadStatus({ + status: "error", + error, + }); + throw new Error(error); + } + + // 更宽松的文件类型检查,支持iOS格式 + const allowedTypes = config.allowedTypes || [ + "audio/webm", + "audio/mp4", + "audio/aac", + "audio/wav", + "audio/ogg", + "audio/mpeg", + ]; + + const baseMimeType = file.type.split(";")[0]; + const isTypeAllowed = allowedTypes.some( + (type) => baseMimeType === type || baseMimeType === type.split(";")[0] + ); + + if (!isTypeAllowed) { + console.warn(`文件类型 ${file.type} 不在允许列表中,但继续上传`); + } + + // 根据实际MIME类型调整文件名 + const extension = getFileExtension(file.type); + const adjustedFileName = fileName.replace(/\.[^/.]+$/, "") + extension; + + setUploadStatus({ + status: "uploading", + progress: { loaded: 0, total: file.size, percentage: 0 }, + }); + + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append(config.fieldName || "file", file, adjustedFileName); + + // 添加额外的元数据 + formData.append("fileName", adjustedFileName); + formData.append("originalFileName", fileName); + formData.append("fileSize", file.size.toString()); + formData.append("fileType", file.type); + formData.append("uploadTime", new Date().toISOString()); + formData.append("userAgent", navigator.userAgent); + + const xhr = new XMLHttpRequest(); + + // 上传进度监听 + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + const progress: UploadProgress = { + loaded: event.loaded, + total: event.total, + percentage: Math.round((event.loaded / event.total) * 100), + }; + + setUploadStatus({ + status: "uploading", + progress, + }); + } + }); + + // 上传完成监听 + xhr.addEventListener("load", () => { + try { + const response: UploadResponse = JSON.parse(xhr.responseText); + + if (xhr.status >= 200 && xhr.status < 300 && response.success) { + setUploadStatus({ + status: "success", + response, + progress: { + loaded: file.size, + total: file.size, + percentage: 100, + }, + }); + resolve(response); + } else { + const error = response.error || `上传失败: ${xhr.status}`; + setUploadStatus({ + status: "error", + error, + }); + reject(new Error(error)); + } + } catch (parseError) { + const error = "服务器响应格式错误"; + setUploadStatus({ + status: "error", + error, + }); + reject(new Error(error)); + } + }); + + // 上传错误监听 + xhr.addEventListener("error", () => { + const error = "网络错误,上传失败"; + setUploadStatus({ + status: "error", + error, + }); + reject(new Error(error)); + }); + + // 上传中断监听 + xhr.addEventListener("abort", () => { + const error = "上传已取消"; + setUploadStatus({ + status: "error", + error, + }); + reject(new Error(error)); + }); + + // 设置请求头 + const headers = { + "X-Requested-With": "XMLHttpRequest", + ...config.headers, + }; + + Object.entries(headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value); + }); + + // 发送请求 + xhr.open(config.method || "POST", config.url); + xhr.send(formData); + }); + }, + [] + ); + + const resetUpload = useCallback(() => { + setUploadStatus({ status: "idle" }); + }, []); + + return { + uploadStatus, + uploadFile, + resetUpload, + }; +}; diff --git a/src/hooks/usePetTranslator.ts b/src/hooks/usePetTranslator.ts new file mode 100644 index 0000000..71438f1 --- /dev/null +++ b/src/hooks/usePetTranslator.ts @@ -0,0 +1,153 @@ +// hooks/usePetTranslator.ts +import { useState, useCallback } from "react"; +import { VoiceMessage, ChatMessage, PetProfile } from "../types/chat"; + +interface UsePetTranslatorReturn { + messages: ChatMessage[]; + currentPet: PetProfile; + translateVoice: (voiceMessage: VoiceMessage) => Promise; + addMessage: (message: ChatMessage) => void; + updateMessage: (messageId: string, updatedMessage: ChatMessage) => void; // 新增 + clearMessages: () => void; + setPet: (pet: PetProfile) => void; +} + +export const usePetTranslator = (): UsePetTranslatorReturn => { + const [messages, setMessages] = useState([]); + const [currentPet, setCurrentPet] = useState({ + name: "小汪", + avatar: "🐕", + species: "dog", + personality: "活泼可爱", + }); + + // 模拟翻译API调用 + const translateVoice = useCallback( + async (voiceMessage: VoiceMessage): Promise => { + // 更新消息状态为翻译中 + setMessages((prev) => + prev.map((msg) => + msg.id === voiceMessage.id ? { ...msg, translating: true } : msg + ) + ); + + try { + // 模拟API调用延迟 + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // 模拟翻译结果 + const translations = { + dog: [ + "主人,我饿了!给我点好吃的吧!🍖", + "我想出去玩耍!带我去公园吧!🏃‍♂️", + "我爱你,主人!你是最好的!❤️", + "我想睡觉了,陪我一起休息吧!😴", + "有陌生人!我要保护你!🛡️", + "今天天气真好,我们去散步吧!🌞", + "我想和其他狗狗玩耍!🐕‍🦺", + "主人回来了!我好开心!🎉", + ], + cat: [ + "铲屎官,快来伺候本喵!😼", + "我要晒太阳,别打扰我!☀️", + "给我准备小鱼干!现在就要!🐟", + "我心情不好,离我远点!😾", + "勉强让你摸摸我的头吧!😸", + "这个位置是我的,你不能坐!🛋️", + "我饿了,但我不会告诉你的!🙄", + "今天我心情好,可以陪你玩一会!😺", + ], + bird: [ + "早上好!新的一天开始了!🌅", + "我想唱歌给你听!🎵", + "给我一些种子吧!我饿了!🌱", + "外面的世界真美好!🌳", + "我想和你一起飞翔!🕊️", + "今天的阳光真温暖!☀️", + "我学会了新的歌曲!🎶", + "陪我聊聊天吧!💬", + ], + other: [ + "我想和你交流!👋", + "照顾好我哦!💕", + "我很开心!😊", + "陪我玩一会儿吧!🎾", + "我需要你的关爱!🤗", + "今天过得真愉快!😄", + "我想要你的注意!👀", + "我们是最好的朋友!🤝", + ], + }; + + const speciesTranslations = + translations[currentPet.species] || translations.other; + const randomTranslation = + speciesTranslations[ + Math.floor(Math.random() * speciesTranslations.length) + ]; + + // 更新翻译结果 + setMessages((prev) => + prev.map((msg) => + msg.id === voiceMessage.id + ? { ...msg, translation: randomTranslation, translating: false } + : msg + ) + ); + + // 添加宠物回复 + const petReply: ChatMessage = { + id: `pet_${Date.now()}`, + type: "text", + content: `${currentPet.name}说:${randomTranslation}`, + sender: "pet", + timestamp: Date.now(), + }; + + setMessages((prev) => [...prev, petReply]); + } catch (error) { + console.error("翻译失败:", error); + setMessages((prev) => + prev.map((msg) => + msg.id === voiceMessage.id + ? { ...msg, translation: "翻译失败,请重试", translating: false } + : msg + ) + ); + } + }, + [currentPet] + ); + + const addMessage = useCallback((message: ChatMessage): void => { + setMessages((prev) => [...prev, message]); + }, []); + + // 新增:更新消息方法 + const updateMessage = useCallback( + (messageId: string, updatedMessage: ChatMessage): void => { + setMessages((prev) => + prev.map((msg) => (msg.id === messageId ? updatedMessage : msg)) + ); + }, + [] + ); + + const clearMessages = useCallback((): void => { + setMessages([]); + }, []); + + const setPet = useCallback((pet: PetProfile): void => { + setCurrentPet(pet); + }, []); + + return { + messages, + currentPet, + translateVoice, + addMessage, + updateMessage, // 导出新方法 + clearMessages, + setPet, + }; +}; diff --git a/src/hooks/useVoiceRecorder.ts b/src/hooks/useVoiceRecorder.ts new file mode 100644 index 0000000..400df19 --- /dev/null +++ b/src/hooks/useVoiceRecorder.ts @@ -0,0 +1,125 @@ +// hooks/useVoiceRecorder.ts (使用新的录音工具类) +import { useState, useRef, useCallback } from "react"; +import { UniversalAudioRecorder } from "../utils/audioRecorder"; + +interface UseVoiceRecorderReturn { + isRecording: boolean; + recordingTime: number; + isPaused: boolean; + startRecording: () => Promise; + stopRecording: () => Promise; + pauseRecording: () => void; + resumeRecording: () => void; + cancelRecording: () => void; +} + +export const useVoiceRecorder = (): UseVoiceRecorderReturn => { + const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [recordingTime, setRecordingTime] = useState(0); + + const recorderRef = useRef(null); + const timerRef = useRef(null); + + const startTimer = useCallback(() => { + timerRef.current = setInterval(() => { + if (recorderRef.current) { + setRecordingTime(recorderRef.current.getDuration()); + } + }, 100); + }, []); + + const stopTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const startRecording = useCallback(async (): Promise => { + try { + const recorder = new UniversalAudioRecorder({ + sampleRate: 16000, + channels: 1, + bitDepth: 16, + }); + + await recorder.start(); + recorderRef.current = recorder; + + setIsRecording(true); + setIsPaused(false); + setRecordingTime(0); + startTimer(); + } catch (error) { + console.error("录音启动失败:", error); + throw error; + } + }, [startTimer]); + + const stopRecording = useCallback(async (): Promise => { + if (!recorderRef.current || !isRecording) return null; + + try { + const audioBlob = await recorderRef.current.stop(); + + setIsRecording(false); + setIsPaused(false); + setRecordingTime(0); + stopTimer(); + + recorderRef.current = null; + return audioBlob; + } catch (error) { + console.error("录音停止失败:", error); + return null; + } + }, [isRecording, stopTimer]); + + const pauseRecording = useCallback((): void => { + if (!recorderRef.current || !isRecording || isPaused) return; + + try { + recorderRef.current.pause(); + setIsPaused(true); + stopTimer(); + } catch (error) { + console.error("录音暂停失败:", error); + } + }, [isRecording, isPaused, stopTimer]); + + const resumeRecording = useCallback((): void => { + if (!recorderRef.current || !isRecording || !isPaused) return; + + try { + recorderRef.current.resume(); + setIsPaused(false); + startTimer(); + } catch (error) { + console.error("录音恢复失败:", error); + } + }, [isRecording, isPaused, startTimer]); + + const cancelRecording = useCallback((): void => { + if (recorderRef.current) { + recorderRef.current.cancel(); + recorderRef.current = null; + } + + setIsRecording(false); + setIsPaused(false); + setRecordingTime(0); + stopTimer(); + }, [stopTimer]); + + return { + isRecording, + recordingTime, + isPaused, + startRecording, + stopRecording, + pauseRecording, + resumeRecording, + cancelRecording, + }; +}; diff --git a/src/http/axios-instance.ts b/src/http/axios-instance.ts new file mode 100644 index 0000000..2bef525 --- /dev/null +++ b/src/http/axios-instance.ts @@ -0,0 +1,49 @@ +import Axios, { + AxiosError, + AxiosInstance as AxiosType, + AxiosResponse, + InternalAxiosRequestConfig +} from 'axios'; +import {STORAGE_AUTHORIZE_KEY} from "@/composables/authorization.ts"; + +export interface ResponseBody { + code: number; + data?: T; + msg: string; +} + +async function requestHandler(config: InternalAxiosRequestConfig): Promise { + const token = localStorage.getItem(STORAGE_AUTHORIZE_KEY); + if (token) { + config.headers[STORAGE_AUTHORIZE_KEY] = token; + } + return config; +} + +function responseHandler(response: AxiosResponse): AxiosResponse { + // 响应拦截器逻辑... + return response; +} + +function errorHandler(error: AxiosError): Promise> { + // 错误处理逻辑... + return Promise.reject(error); +} + +class AxiosInstance { + private readonly instance: AxiosType; + + constructor(baseURL: string) { + this.instance = Axios.create({baseURL}); + + this.instance.interceptors.request.use(requestHandler, errorHandler); + this.instance.interceptors.response.use(responseHandler, errorHandler); + } + + public getInstance(): AxiosType { + return this.instance; + } +} + +const baseURL = import.meta.env.VITE_BASE_URL || 'http://127.0.0.1:8080'; +export const axiosInstance = new AxiosInstance(baseURL).getInstance(); diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..06b9df1 --- /dev/null +++ b/src/index.css @@ -0,0 +1,103 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Roboto", sans-serif; +} + +a, +button, +input, +textarea { + -webkit-tap-highlight-color: transparent; +} + +html { + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-size: 100%; +} + +/* 小屏幕设备(如手机)*/ +@media (max-width: 600px) { + html { + font-size: 90%; /* 字体稍微小一点 */ + } +} + +/* 中等屏幕设备(如平板)*/ +@media (min-width: 601px) and (max-width: 1024px) { + html { + font-size: 100%; /* 标准大小 */ + } +} + +/* 大屏幕设备(如桌面)*/ +@media (min-width: 1025px) { + html { + font-size: 110%; /* 字体稍微大一点 */ + } +} + +* { + -webkit-backface-visibility: hidden; + -moz-backface-visibility: hidden; + -ms-backface-visibility: hidden; +} + +a { + text-decoration: none; + color: inherit; +} + +img { + max-width: 100%; + height: auto; +} + +.ec-navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background-color: #fff; + border-bottom: 1px solid #e5e5e5; + -webkit-transition: all 0.3s; + transition: all 0.3s; +} + +:root { + --primary-color: #ffc300; +} + +:root:root { + /* --adm-color-primary: #FFC300; + --adm-color-success: #00b578; + --adm-color-warning: #ff8f1f; + --adm-color-danger: #ff3141; + + --adm-color-white: #ffffff; + --adm-color-text: #333333; + --adm-color-text-secondary: #666666; + --adm-color-weak: #999999; + --adm-color-light: #cccccc; + --adm-color-border: #eeeeee; + --adm-color-box: #f5f5f5; + --adm-color-background: #ffffff; + + --adm-font-size-main: var(--adm-font-size-5); */ + + --adm-font-family: -apple-system, blinkmacsystemfont, "Helvetica Neue", + helvetica, segoe ui, arial, roboto, "PingFang SC", "miui", + "Hiragino Sans GB", "Microsoft Yahei", sans-serif; +} +.i-icon { + height: 100%; +} +svg { + height: 100%; +} diff --git a/src/layout/main/index.less b/src/layout/main/index.less new file mode 100644 index 0000000..fe18e79 --- /dev/null +++ b/src/layout/main/index.less @@ -0,0 +1,20 @@ +.main-layout { + height: 100%; + .layout-content { + height: 100%; + overflow-y: auto; + // padding-bottom: 150px; + padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/ + padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/ + } + + .layout-tab { + flex: 0 0 auto; + position: fixed; + bottom: 0; + width: 100%; + z-index: 1000; + background-color: #fff; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.1); + } +} diff --git a/src/layout/main/mainLayout.tsx b/src/layout/main/mainLayout.tsx new file mode 100644 index 0000000..9c18d94 --- /dev/null +++ b/src/layout/main/mainLayout.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { NavBar, SafeArea, TabBar, Toast } from "antd-mobile"; +import { useNavigate, useLocation } from "react-router-dom"; +import { + ShoppingBag, + TransactionOrder, + User, + CattleZodiac, +} from "@icon-park/react"; +import useI18n from "@/hooks/i18n.ts"; +import "./index.less"; + +interface MainLayoutProps { + children: React.ReactNode; + isShowNavBar?: boolean; + title?: string; + onLink?: () => void; +} + +const MainLayout: React.FC = ({ + isShowNavBar, + children, + onLink, + title, +}) => { + const navigate = useNavigate(); + const location = useLocation(); + const { pathname } = location; + const [activeKey, setActiveKey] = React.useState(pathname); + + const setRouteActive = (value: string) => { + if (value !== "/") { + Toast.show("待开发"); + } + }; + + const tabs = [ + { + key: "/", + title: "宠物翻译", + icon: , + }, + { + key: "/set", + title: "待办", + icon: , + }, + { + key: "/message", + title: "消息", + icon: , + }, + { + key: "/me", + title: "我的", + + icon: , + }, + ]; + + const goBack = () => { + if (onLink) { + onLink?.(); + } else { + navigate(-1); + } + }; + return ( +
+ + {isShowNavBar ? {title} : ""} +
{children}
+ +
+ {/* setRouteActive(value)} + safeArea={true} + > + {tabs.map((item) => ( + + ))} + */} +
+
+ ); +}; + +export default MainLayout; diff --git a/src/locales/en_US.json b/src/locales/en_US.json new file mode 100644 index 0000000..373a607 --- /dev/null +++ b/src/locales/en_US.json @@ -0,0 +1,3 @@ +{ + "index.title": "Hello World!" +} diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json new file mode 100644 index 0000000..fb48ba5 --- /dev/null +++ b/src/locales/zh_CN.json @@ -0,0 +1,3 @@ +{ + "index.title": "你好,世界!" +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..638931a --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "@/view/app/App.tsx"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + // + + + + // +); diff --git a/src/route/auth.tsx b/src/route/auth.tsx new file mode 100644 index 0000000..1406e73 --- /dev/null +++ b/src/route/auth.tsx @@ -0,0 +1,29 @@ +import React, {useEffect} from 'react'; +import {useNavigate} from 'react-router-dom'; + +interface AuthRouteProps { + children: React.ReactNode; + auth?: boolean; +} + +/** + * 认证路由 + * @param children 子组件 + * @param auth 是否需要认证 + * @constructor 认证路由组件 + */ +const AuthRoute: React.FC = ({children, auth}) => { + const navigate = useNavigate(); + const token = localStorage.getItem('token'); // 或者其他认证令牌的获取方式 + const isAuthenticated = Boolean(token); // 认证逻辑 + + useEffect(() => { + if (auth && !isAuthenticated) { + navigate('/login'); // 如果未认证且路由需要认证,则重定向到登录 + } + }, [auth, isAuthenticated, navigate]); + + return <>{children}; +}; + +export default AuthRoute; diff --git a/src/route/render-routes.tsx b/src/route/render-routes.tsx new file mode 100644 index 0000000..12d6537 --- /dev/null +++ b/src/route/render-routes.tsx @@ -0,0 +1,27 @@ +import {Route, Routes} from 'react-router-dom'; +import {routes, AppRoute} from './routes'; +import AuthRoute from './auth.tsx'; + +/** + * 渲染路由 + * @constructor RenderRoutes + */ +export const RenderRoutes = () => { + const renderRoutes = (routes: AppRoute[]) => { + return routes.map(route => ( + + {route.element} + + } + > + {route.children && renderRoutes(route.children)} + + )); + }; + + return {renderRoutes(routes)}; +}; diff --git a/src/route/routes.tsx b/src/route/routes.tsx new file mode 100644 index 0000000..44b59ee --- /dev/null +++ b/src/route/routes.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import Home from "@/view/home"; +import TranslateDetail from "@/view/home/detail"; +import Setting from "@/view/setting"; +import Page404 from "@/view/error/page404"; +export interface AppRoute { + path: string; + element: React.ReactNode; + auth?: boolean; + children?: AppRoute[]; +} + +export const routes: AppRoute[] = [ + { path: "/", element: , auth: false }, + { path: "/set", element: , auth: false }, + { path: "/detail", element: , auth: false }, + { path: "/mood", element: , auth: false }, + { path: "*", element: , auth: false }, +]; diff --git a/src/store/i18n.ts b/src/store/i18n.ts new file mode 100644 index 0000000..c7b7aee --- /dev/null +++ b/src/store/i18n.ts @@ -0,0 +1,21 @@ +import {create} from 'zustand' +import {persist, createJSONStorage} from 'zustand/middleware' + +export const LANG_KEY = 'lang' +export const useI18nStore = create()( + persist( + (set, get) => ({ + lang: 'en_US', + changeLanguage: (lang) => set({lang}), + toggleLanguage: () => set(() => { + return { + lang: get().lang.includes('zh') ? 'en_US' : 'zh_CN' + } + }) + }), + { + name: 'i18n-storage', + storage: createJSONStorage(() => localStorage) + }, + ), +) \ No newline at end of file diff --git a/src/store/user.ts b/src/store/user.ts new file mode 100644 index 0000000..f87b10e --- /dev/null +++ b/src/store/user.ts @@ -0,0 +1,22 @@ +import {create} from 'zustand' +import {persist, createJSONStorage} from 'zustand/middleware' + +export const useUserStore = create()( + persist( + (set) => ({ + user: { + name: '', + avatar: '', + email: '' + }, + setUser: (user: User) => set({user}), + clearUser: () => set({ + user: null + }), + }), + { + name: 'user-storage', + storage: createJSONStorage(() => localStorage) + }, + ), +) \ No newline at end of file diff --git a/src/types/chat.ts b/src/types/chat.ts new file mode 100644 index 0000000..22ec78a --- /dev/null +++ b/src/types/chat.ts @@ -0,0 +1,41 @@ +// types/chat.ts +// types/chat.ts (更新) +export interface VoiceMessage { + id: string; + type: "voice"; + content: { + duration: number; + url?: string; + localId?: string; + blob?: Blob; + waveform?: number[]; + // 新增上传相关字段 + fileId?: string; + fileName?: string; + serverUrl?: string; + uploadStatus?: "uploading" | "success" | "error"; + uploadProgress?: number; + }; + sender: "user" | "pet"; + timestamp: number; + isPlaying?: boolean; + translation?: string; + translating?: boolean; +} + +export interface TextMessage { + id: string; + type: "text"; + content: string; + sender: "user" | "pet"; + timestamp: number; +} + +export type ChatMessage = VoiceMessage | TextMessage; + +export interface PetProfile { + name: string; + avatar: string; + species: "dog" | "cat" | "bird" | "other"; + personality: string; +} diff --git a/src/types/http.d.ts b/src/types/http.d.ts new file mode 100644 index 0000000..019239c --- /dev/null +++ b/src/types/http.d.ts @@ -0,0 +1,14 @@ +interface Page { + total: number; + size: number; + current: number; + pages: number; + records: T[]; +} + +export interface Result { + success: boolean; + code: number; + message: string; + data: T; +} \ No newline at end of file diff --git a/src/types/store.d.ts b/src/types/store.d.ts new file mode 100644 index 0000000..c238023 --- /dev/null +++ b/src/types/store.d.ts @@ -0,0 +1,19 @@ +interface User { + name: string; + avatar: string; + email: string; +} + +interface UserState { + user: User | null; + setUser: (user: User) => void; + clearUser: () => void; +} + +type lang = 'zh_CN' | 'en_US' + +interface LangStore { + lang: lang; + changeLanguage: (lang: lang) => void + toggleLanguage: () => void +} \ No newline at end of file diff --git a/src/types/upload.ts b/src/types/upload.ts new file mode 100644 index 0000000..64e0400 --- /dev/null +++ b/src/types/upload.ts @@ -0,0 +1,34 @@ +// types/upload.ts +export interface UploadConfig { + url: string; + method?: "POST" | "PUT"; + headers?: Record; + fieldName?: string; + maxFileSize?: number; + allowedTypes?: string[]; +} + +export interface UploadProgress { + loaded: number; + total: number; + percentage: number; +} + +export interface UploadResponse { + success: boolean; + data?: { + fileId: string; + fileName: string; + fileUrl: string; + duration: number; + size: number; + }; + error?: string; +} + +export interface VoiceUploadStatus { + status: "idle" | "uploading" | "success" | "error"; + progress?: UploadProgress; + response?: UploadResponse; + error?: string; +} diff --git a/src/utils/amount.ts b/src/utils/amount.ts new file mode 100644 index 0000000..6db3cae --- /dev/null +++ b/src/utils/amount.ts @@ -0,0 +1,3 @@ +export function toYuan(num: number) { + return (num / 100).toFixed(2); +} \ No newline at end of file diff --git a/src/utils/audioManager.ts b/src/utils/audioManager.ts new file mode 100644 index 0000000..044ba5d --- /dev/null +++ b/src/utils/audioManager.ts @@ -0,0 +1,208 @@ +// utils/audioManager.ts +import { UniversalAudioPlayer } from "./audioPlayer"; + +export interface AudioInstance { + id: string; + player: UniversalAudioPlayer; + onStop?: () => void; + onPlay?: () => void; + onPause?: () => void; +} + +class AudioManager { + private static instance: AudioManager; + private currentAudio: AudioInstance | null = null; + private audioInstances: Map = new Map(); + + private constructor() {} + + static getInstance(): AudioManager { + if (!AudioManager.instance) { + AudioManager.instance = new AudioManager(); + } + return AudioManager.instance; + } + + // 注册音频实例 + registerAudio( + id: string, + player: UniversalAudioPlayer, + callbacks?: { + onStop?: () => void; + onPlay?: () => void; + onPause?: () => void; + } + ): void { + const audioInstance: AudioInstance = { + id, + player, + onStop: callbacks?.onStop, + onPlay: callbacks?.onPlay, + onPause: callbacks?.onPause, + }; + + this.audioInstances.set(id, audioInstance); + } + + // 注销音频实例 + unregisterAudio(id: string): void { + const audioInstance = this.audioInstances.get(id); + if (audioInstance) { + // 如果是当前播放的音频,先停止 + if (this.currentAudio?.id === id) { + this.stopCurrent(); + } + + // 销毁播放器 + audioInstance.player.destroy(); + this.audioInstances.delete(id); + } + } + + // 播放指定音频(会自动停止其他音频) + async playAudio(id: string): Promise { + const audioInstance = this.audioInstances.get(id); + if (!audioInstance) { + throw new Error(`音频实例 ${id} 不存在`); + } + + // 如果当前有其他音频在播放,先停止 + if (this.currentAudio && this.currentAudio.id !== id) { + this.stopCurrent(); + } + + try { + await audioInstance.player.play(); + this.currentAudio = audioInstance; + + // 触发播放回调 + audioInstance.onPlay?.(); + + console.log(`开始播放音频: ${id}`); + } catch (error) { + console.error(`播放音频 ${id} 失败:`, error); + throw error; + } + } + + // 暂停指定音频 + pauseAudio(id: string): void { + const audioInstance = this.audioInstances.get(id); + if (!audioInstance) { + console.warn(`音频实例 ${id} 不存在`); + return; + } + + audioInstance.player.pause(); + + // 如果是当前播放的音频,清除当前音频引用 + if (this.currentAudio?.id === id) { + this.currentAudio = null; + } + + // 触发暂停回调 + audioInstance.onPause?.(); + + console.log(`暂停音频: ${id}`); + } + + // 停止指定音频 + stopAudio(id: string): void { + const audioInstance = this.audioInstances.get(id); + if (!audioInstance) { + console.warn(`音频实例 ${id} 不存在`); + return; + } + + audioInstance.player.stop(); + + // 如果是当前播放的音频,清除当前音频引用 + if (this.currentAudio?.id === id) { + this.currentAudio = null; + } + + // 触发停止回调 + audioInstance.onStop?.(); + + console.log(`停止音频: ${id}`); + } + + // 停止当前播放的音频 + stopCurrent(): void { + if (this.currentAudio) { + this.stopAudio(this.currentAudio.id); + } + } + + // 暂停当前播放的音频 + pauseCurrent(): void { + if (this.currentAudio) { + this.pauseAudio(this.currentAudio.id); + } + } + + // 获取当前播放的音频ID + getCurrentAudioId(): string | null { + return this.currentAudio?.id || null; + } + + // 检查指定音频是否正在播放 + isPlaying(id: string): boolean { + const audioInstance = this.audioInstances.get(id); + if (!audioInstance) { + return false; + } + return audioInstance.player.getIsPlaying(); + } + + // 获取指定音频的播放时长 + getDuration(id: string): number { + const audioInstance = this.audioInstances.get(id); + if (!audioInstance) { + return 0; + } + return audioInstance.player.getDuration(); + } + + // 获取指定音频的当前播放时间 + getCurrentTime(id: string): number { + const audioInstance = this.audioInstances.get(id); + if (!audioInstance) { + return 0; + } + return audioInstance.player.getCurrentTime(); + } + + // 清理所有音频实例 + cleanup(): void { + this.audioInstances.forEach((audioInstance) => { + audioInstance.player.destroy(); + }); + this.audioInstances.clear(); + this.currentAudio = null; + } + + // 获取所有音频实例的状态 + getAudioStates(): Record< + string, + { + isPlaying: boolean; + duration: number; + currentTime: number; + } + > { + const states: Record = {}; + + this.audioInstances.forEach((audioInstance, id) => { + states[id] = { + isPlaying: audioInstance.player.getIsPlaying(), + duration: audioInstance.player.getDuration(), + currentTime: audioInstance.player.getCurrentTime(), + }; + }); + + return states; + } +} + +export default AudioManager; diff --git a/src/utils/audioPlayer.ts b/src/utils/audioPlayer.ts new file mode 100644 index 0000000..e6c3315 --- /dev/null +++ b/src/utils/audioPlayer.ts @@ -0,0 +1,246 @@ +// utils/audioPlayer.ts +export class UniversalAudioPlayer { + private audio: HTMLAudioElement | null = null; + private audioContext: AudioContext | null = null; + private source: AudioBufferSourceNode | null = null; + private audioBuffer: AudioBuffer | null = null; + private isPlaying: boolean = false; + private startTime: number = 0; + private pauseTime: number = 0; + + constructor() { + this.initAudioContext(); + } + + private async initAudioContext(): Promise { + try { + if (this.isAndroid()) { + this.audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + + // 安卓需要用户交互来启动AudioContext + if (this.audioContext.state === "suspended") { + // 等待用户交互后再启动 + document.addEventListener( + "touchstart", + this.resumeAudioContext.bind(this), + { once: true } + ); + document.addEventListener( + "click", + this.resumeAudioContext.bind(this), + { once: true } + ); + } + } + } catch (error) { + console.warn("AudioContext初始化失败:", error); + } + } + + private async resumeAudioContext(): Promise { + if (this.audioContext && this.audioContext.state === "suspended") { + try { + await this.audioContext.resume(); + console.log("AudioContext已启动"); + } catch (error) { + console.error("AudioContext启动失败:", error); + } + } + } + + private isAndroid(): boolean { + return /Android/.test(navigator.userAgent); + } + + private isIOS(): boolean { + return /iPad|iPhone|iPod/.test(navigator.userAgent); + } + + async loadAudio(audioBlob: Blob): Promise { + try { + const audioUrl = URL.createObjectURL(audioBlob); + + if (this.isAndroid() && this.audioContext) { + // 安卓使用AudioContext播放 + await this.loadWithAudioContext(audioBlob); + } else { + // iOS和桌面使用HTMLAudioElement + await this.loadWithAudioElement(audioUrl); + } + } catch (error) { + console.error("音频加载失败:", error); + throw error; + } + } + + private async loadWithAudioContext(audioBlob: Blob): Promise { + if (!this.audioContext) { + throw new Error("AudioContext未初始化"); + } + + try { + const arrayBuffer = await audioBlob.arrayBuffer(); + this.audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); + console.log("AudioContext音频加载成功"); + } catch (error) { + console.error("AudioContext音频解码失败:", error); + // 降级到HTMLAudioElement + const audioUrl = URL.createObjectURL(audioBlob); + await this.loadWithAudioElement(audioUrl); + } + } + + private async loadWithAudioElement(audioUrl: string): Promise { + return new Promise((resolve, reject) => { + this.audio = new Audio(audioUrl); + + // 安卓需要设置这些属性 + this.audio.preload = "auto"; + this.audio.controls = false; + + this.audio.addEventListener( + "canplaythrough", + () => { + console.log("HTMLAudioElement音频加载成功"); + resolve(); + }, + { once: true } + ); + + this.audio.addEventListener( + "error", + (e) => { + console.error("HTMLAudioElement音频加载失败:", e); + reject(new Error("音频加载失败")); + }, + { once: true } + ); + + // 安卓需要手动触发加载 + this.audio.load(); + }); + } + + async play(): Promise { + try { + if (this.audioBuffer && this.audioContext) { + // 使用AudioContext播放 + await this.playWithAudioContext(); + } else if (this.audio) { + // 使用HTMLAudioElement播放 + await this.playWithAudioElement(); + } else { + throw new Error("音频未加载"); + } + this.isPlaying = true; + } catch (error) { + console.error("音频播放失败:", error); + throw error; + } + } + + private async playWithAudioContext(): Promise { + if (!this.audioContext || !this.audioBuffer) { + throw new Error("AudioContext或AudioBuffer未准备好"); + } + + // 确保AudioContext已启动 + if (this.audioContext.state === "suspended") { + await this.audioContext.resume(); + } + + this.source = this.audioContext.createBufferSource(); + this.source.buffer = this.audioBuffer; + this.source.connect(this.audioContext.destination); + + this.source.onended = () => { + this.isPlaying = false; + }; + + this.startTime = this.audioContext.currentTime; + this.source.start(0); + } + + private async playWithAudioElement(): Promise { + if (!this.audio) { + throw new Error("Audio元素未准备好"); + } + + // 安卓需要重置音频位置 + this.audio.currentTime = 0; + + try { + await this.audio.play(); + } catch (error) { + // 如果自动播放失败,可能需要用户交互 + console.warn("自动播放失败,需要用户交互:", error); + throw new Error("播放失败,请点击播放按钮"); + } + } + + pause(): void { + if (this.source) { + this.source.stop(); + this.source = null; + } + + if (this.audio) { + this.audio.pause(); + } + + this.isPlaying = false; + } + + stop(): void { + this.pause(); + + if (this.audio) { + this.audio.currentTime = 0; + } + } + + getIsPlaying(): boolean { + return this.isPlaying; + } + + getDuration(): number { + if (this.audioBuffer) { + return this.audioBuffer.duration; + } + + if (this.audio) { + return this.audio.duration || 0; + } + + return 0; + } + + getCurrentTime(): number { + if (this.audioContext && this.startTime > 0) { + return this.audioContext.currentTime - this.startTime; + } + + if (this.audio) { + return this.audio.currentTime; + } + + return 0; + } + + destroy(): void { + this.stop(); + + if (this.audio) { + this.audio.src = ""; + this.audio = null; + } + + if (this.audioContext && this.audioContext.state !== "closed") { + this.audioContext.close().catch(console.error); + this.audioContext = null; + } + + this.audioBuffer = null; + } +} diff --git a/src/utils/audioRecorder.ts b/src/utils/audioRecorder.ts new file mode 100644 index 0000000..25d2027 --- /dev/null +++ b/src/utils/audioRecorder.ts @@ -0,0 +1,362 @@ +// utils/audioRecorder.ts +export interface AudioRecorderConfig { + sampleRate?: number; + channels?: number; + bitDepth?: number; +} + +export class UniversalAudioRecorder { + private mediaRecorder: MediaRecorder | null = null; + private audioChunks: BlobPart[] = []; + private stream: MediaStream | null = null; + private startTime: number = 0; + private pausedDuration: number = 0; + private pauseStartTime: number = 0; + private audioContext: AudioContext | null = null; + + constructor(private config: AudioRecorderConfig = {}) { + this.config = { + sampleRate: 44100, + channels: 1, + bitDepth: 16, + ...config, + }; + } + + // 设备检测 + private isIOS(): boolean { + return /iPad|iPhone|iPod/.test(navigator.userAgent); + } + + private isAndroid(): boolean { + return /Android/.test(navigator.userAgent); + } + + private isSafari(): boolean { + return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + } + + private isChrome(): boolean { + return ( + /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor) + ); + } + + // 获取设备支持的最佳MIME类型 + private getSupportedMimeType(): string { + const isIOSDevice = this.isIOS(); + const isAndroidDevice = this.isAndroid(); + const isSafariBrowser = this.isSafari(); + const isChromeBrowser = this.isChrome(); + + let mimeTypes: string[] = []; + + if (isAndroidDevice) { + // 安卓设备优先使用这些格式 + mimeTypes = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/mp4;codecs=aac", + "audio/mp4", + "audio/aac", + "audio/ogg;codecs=opus", + "audio/wav", + ]; + } else if (isIOSDevice || isSafariBrowser) { + // iOS和Safari优先使用这些格式 + mimeTypes = [ + "audio/mp4;codecs=aac", + "audio/mp4", + "audio/aac", + "audio/wav", + "audio/webm;codecs=opus", + "audio/webm", + ]; + } else { + // 其他浏览器(主要是桌面Chrome/Firefox) + mimeTypes = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/mp4;codecs=aac", + "audio/mp4", + "audio/ogg;codecs=opus", + "audio/wav", + ]; + } + + for (const mimeType of mimeTypes) { + if (MediaRecorder.isTypeSupported(mimeType)) { + console.log( + `选择的音频格式: ${mimeType} (设备: ${this.getDeviceInfo()})` + ); + return mimeType; + } + } + + console.warn("没有找到明确支持的音频格式,使用浏览器默认格式"); + return ""; + } + + private getDeviceInfo(): string { + if (this.isAndroid()) return "Android"; + if (this.isIOS()) return "iOS"; + return "Desktop"; + } + + // 初始化AudioContext(安卓设备需要) + private async initAudioContext(): Promise { + try { + // 安卓设备需要AudioContext来确保音频正常工作 + if (this.isAndroid() && !this.audioContext) { + this.audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + + // 安卓需要用户交互来启动AudioContext + if (this.audioContext.state === "suspended") { + await this.audioContext.resume(); + } + } + } catch (error) { + console.warn("AudioContext初始化失败:", error); + } + } + + async start(): Promise { + try { + // 初始化AudioContext + await this.initAudioContext(); + + // 获取音频约束 + const audioConstraints = this.getAudioConstraints(); + + this.stream = await navigator.mediaDevices.getUserMedia(audioConstraints); + + // 安卓设备需要特殊处理 + if (this.isAndroid() && this.audioContext && this.stream) { + // 创建音频源节点以确保音频流正常 + const source = this.audioContext.createMediaStreamSource(this.stream); + const analyser = this.audioContext.createAnalyser(); + source.connect(analyser); + } + + const selectedMimeType = this.getSupportedMimeType(); + + // 创建MediaRecorder + const mediaRecorderOptions: MediaRecorderOptions = {}; + if (selectedMimeType) { + mediaRecorderOptions.mimeType = selectedMimeType; + } + + // 安卓使用较大的时间片,iOS使用中等时间片 + const timeslice = this.isAndroid() ? 1000 : this.isIOS() ? 1000 : 100; + + this.mediaRecorder = new MediaRecorder(this.stream, mediaRecorderOptions); + + this.audioChunks = []; + this.startTime = Date.now(); + this.pausedDuration = 0; + + this.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + this.audioChunks.push(event.data); + console.log(`收到音频数据: ${event.data.size} bytes`); + } + }; + + this.mediaRecorder.onerror = (event) => { + console.error("MediaRecorder错误:", event); + }; + + this.mediaRecorder.onstart = () => { + console.log("录音开始"); + }; + + this.mediaRecorder.onstop = () => { + console.log("录音停止"); + }; + + this.mediaRecorder.start(timeslice); + console.log("录音器启动成功:", { + mimeType: this.mediaRecorder.mimeType, + state: this.mediaRecorder.state, + device: this.getDeviceInfo(), + }); + } catch (error) { + this.cleanup(); + console.error("录音启动失败:", error); + throw new Error(`录音启动失败: ${error}`); + } + } + + private getAudioConstraints(): MediaStreamConstraints { + const isAndroidDevice = this.isAndroid(); + const isIOSDevice = this.isIOS(); + + if (isAndroidDevice) { + // 安卓设备使用更宽松的约束 + return { + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + // 安卓不强制指定采样率和声道数 + }, + }; + } else if (isIOSDevice) { + // iOS设备约束 + return { + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }; + } else { + // 桌面设备可以使用更详细的约束 + return { + audio: { + sampleRate: this.config.sampleRate, + channelCount: this.config.channels, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }; + } + } + + stop(): Promise { + return new Promise((resolve, reject) => { + if (!this.mediaRecorder) { + reject(new Error("录音器未初始化")); + return; + } + + const timeout = setTimeout(() => { + reject(new Error("录音停止超时")); + }, 10000); // 增加超时时间 + + this.mediaRecorder.onstop = () => { + clearTimeout(timeout); + try { + console.log(`收集到 ${this.audioChunks.length} 个音频片段`); + + if (this.audioChunks.length === 0) { + reject(new Error("没有录制到音频数据")); + return; + } + + const mimeType = this.mediaRecorder?.mimeType || "audio/webm"; + const audioBlob = new Blob(this.audioChunks, { type: mimeType }); + + console.log("录音完成:", { + size: audioBlob.size, + type: audioBlob.type, + duration: this.getDuration(), + chunks: this.audioChunks.length, + }); + + // 验证blob是否有效 + if (audioBlob.size === 0) { + reject(new Error("录制的音频文件为空")); + return; + } + + this.cleanup(); + resolve(audioBlob); + } catch (error) { + this.cleanup(); + reject(error); + } + }; + + this.mediaRecorder.onerror = (event) => { + clearTimeout(timeout); + this.cleanup(); + reject(new Error("录音停止时发生错误")); + }; + + try { + if ( + this.mediaRecorder.state === "recording" || + this.mediaRecorder.state === "paused" + ) { + this.mediaRecorder.stop(); + } else { + clearTimeout(timeout); + reject(new Error("录音器状态异常")); + } + } catch (error) { + clearTimeout(timeout); + this.cleanup(); + reject(error); + } + }); + } + + pause(): void { + if (this.mediaRecorder && this.mediaRecorder.state === "recording") { + this.pauseStartTime = Date.now(); + this.mediaRecorder.pause(); + } + } + + resume(): void { + if (this.mediaRecorder && this.mediaRecorder.state === "paused") { + this.pausedDuration += Date.now() - this.pauseStartTime; + this.mediaRecorder.resume(); + } + } + + cancel(): void { + try { + if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") { + this.mediaRecorder.stop(); + } + } catch (error) { + console.error("取消录音时出错:", error); + } + this.cleanup(); + } + + getDuration(): number { + const currentTime = Date.now(); + const totalTime = currentTime - this.startTime - this.pausedDuration; + return Math.max(0, Math.floor(totalTime / 1000)); + } + + getState(): string { + return this.mediaRecorder?.state || "inactive"; + } + + // 获取支持的音频格式信息 + static getSupportedFormats(): string[] { + const formats = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/mp4;codecs=aac", + "audio/mp4", + "audio/aac", + "audio/wav", + "audio/ogg;codecs=opus", + "audio/ogg", + ]; + + return formats.filter((format) => MediaRecorder.isTypeSupported(format)); + } + + private cleanup(): void { + if (this.stream) { + this.stream.getTracks().forEach((track) => track.stop()); + this.stream = null; + } + + if (this.audioContext && this.audioContext.state !== "closed") { + this.audioContext.close().catch(console.error); + this.audioContext = null; + } + + this.mediaRecorder = null; + this.audioChunks = []; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..5a21bf8 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './amount'; +export * from './location'; \ No newline at end of file diff --git a/src/utils/js-audio-recorder.d.ts b/src/utils/js-audio-recorder.d.ts new file mode 100644 index 0000000..a8b6c3c --- /dev/null +++ b/src/utils/js-audio-recorder.d.ts @@ -0,0 +1,22 @@ +declare module "js-audio-recorder" { + export interface RecorderOptions { + sampleBits?: number; + sampleRate?: number; + numChannels?: number; + compiling?: boolean; + } + + export default class Recorder { + constructor(options?: RecorderOptions); + start(): Promise; + pause(): void; + resume(): void; + stop(): void; + getBlob(): Blob; + getWAVBlob(): Blob; + destroy(): void; + duration: number; + fileSize: number; + isrecording: boolean; + } +} diff --git a/src/utils/location.ts b/src/utils/location.ts new file mode 100644 index 0000000..5a997c7 --- /dev/null +++ b/src/utils/location.ts @@ -0,0 +1,3 @@ +export function toKm(m: number): number { + return m / 1000; +} \ No newline at end of file diff --git a/src/utils/voice.ts b/src/utils/voice.ts new file mode 100644 index 0000000..f67276e --- /dev/null +++ b/src/utils/voice.ts @@ -0,0 +1,186 @@ +// export interface VoiceMessage { +// id: string; +// audioUrl: string; +// audioBlob: Blob; +// duration: number; +// isPlaying: boolean; +// timestamp: number; +// type: "sent" | "received"; +// } + +// export interface WeChatVoiceRecorderProps { +// onSendVoice?: (voiceData: VoiceMessage) => void; +// maxDuration?: number; +// className?: string; +// } + +// export interface RecordingState { +// isRecording: boolean; +// isPaused: boolean; +// duration: number; +// hasPermission: boolean; +// } +export const mockTranslateAudio = async (): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + const mockTranslations = [ + "汪汪汪!我饿了,想吃东西!🍖", + "喵喵~我想要抱抱!🤗", + "我想出去玩耍!🎾", + "我很开心!😊", + "我有点害怕...😰", + "我想睡觉了~😴", + "主人,陪我玩一会儿吧!🎮", + "我想喝水了💧", + "外面有什么声音?👂", + "我爱你,主人!❤️", + ]; + const randomTranslation = + mockTranslations[Math.floor(Math.random() * mockTranslations.length)]; + resolve(randomTranslation); + }, 2000 + Math.random() * 2000); + }); +}; +// 创建发送音效 - 清脆的"叮"声 +export const createSendSound = () => { + try { + const audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + // 设置音调 - 清脆的高音 + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); + oscillator.frequency.exponentialRampToValueAtTime( + 1200, + audioContext.currentTime + 0.1 + ); + + // 设置音量包络 + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.01); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.3 + ); + + oscillator.type = "sine"; + + // 创建音频元素 + const audio = new Audio(); + + // 重写play方法来播放合成音效 + audio.play = () => { + return new Promise((resolve) => { + try { + const newOscillator = audioContext.createOscillator(); + const newGainNode = audioContext.createGain(); + + newOscillator.connect(newGainNode); + newGainNode.connect(audioContext.destination); + + newOscillator.frequency.setValueAtTime(800, audioContext.currentTime); + newOscillator.frequency.exponentialRampToValueAtTime( + 1200, + audioContext.currentTime + 0.1 + ); + + newGainNode.gain.setValueAtTime(0, audioContext.currentTime); + newGainNode.gain.linearRampToValueAtTime( + 0.3, + audioContext.currentTime + 0.01 + ); + newGainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.3 + ); + + newOscillator.type = "sine"; + newOscillator.start(audioContext.currentTime); + newOscillator.stop(audioContext.currentTime + 0.3); + + setTimeout(() => resolve(undefined), 300); + } catch (error) { + console.error("播放发送音效失败:", error); + resolve(undefined); + } + }); + }; + + return audio; + } catch (error) { + console.error("创建发送音效失败:", error); + return new Audio(); // 返回空音频对象 + } +}; +export const createStartRecordSound = () => { + try { + const audio = new Audio(); + + audio.play = () => { + return new Promise((resolve) => { + try { + const audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + // 设置音调 - 低沉的音 + oscillator.frequency.setValueAtTime(400, audioContext.currentTime); + + // 设置音量包络 + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime( + 0.2, + audioContext.currentTime + 0.05 + ); + gainNode.gain.linearRampToValueAtTime( + 0, + audioContext.currentTime + 0.2 + ); + + oscillator.type = "sine"; + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.2); + + setTimeout(() => resolve(undefined), 200); + } catch (error) { + console.error("播放录音音效失败:", error); + resolve(undefined); + } + }); + }; + + return audio; + } catch (error) { + console.error("创建录音音效失败:", error); + return new Audio(); + } +}; + +export const getAudioDuration = async (audioBlob: Blob): Promise => { + return new Promise((resolve, reject) => { + const AudioContextClass = + window.AudioContext || (window as any).webkitAudioContext; + const audioContext = new AudioContextClass(); + + const fileReader = new FileReader(); + fileReader.onload = async (e) => { + try { + const arrayBuffer = e.target?.result as ArrayBuffer; + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + const duration = audioBuffer.duration; + resolve(duration); + } catch (error) { + reject(error); + } + }; + fileReader.readAsArrayBuffer(audioBlob); + }); +}; diff --git a/src/view/app/App.css b/src/view/app/App.css new file mode 100644 index 0000000..e69de29 diff --git a/src/view/app/App.tsx b/src/view/app/App.tsx new file mode 100644 index 0000000..58c3075 --- /dev/null +++ b/src/view/app/App.tsx @@ -0,0 +1,24 @@ +import "./App.css"; +import { RenderRoutes } from "@/route/render-routes.tsx"; +import { axiosInstance } from "@/http/axios-instance.ts"; +import { configure } from "axios-hooks"; +import enUS from "antd-mobile/es/locales/en-US"; +import { ConfigProvider } from "antd-mobile"; +import { useI18nStore } from "@/store/i18n.ts"; +import zhCN from "antd-mobile/es/locales/zh-CN"; + +function App() { + configure({ + axios: axiosInstance, + }); + const i18nStore = useI18nStore(); + return ( + <> + + + + + ); +} + +export default App; diff --git a/src/view/archives/index.less b/src/view/archives/index.less new file mode 100644 index 0000000..e69de29 diff --git a/src/view/archives/index.tsx b/src/view/archives/index.tsx new file mode 100644 index 0000000..bbfd98e --- /dev/null +++ b/src/view/archives/index.tsx @@ -0,0 +1,14 @@ +// 档案 +import MainLayout from "@/layout/main/mainLayout"; +import { Button, Tabs } from "antd-mobile"; +import "./index.less"; + +function Index() { + return ( + +
+
+ ); +} + +export default Index; diff --git a/src/view/error/page404.tsx b/src/view/error/page404.tsx new file mode 100644 index 0000000..c9032f8 --- /dev/null +++ b/src/view/error/page404.tsx @@ -0,0 +1,18 @@ +import MainLayout from "@/layout/main/mainLayout"; +import { Button } from "antd-mobile"; +import { useNavigate } from "react-router-dom"; +function Index() { + const navigate = useNavigate(); + const goHome = () => { + navigate("/"); + }; + return ( + + + + ); +} + +export default Index; diff --git a/src/view/home/component/index.less b/src/view/home/component/index.less new file mode 100644 index 0000000..e69de29 diff --git a/src/view/home/component/message/index.less b/src/view/home/component/message/index.less new file mode 100644 index 0000000..3d6e700 --- /dev/null +++ b/src/view/home/component/message/index.less @@ -0,0 +1,46 @@ +.message { + flex: 1 auto; + overflow-y: auto; + padding: 12px; + .item { + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + display: flex; + gap: 12px; + margin-bottom: 20px; + } + .avatar { + width: 40px; + height: 40px; + border-radius: 40px; + background: #eee; + } + .voice-container { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(0, 0, 0, 0.02); + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 8px; + margin-top: 8px; + padding-right: 12px; + .time { + color: rgba(0, 0, 0, 0.88); + } + .tips { + // align-self: stretch; + // display: grid; + // align-items: center; + color: rgba(0, 0, 0, 0.45); + } + } + .translate { + display: flex; + gap: 12px; + padding: 12px; + align-items: center; + background: rgba(0, 0, 0, 0.02); + border-radius: 8px; + margin-top: 12px; + } +} diff --git a/src/view/home/component/message/index.tsx b/src/view/home/component/message/index.tsx new file mode 100644 index 0000000..aae218e --- /dev/null +++ b/src/view/home/component/message/index.tsx @@ -0,0 +1,176 @@ +import { Divider, Image, SpinLoading, Toast } from "antd-mobile"; +import VoiceIcon from "@/component/voiceIcon"; +import { Message } from "../../types"; +import dogSvg from "@/assets/translate/dog.svg"; +import catSvg from "@/assets/translate/cat.svg"; +import pigSvg from "@/assets/translate/pig.svg"; +import "./index.less"; +import { useEffect, useRef, useState } from "react"; + +interface DefinedProps { + data: Message[]; + isRecording: boolean; +} + +function Index(props: DefinedProps) { + const { data, isRecording } = props; + const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({}); + const [isPlaying, setIsPlating] = useState(false); + const [currentPlayingId, setCurrentPlayingId] = useState(); + + useEffect(() => { + if (isRecording) { + stopAllAudio(); + } + }, [isRecording]); + const onVoiceChange = () => { + setIsPlating(!isPlaying); + }; + const playAudio = (messageId: number, audioUrl: string) => { + if (isRecording) { + Toast.show("录音中,无法播放音频"); + return; + } + + if (currentPlayingId === messageId) { + if (audioRefs.current[messageId]) { + audioRefs.current[messageId].pause(); + audioRefs.current[messageId].currentTime = 0; + } + setCurrentPlayingId(undefined); + setIsPlating(false); + return; + } + + stopAllAudio(); + if (!audioRefs.current[messageId]) { + audioRefs.current[messageId] = new Audio(audioUrl); + } + + const audio = audioRefs.current[messageId]; + audio.currentTime = 0; + + audio.onended = () => { + setCurrentPlayingId(undefined); + setIsPlating(false); + }; + + audio.onerror = (error) => { + console.error("音频播放错误:", error); + Toast.show("音频播放失败"); + setIsPlating(false); + }; + + audio + .play() + .then(() => { + setCurrentPlayingId(messageId); + setIsPlating(true); + }) + .catch((error) => { + console.error("音频播放失败:", error); + Toast.show("音频播放失败"); + }); + }; + const stopAllAudio = () => { + if (currentPlayingId && audioRefs.current[currentPlayingId]) { + audioRefs.current[currentPlayingId].pause(); + audioRefs.current[currentPlayingId].currentTime = 0; + setIsPlating(false); + setCurrentPlayingId(undefined); + } + + Object.values(audioRefs.current).forEach((audio) => { + if (!audio.paused) { + audio.pause(); + audio.currentTime = 0; + } + }); + }; + const renderAvatar = (type?: "pig" | "cat" | "dog") => { + if (type === "pig") { + ; + } + if (type === "cat") { + return ( + + ); + } + return ( + + ); + }; + + return ( +
+ {data.map((item, index) => ( +
playAudio(item.id, item.audioUrl)} + > + {renderAvatar(item.type)} +
+
+ {item.name} + + {item.timestamp} +
+
+ +
{item.duration}''
+
+ {item.isTranslating ? ( +
+ + 翻译中... +
+ ) : ( +
{item.translatedText}
+ )} +
+
+ ))} + +
+
+
+
+ 生无可恋喵 + + 15:00 +
+
+ +
+ {isRecording ? "录制中..." : "轻点麦克风录制"} +
+
+
+
+
+ ); +} + +export default Index; diff --git a/src/view/home/component/search/index.less b/src/view/home/component/search/index.less new file mode 100644 index 0000000..7289ace --- /dev/null +++ b/src/view/home/component/search/index.less @@ -0,0 +1,22 @@ +.search { + display: flex; + gap: 12px; + width: 100%; + .adm-search-bar-input-box-icon { + display: flex; + align-items: center; + } + .adm-search-bar { + flex: 1 auto; + } + .all { + height: 34px !important; + background: var(--adm-color-fill-content); + border-radius: 6px; + padding: 0px 12px; + display: flex; + align-items: center; + color: rgba(0, 0, 0, 0.88); + gap: 6px; + } +} diff --git a/src/view/home/component/search/index.tsx b/src/view/home/component/search/index.tsx new file mode 100644 index 0000000..d371724 --- /dev/null +++ b/src/view/home/component/search/index.tsx @@ -0,0 +1,87 @@ +import { DownOne } from "@icon-park/react"; +import { + ActionSheet, + Dropdown, + type DropdownRef, + Popup, + Radio, + SearchBar, + Space, +} from "antd-mobile"; +import { RadioValue } from "antd-mobile/es/components/radio"; +import { useRef, useState } from "react"; + +interface PropsConfig { + handleAllAni: () => void; +} +const allAni = ["全部宠物", "丑丑", "胖胖", "可可"]; +function Index(props: PropsConfig) { + const [aniName, setAniName] = useState("全部宠物"); + const animenuRef = useRef(null); + const handleAniSelect = (val: RadioValue) => { + setAniName(allAni[val as number]); + animenuRef.current?.close(); + }; + + return ( +
+ + + +
+ + + + 全部宠物 + + + 丑丑 + + + 胖胖 + + + 可可 + + + +
+
+
+ {/*
+ 全部宠物 + +
+ setVisible(false)} + /> */} + {/* { + setVisible(false); + }} + > +
+ 宠物 +
+ 11111 +
*/} +
+ ); +} + +export default Index; diff --git a/src/view/home/component/voice/index.less b/src/view/home/component/voice/index.less new file mode 100644 index 0000000..2cfcb68 --- /dev/null +++ b/src/view/home/component/voice/index.less @@ -0,0 +1,54 @@ +.voice-record { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 12px 0px; + box-shadow: 1px 2px 4px 3px #eee; + .adm-progress-circle-info { + height: 32px; + } + .isRecording { + .adm-progress-circle { + border-radius: 50%; + animation: recordingButtonPulse 1s infinite; + } + } + .tips { + color: #9f9f9f; + } + .circle { + display: inline-block; + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(22, 119, 255, 1); + } + + .cancleBtn { + position: absolute; + border: 2px solid rgba(230, 244, 255, 1); + width: 72px; + height: 72px; + right: 60px; + border-radius: 50%; + top: 50%; + transform: translateY(-50%); + } + + @keyframes recordingButtonPulse { + 0% { + box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.5); + } + 30% { + box-shadow: 0 0 0 14px rgba(255, 77, 79, 0); + } + 70% { + box-shadow: 0 0 0 14px rgba(255, 77, 79, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.5); + } + } +} diff --git a/src/view/home/component/voice/index.tsx b/src/view/home/component/voice/index.tsx new file mode 100644 index 0000000..196ec00 --- /dev/null +++ b/src/view/home/component/voice/index.tsx @@ -0,0 +1,277 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { AudioRecorder, useAudioRecorder } from "react-audio-voice-recorder"; +import { Button, Dialog, Image, ProgressCircle, Toast } from "antd-mobile"; +import microphoneSvg from "@/assets/translate/microphone.svg"; +import microphoneDisabledSvg from "@/assets/translate/microphoneDisabledSvg.svg"; +import { createStartRecordSound, createSendSound } from "@/utils/voice"; +import "./index.less"; +import { Message } from "../../types"; +import { CloseCircleOutline } from "antd-mobile-icons"; + +interface DefinedProps { + onRecordingComplete: (url: string, finalDuration: number) => void; + isRecording: boolean; + onSetIsRecording: (flag: boolean) => void; +} +function Index(props: DefinedProps) { + const { isRecording } = props; + const [hasPermission, setHasPermission] = useState(false); //是否有权限 + const [isPermissioning, setIsPermissioning] = useState(true); //获取权限中 + const [recordingDuration, setRecordingDuration] = useState(0); //录音时长进度 + const [isModal, setIsModal] = useState(false); + const recordingTimerRef = useRef(); + const isCancelledRef = useRef(false); + const recordingStartTimeRef = useRef(0); //录音时长 + // 音效相关 + const sendSoundRef = useRef(null); + const startRecordSoundRef = useRef(null); + useEffect(() => { + initializeSounds(); + checkMicrophonePermission(); + }, [hasPermission]); + + useEffect(() => { + if (isRecording) { + recorderControls.startRecording(); + } else { + } + }, [isRecording]); + + //重置状态 + const onResetRecordingState = () => { + props.onSetIsRecording(false); + setRecordingDuration(0); + recordingStartTimeRef.current = 0; + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = undefined; + } + }; + + const initializeSounds = () => { + try { + // 发送音效 - 使用Web Audio API生成 + sendSoundRef.current = createSendSound(); + + // 开始录音音效 + startRecordSoundRef.current = createStartRecordSound(); + + console.log("音效初始化完成"); + } catch (error) { + console.error("音效初始化失败:", error); + } + }; + + const renderBtn = useCallback(() => { + if (!hasPermission) { + //没有权限 + return ( + <> + + {isPermissioning ? ( +
获取麦克风权限中...
+ ) : ( +
麦克风没有开启权限
+ )} + + ); + } + if (isRecording) { + //正在录音中 + return ( +
+ +
+ +
+
+ +
+ ); + } else { + //麦克风状态 + return ( + + ); + } + }, [hasPermission, isRecording, recordingDuration]); + const checkMicrophonePermission = useCallback(async () => { + try { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setHasPermission(false); + setIsPermissioning(false); + Toast.show("浏览器不支持录音功能"); + return false; + } + + if (navigator.permissions && navigator.permissions.query) { + try { + const permissionStatus = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + + if (permissionStatus.state === "denied") { + setHasPermission(false); + setIsPermissioning(false); + setIsModal(true); + + return false; + } + if (permissionStatus.state === "granted") { + setHasPermission(true); + setIsModal(false); + return true; + } + } catch (permError) { + console.log("权限查询不支持,继续使用getUserMedia检查"); + } + } + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + stream.getTracks().forEach((track) => track.stop()); + setHasPermission(true); + return true; + } catch (error: any) { + if (error.message.includes("user denied permission")) { + setIsModal(true); + } + + setHasPermission(false); + return false; + } + }, []); + + useEffect(() => { + if (isModal) { + Dialog.confirm({ + content: "重新获取麦克风权限", + onConfirm: async () => { + setIsModal(true); + await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + setHasPermission(true); + }, + }); + } + }, [isModal]); + + const recorderControls = useAudioRecorder( + { + noiseSuppression: true, + echoCancellation: true, + autoGainControl: true, + }, + (err) => { + console.error("录音错误:", err); + Toast.show("录音失败,请重试"); + onResetRecordingState(); + } + ); + + // 播放音效 + const playSound = async (soundRef: React.RefObject) => { + try { + if (soundRef.current) { + await soundRef.current.play(); + } + } catch (error: any) { + Toast.show(`播放音效失败:${error.message}`); + } + }; + + //开始录音 + const onStartRecording = () => { + isCancelledRef.current = false; + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = undefined; + } + props.onSetIsRecording(true); + // recorderControls.startRecording(); + recordingStartTimeRef.current = Date.now(); + // 立即开始计时 + recordingTimerRef.current = setInterval(() => { + setRecordingDuration((prev) => prev + 1); + }, 1000); + }; + const onStopRecording = useCallback(() => { + recorderControls.stopRecording(); + onResetRecordingState(); + }, [recorderControls, recordingDuration]); + //录音完成 + // 在发送时检查录音时长 + const onRecordingComplete = useCallback( + (blob: Blob) => { + if (isCancelledRef.current) { + Toast.show("已取消"); + return; + } + // 检查blob有效性 + if (!blob || blob.size === 0) { + Toast.show("录音数据无效,请重新录音"); + + return; + } + const audioUrl = URL.createObjectURL(blob); + const audio = new Audio(); + audio.src = audioUrl; + // 计算实际录音时长 + + audio.addEventListener("loadedmetadata", () => { + if (audio.duration < 1) { + Toast.show("录音时间太短,请重新录音"); + return; + } + alert(audio.duration); + playSound(sendSoundRef); + props.onRecordingComplete?.(audioUrl, Math.floor(audio.duration)); + }); + }, + [isCancelledRef, isRecording, sendSoundRef] + ); + + const cancelRecording = useCallback(() => { + isCancelledRef.current = true; + recorderControls.stopRecording(); + onResetRecordingState(); + }, []); + + return ( + <> + +
{renderBtn()}
+ + ); +} + +export default React.memo(Index); diff --git a/src/view/home/detail.tsx b/src/view/home/detail.tsx new file mode 100644 index 0000000..2717d85 --- /dev/null +++ b/src/view/home/detail.tsx @@ -0,0 +1,5 @@ +function Index() { + return
; +} + +export default Index; diff --git a/src/view/home/index.less b/src/view/home/index.less new file mode 100644 index 0000000..e32b8cc --- /dev/null +++ b/src/view/home/index.less @@ -0,0 +1,39 @@ +.home { + height: 100%; + overflow: hidden; + .adm-tabs { + display: flex; + flex-direction: column; + height: 100%; + } + .adm-tabs-content { + flex: 1; + overflow: hidden; + padding: 0px; + } + .adm-tabs-header { + border: 0 none; + position: sticky; + top: 0px; + background: #fff; + z-index: 99; + } + + .adm-tabs-tab { + font-size: 20px; + color: rgba(0, 0, 0, 0.25); + font-weight: 600; + &.adm-tabs-tab-active { + color: #000; + } + } + .adm-tabs-tab-line { + height: 0px; + } + + .translate-container { + display: flex; + flex-direction: column; + height: 100%; + } +} diff --git a/src/view/home/index.tsx b/src/view/home/index.tsx new file mode 100644 index 0000000..41f03b8 --- /dev/null +++ b/src/view/home/index.tsx @@ -0,0 +1,31 @@ +import MainLayout from "@/layout/main/mainLayout"; +import { Button, Tabs } from "antd-mobile"; +import Translate from "./translate"; +import "./index.less"; + +function Index() { + const handleRecordComplete = (audioData: AudioData): void => { + console.log("录音完成:", audioData); + }; + + const handleError = (error: Error): void => { + console.error("录音错误:", error); + }; + + return ( + +
+ + + + + + 2 + + +
+
+ ); +} + +export default Index; diff --git a/src/view/home/translate.tsx b/src/view/home/translate.tsx new file mode 100644 index 0000000..77a928e --- /dev/null +++ b/src/view/home/translate.tsx @@ -0,0 +1,154 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import FloatingMenu, { FloatMenuItemConfig } from "@/component/floatingMenu"; +import { Image, Toast } from "antd-mobile"; +import MessageCom from "./component/message"; +import VoiceRecord from "./component/voice"; +import XPopup from "@/component/xpopup"; +import type { Message } from "./types"; + +import { mockTranslateAudio } from "@/utils/voice"; +import dogSvg from "@/assets/translate/dog.svg"; +import catSvg from "@/assets/translate/cat.svg"; +import pigSvg from "@/assets/translate/pig.svg"; +import { MoreTwo } from "@icon-park/react"; +interface DefinedProps {} +const menuItems: FloatMenuItemConfig[] = [ + { icon: , type: "dog" }, + { icon: , type: "cat" }, + { icon: , type: "pig" }, + { + icon: ( + + ), + type: "add", + }, +]; +function Index(props: DefinedProps) { + // const data: Message[] = [ + // { + // id: 1, + // audioUrl: "", + // duration: 0, + // translatedText: "", + // isTranslating: true, + // isPlaying: false, + // timestamp: 0, + // }, + // ]; + const [currentPlayingId, setCurrentPlayingId] = useState(null); //当前播放id + const [messages, setMessages] = useState([]); + const [isRecording, setIsRecording] = useState(false); //是否录音中 + const [currentLanguage, setCurrentLanguage] = useState(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + setCurrentLanguage(menuItems[0]); + }, []); + + //完成录音 + const onRecordingComplete = useCallback( + (audioUrl: string, actualDuration: number) => { + const newMessage: Message = { + id: Date.now(), + type: "dog", + audioUrl, + name: "生无可恋喵", + duration: actualDuration, + timestamp: Date.now(), + isTranslating: true, + }; + + setMessages((prev) => [...prev, newMessage]); + setTimeout(() => { + onTranslateAudio(newMessage.id); + }, 1000); + + Toast.show("语音已发送"); + }, + [messages] + ); + + //翻译 + const onTranslateAudio = useCallback( + async (messageId: number) => { + try { + const translatedText = await mockTranslateAudio(); + + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { ...msg, translatedText, isTranslating: false } + : msg + ) + ); + } catch (error) { + console.error("翻译失败:", error); + Toast.show("翻译失败,请重试"); + + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { + ...msg, + isTranslating: false, + translatedText: "翻译失败,请重试", + } + : msg + ) + ); + } + }, + [messages] + ); + + const onSetIsRecording = (flag: boolean) => { + setIsRecording(flag); + }; + + const onLanguage = (item: FloatMenuItemConfig) => { + if (item.type === "add") { + setVisible(true); + } else { + setCurrentLanguage(item); + } + }; + + return ( +
+ + + + { + setVisible(false); + }} + > +
+ 快捷选项 +
+
+
+ 宠物语种 +
+
+
+ ); +} + +export default Index; diff --git a/src/view/home/types.ts b/src/view/home/types.ts new file mode 100644 index 0000000..9e0d223 --- /dev/null +++ b/src/view/home/types.ts @@ -0,0 +1,12 @@ +export interface Message { + id: number; + type?: "dog" | "cat" | "pig"; + audioUrl: string; + name: string; //名字 + duration: number; //时长 + timestamp: number; //时间 + translatedText?: string; + isTranslating?: boolean; + avatar?: string; + isPlaying?: boolean; +} diff --git a/src/view/setting/index.tsx b/src/view/setting/index.tsx new file mode 100644 index 0000000..b7cbbd5 --- /dev/null +++ b/src/view/setting/index.tsx @@ -0,0 +1,13 @@ +import useI18n from "@/hooks/i18n.ts"; +import MainLayout from "@/layout/main/mainLayout"; +import { Button } from "antd-mobile"; +import { useI18nStore } from "@/store/i18n.ts"; + +function Index() { + const t = useI18n(); + const i18nStore = useI18nStore(); + + return qq; +} + +export default Index; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..62e4f5d --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BASE_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4a6e1df --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + // "jsx": "react", + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b2e4138 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; +import basicSsl from "@vitejs/plugin-basic-ssl"; +export default defineConfig({ + // plugins: [react()], + plugins: [react(), basicSsl()], + server: { + https: {}, + port: 3000, + host: "0.0.0.0", + open: true, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "~": "/src", + }, + }, +});