Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bffed8bb8a | ||
|
|
ff3c0ab7ca | ||
|
|
6d0addf005 | ||
|
|
1520874432 | ||
|
|
eaade698fb | ||
|
|
b4f4995ff9 | ||
|
|
76a7ac04c9 | ||
|
|
3e1765a26d | ||
|
|
95b80f6bb1 | ||
|
|
caf5f3f04c | ||
|
|
41b8fac3aa | ||
|
|
f6f240bc4b | ||
|
|
a79d8d61e3 | ||
|
|
a5e748f775 | ||
|
|
0fc4edc431 | ||
|
|
3378d2cd8f | ||
|
|
e004a85f53 | ||
|
|
91cd843cfe | ||
|
|
880b669877 | ||
|
|
e53ba28c65 | ||
|
|
bdb6d3c093 | ||
|
|
9af77d0f47 | ||
|
|
15291bfcf2 | ||
|
|
ae228f3b86 | ||
|
|
b8803f92e7 | ||
|
|
1515784700 | ||
|
|
06e2909fda | ||
|
|
9012deeae7 | ||
|
|
31657e1dc0 | ||
|
|
5a08a41cc8 | ||
|
|
3e48d7e331 | ||
|
|
5993470d3f | ||
|
|
18acec1a81 | ||
|
|
701936326d | ||
|
|
5b7d613a35 | ||
|
|
0ce55ff7a4 | ||
|
|
595edb6f80 | ||
|
|
d4b75c5d99 | ||
|
|
1988bced60 | ||
|
|
60f8dbf418 | ||
|
|
a708382cfd | ||
|
|
1d95e1420a | ||
|
|
e1bcff82e2 | ||
|
|
216f1b701f | ||
|
|
0ea18d87a3 | ||
|
|
6a79d57a93 | ||
|
|
31f596f96c | ||
|
|
7f99895116 | ||
|
|
789d756ce1 | ||
|
|
570fb29d3c | ||
|
|
d7dc636d3f | ||
|
|
67d3a8f94f | ||
|
|
7d7b8ddfb1 | ||
|
|
74826c1c59 | ||
|
|
d138e1dcef | ||
|
|
372dc8ecb0 | ||
|
|
e8bfe9850e | ||
|
|
ad50fbe3c9 | ||
|
|
61636dd085 | ||
|
|
5ea669be5a | ||
|
|
5260b62287 | ||
|
|
984a84edc1 | ||
|
|
32548878e4 | ||
|
|
093db56c98 | ||
|
|
a17f8518d6 | ||
|
|
e9e9c5355d | ||
|
|
61ee21d627 | ||
|
|
a86707f1ff | ||
|
|
be42d80061 | ||
|
|
16da6f9696 | ||
|
|
427b0aa197 | ||
|
|
a2432d45cc | ||
|
|
f62b5d4f22 | ||
|
|
001225c27b | ||
|
|
3e68f188fa | ||
|
|
2d75eb76f1 | ||
|
|
bcc3ebb6bb | ||
|
|
52c4e65686 | ||
|
|
44a5afb8a2 | ||
|
|
3057d35111 | ||
|
|
bdf99bc554 | ||
|
|
e70415690c | ||
|
|
9b5e4eb9b7 | ||
|
|
e2324d3c37 | ||
|
|
c395c31e46 | ||
|
|
94662e17ba | ||
|
|
0bfa5cf723 | ||
|
|
d2f29f335e | ||
|
|
4423cb85c2 | ||
|
|
46fb997b52 | ||
|
|
dcf8c134a2 | ||
|
|
7c0df908e9 | ||
|
|
7bb76d7ced | ||
|
|
1cfb8de3a0 | ||
|
|
768d2b82d6 | ||
|
|
7a4431d80c | ||
|
|
7e63e1d607 | ||
|
|
bda6c1e3ed | ||
|
|
a9d116df2f | ||
|
|
e7c6c32d40 | ||
|
|
cdfe24fcdb | ||
|
|
a9199171df | ||
|
|
76b9542507 | ||
|
|
d30b6a8dcf | ||
|
|
76b5ad1c99 | ||
|
|
7840e83ada | ||
|
|
c4ac9da3e5 | ||
|
|
311e5d5cfd | ||
|
|
a1f6795d44 | ||
|
|
f8a30ce261 | ||
|
|
ccfd15bda0 | ||
|
|
a9610e6ee7 | ||
|
|
733b36b869 | ||
|
|
70c3f9f929 | ||
|
|
210bda15b8 | ||
|
|
93be1b3443 | ||
|
|
533a001aeb | ||
|
|
0b57d78732 | ||
|
|
dce9d1a1e2 | ||
|
|
1cff39cec4 | ||
|
|
c7392dcf5a | ||
|
|
3eb39ebef2 | ||
|
|
041d8d5643 | ||
|
|
1c19046442 | ||
|
|
3d696aaa64 | ||
|
|
76516336af | ||
|
|
fc18677409 | ||
|
|
029d3730a8 | ||
|
|
8509e62114 | ||
|
|
6e8f9ca22e | ||
|
|
dbc1a515d2 | ||
|
|
70221e7f62 | ||
|
|
28f921c38d | ||
|
|
30a773601f | ||
|
|
6cddd5c704 | ||
|
|
709bc12b1c | ||
|
|
171892a894 | ||
|
|
535ab315d9 | ||
|
|
de28247b7e | ||
|
|
6186eb753a | ||
|
|
a18bc85a9b | ||
|
|
806e4c398d | ||
|
|
a4f1abfa42 | ||
|
|
f2efd9718e | ||
|
|
031c56e30b | ||
|
|
994715d9eb | ||
|
|
90f7af7afa | ||
|
|
2f3c466833 | ||
|
|
d742bca205 | ||
|
|
8e51d52155 | ||
|
|
a1c4f72470 | ||
|
|
2c11bba25e | ||
|
|
42a5b4fae7 | ||
|
|
a61aedc906 | ||
|
|
7b72d517ab | ||
|
|
1c43295fbc | ||
|
|
747c432ea3 | ||
|
|
235bdba42a | ||
|
|
b3f603732e | ||
|
|
6091eae7fc | ||
|
|
ef36ad1ec1 | ||
|
|
f832611d41 | ||
|
|
4d50eeb830 | ||
|
|
abf1fa0ef4 | ||
|
|
903c0e51ee | ||
|
|
ce6a711c8e | ||
|
|
095fe72db0 | ||
|
|
1b6cd01eaf | ||
|
|
ac1e19f621 | ||
|
|
faf2890695 | ||
|
|
75f0773247 | ||
|
|
8b81b918cc | ||
|
|
04746961f2 |
@@ -175,3 +175,11 @@ uploadfile
|
|||||||
*.json
|
*.json
|
||||||
.vscode
|
.vscode
|
||||||
.cursor
|
.cursor
|
||||||
|
|
||||||
|
!package.json
|
||||||
|
!**/package.json
|
||||||
|
|
||||||
|
# Do not ignore env and json files inside manager-mobile
|
||||||
|
!main/manager-mobile/**/env/
|
||||||
|
!main/manager-mobile/**/.env*
|
||||||
|
!main/manager-mobile/**/*.json
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
By Professor Siyuan Liu Research and Development Group ( South China University of Technology)
|
Spearheaded by Professor Siyuan Liu's Team (South China University of Technology)
|
||||||
</br>
|
</br>
|
||||||
刘思源教授团队研发(华南理工大学)
|
刘思源教授团队主导研发(华南理工大学)
|
||||||
</br>
|
</br>
|
||||||
<img src="./docs/images/hnlg.jpg" alt="华南理工大学" width="50%">
|
<img src="./docs/images/hnlg.jpg" alt="华南理工大学" width="50%">
|
||||||
</p>
|
</p>
|
||||||
@@ -57,9 +57,9 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||||
<picture>
|
<picture>
|
||||||
<img alt="小智esp32连接自己的后台模型" src="docs/images/demo1.png" />
|
<img alt="小智医疗救护车场景" src="docs/images/demo1.png" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -94,7 +94,7 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||||
<picture>
|
<picture>
|
||||||
<img alt="自定义音色" src="docs/images/demo6.png" />
|
<img alt="自定义音色" src="docs/images/demo6.png" />
|
||||||
</picture>
|
</picture>
|
||||||
@@ -191,11 +191,13 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
|||||||
| **最简化安装** | 智能对话、IOT、MCP、视觉感知 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
|
| **最简化安装** | 智能对话、IOT、MCP、视觉感知 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
|
||||||
| **全模块安装** | 智能对话、IOT、MCP接入点、声纹识别、视觉感知、OTA、智控台 | 完整功能体验,数据存储在数据库 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
| **全模块安装** | 智能对话、IOT、MCP接入点、声纹识别、视觉感知、OTA、智控台 | 完整功能体验,数据存储在数据库 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||||
|
|
||||||
|
常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
|
||||||
|
|
||||||
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
|
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据,
|
||||||
|
|
||||||
```
|
```
|
||||||
智控台地址: https://2662r3426b.vicp.fun
|
智控台地址: https://2662r3426b.vicp.fun
|
||||||
|
智控台(h5版): https://2662r3426b.vicp.fun/h5/index.html
|
||||||
|
|
||||||
服务测试工具: https://2662r3426b.vicp.fun/test/
|
服务测试工具: https://2662r3426b.vicp.fun/test/
|
||||||
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||||
@@ -214,21 +216,22 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
|
|
||||||
| 模块名称 | 入门全免费设置 | 流式配置 |
|
| 模块名称 | 入门全免费设置 | 流式配置 |
|
||||||
|:---:|:---:|:---:|
|
|:---:|:---:|:---:|
|
||||||
| ASR(语音识别) | FunASR(本地) | 👍FunASRServer 或 👍DoubaoStreamASR |
|
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) |
|
||||||
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍DoubaoLLM(火山doubao-1-5-pro-32k-250115) |
|
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) |
|
||||||
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) |
|
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) |
|
||||||
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) |
|
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) |
|
||||||
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
|
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
|
||||||
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
||||||
|
|
||||||
|
如果您关心各组件的耗时,请查阅[小智各组件性能测试报告](https://github.com/xinnan-tech/xiaozhi-performance-research),可按报告中的测试方法在您的环境中实际测试。
|
||||||
|
|
||||||
#### 🔧 测试工具
|
#### 🔧 测试工具
|
||||||
本项目提供以下测试工具,帮助您验证系统和选择合适的模型:
|
本项目提供以下测试工具,帮助您验证系统和选择合适的模型:
|
||||||
|
|
||||||
| 工具名称 | 位置 | 使用方法 | 功能说明 |
|
| 工具名称 | 位置 | 使用方法 | 功能说明 |
|
||||||
|:---:|:---|:---:|:---:|
|
|:---:|:---|:---:|:---:|
|
||||||
| 音频交互测试工具 | main》xiaozhi-server》test》test_page.html | 使用谷歌浏览器直接打开 | 测试音频播放和接收功能,验证Python端音频处理是否正常 |
|
| 音频交互测试工具 | main》xiaozhi-server》test》test_page.html | 使用谷歌浏览器直接打开 | 测试音频播放和接收功能,验证Python端音频处理是否正常 |
|
||||||
| 模型响应测试工具1 | main》xiaozhi-server》performance_tester.py | 执行 `python performance_tester.py` | 测试ASR(语音识别)、LLM(大模型)、TTS(语音合成)三个核心模块的响应速度 |
|
| 模型响应测试工具 | main》xiaozhi-server》performance_tester.py | 执行 `python performance_tester.py` | 测试ASR(语音识别)、LLM(大模型)、VLLM(视觉模型)、TTS(语音合成)三个核心模块的响应速度 |
|
||||||
| 模型响应测试工具2 | main》xiaozhi-server》performance_tester_vllm.py | 执行 `python performance_tester_vllm.py` | 测试VLLM(视觉模型)的响应速度 |
|
|
||||||
|
|
||||||
> 💡 提示:测试模型速度时,只会测试配置了密钥的模型。
|
> 💡 提示:测试模型速度时,只会测试配置了密钥的模型。
|
||||||
|
|
||||||
@@ -253,7 +256,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
|
|
||||||
### 正在开发 🚧
|
### 正在开发 🚧
|
||||||
|
|
||||||
想了解具体开发计划进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)
|
想了解具体开发计划进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)。常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
|
||||||
|
|
||||||
如果你是一名软件开发者,这里有一份[《致开发者的公开信》](docs/contributor_open_letter.md),欢迎加入!
|
如果你是一名软件开发者,这里有一份[《致开发者的公开信》](docs/contributor_open_letter.md),欢迎加入!
|
||||||
|
|
||||||
@@ -354,8 +357,10 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
| <img src="./docs/images/logo_bailing.png" width="160"> | [百聆语音对话机器人](https://github.com/wwbin2017/bailing) | 本项目受[百聆语音对话机器人](https://github.com/wwbin2017/bailing)启发,并在其基础上实现 |
|
| <img src="./docs/images/logo_bailing.png" width="160"> | [百聆语音对话机器人](https://github.com/wwbin2017/bailing) | 本项目受[百聆语音对话机器人](https://github.com/wwbin2017/bailing)启发,并在其基础上实现 |
|
||||||
| <img src="./docs/images/logo_tenclass.png" width="160"> | [十方融海](https://www.tenclass.com/) | 感谢[十方融海](https://www.tenclass.com/)为小智生态制定了标准的通讯协议、多设备兼容性方案及高并发场景实践示范;为本项目提供了全链路技术文档支持 |
|
| <img src="./docs/images/logo_tenclass.png" width="160"> | [十方融海](https://www.tenclass.com/) | 感谢[十方融海](https://www.tenclass.com/)为小智生态制定了标准的通讯协议、多设备兼容性方案及高并发场景实践示范;为本项目提供了全链路技术文档支持 |
|
||||||
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [玄凤科技](https://github.com/Eric0308) | 感谢[玄凤科技](https://github.com/Eric0308)贡献函数调用框架、MCP通信协议及插件化调用机制的实现代码,通过标准化的指令调度体系与动态扩展能力,显著提升了前端设备(IoT)的交互效率和功能延展性 |
|
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [玄凤科技](https://github.com/Eric0308) | 感谢[玄凤科技](https://github.com/Eric0308)贡献函数调用框架、MCP通信协议及插件化调用机制的实现代码,通过标准化的指令调度体系与动态扩展能力,显著提升了前端设备(IoT)的交互效率和功能延展性 |
|
||||||
|
| <img src="./docs/images/logo_junsen.png" width="160"> | [huangjunsen](https://github.com/huangjunsen0406) | 感谢[huangjunsen](https://github.com/huangjunsen0406) 贡献`智控台移动端`模块,实现了跨平台移动设备的高效控制与实时交互,大幅提升了系统在移动场景下的操作便捷性和管理效率 |
|
||||||
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [汇远设计](http://ui.kwd988.net/) | 感谢[汇远设计](http://ui.kwd988.net/)为本项目提供专业视觉解决方案,用其服务超千家企业的设计实战经验,赋能本项目产品用户体验 |
|
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [汇远设计](http://ui.kwd988.net/) | 感谢[汇远设计](http://ui.kwd988.net/)为本项目提供专业视觉解决方案,用其服务超千家企业的设计实战经验,赋能本项目产品用户体验 |
|
||||||
| <img src="./docs/images/logo_qinren.png" width="160"> | [西安勤人信息科技](https://www.029app.com/) | 感谢[西安勤人信息科技](https://www.029app.com/)深化本项目视觉体系,确保整体设计风格在多场景应用中的一致性和扩展性 |
|
| <img src="./docs/images/logo_qinren.png" width="160"> | [西安勤人信息科技](https://www.029app.com/) | 感谢[西安勤人信息科技](https://www.029app.com/)深化本项目视觉体系,确保整体设计风格在多场景应用中的一致性和扩展性 |
|
||||||
|
| <img src="./docs/images/logo_contributors.png" width="160"> | [代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | 感谢[所有代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors)贡献者,你们的付出让项目更加健壮和强大。 |
|
||||||
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ Supports MCP endpoints and voiceprint recognition
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
By Professor Siyuan Liu Research and Development Group (South China University of Technology)
|
Spearheaded by Professor Siyuan Liu's Team (South China University of Technology)
|
||||||
</br>
|
</br>
|
||||||
刘思源教授团队研发(华南理工大学)
|
刘思源教授团队主导研发(华南理工大学)
|
||||||
</br>
|
</br>
|
||||||
<img src="./docs/images/hnlg.jpg" alt="South China University of Technology" width="50%">
|
<img src="./docs/images/hnlg.jpg" alt="South China University of Technology" width="50%">
|
||||||
</p>
|
</p>
|
||||||
@@ -93,7 +93,7 @@ Want to see the usage effects? Click the videos below 🎥
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||||
<picture>
|
<picture>
|
||||||
<img alt="Custom voice timbre" src="docs/images/demo6.png" />
|
<img alt="Custom voice timbre" src="docs/images/demo6.png" />
|
||||||
</picture>
|
</picture>
|
||||||
@@ -194,6 +194,7 @@ This project provides two deployment methods. Please choose based on your specif
|
|||||||
|
|
||||||
```
|
```
|
||||||
Intelligent Control Console Address: https://2662r3426b.vicp.fun
|
Intelligent Control Console Address: https://2662r3426b.vicp.fun
|
||||||
|
Intelligent Control Console Address (H5): https://2662r3426b.vicp.fun/h5/index.html
|
||||||
|
|
||||||
Service Test Tool: https://2662r3426b.vicp.fun/test/
|
Service Test Tool: https://2662r3426b.vicp.fun/test/
|
||||||
OTA Interface Address: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
OTA Interface Address: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||||
@@ -352,8 +353,10 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
|||||||
| <img src="./docs/images/logo_bailing.png" width="160"> | [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) | This project is inspired by [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) and implemented on its basis |
|
| <img src="./docs/images/logo_bailing.png" width="160"> | [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) | This project is inspired by [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) and implemented on its basis |
|
||||||
| <img src="./docs/images/logo_tenclass.png" width="160"> | [Tenclass](https://www.tenclass.com/) | Thanks to [Tenclass](https://www.tenclass.com/) for formulating standard communication protocols, multi-device compatibility solutions, and high-concurrency scenario practice demonstrations for the Xiaozhi ecosystem; providing full-link technical documentation support for this project |
|
| <img src="./docs/images/logo_tenclass.png" width="160"> | [Tenclass](https://www.tenclass.com/) | Thanks to [Tenclass](https://www.tenclass.com/) for formulating standard communication protocols, multi-device compatibility solutions, and high-concurrency scenario practice demonstrations for the Xiaozhi ecosystem; providing full-link technical documentation support for this project |
|
||||||
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [Xuanfeng Technology](https://github.com/Eric0308) | Thanks to [Xuanfeng Technology](https://github.com/Eric0308) for contributing function calling framework, MCP communication protocol, and plugin-based calling mechanism implementation code. Through standardized instruction scheduling system and dynamic expansion capabilities, it significantly improves the interaction efficiency and functional extensibility of frontend devices (IoT) |
|
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [Xuanfeng Technology](https://github.com/Eric0308) | Thanks to [Xuanfeng Technology](https://github.com/Eric0308) for contributing function calling framework, MCP communication protocol, and plugin-based calling mechanism implementation code. Through standardized instruction scheduling system and dynamic expansion capabilities, it significantly improves the interaction efficiency and functional extensibility of frontend devices (IoT) |
|
||||||
|
| <img src="./docs/images/logo_junsen.png" width="160"> | [huangjunsen](https://github.com/huangjunsen0406) | Thanks to [huangjunsen](https://github.com/huangjunsen0406) for contributing the `Smart Control Console Mobile` module, which enables efficient control and real-time interaction across mobile devices, significantly enhancing the system's operational convenience and management efficiency in mobile scenarios. |
|
||||||
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [Huiyuan Design](http://ui.kwd988.net/) | Thanks to [Huiyuan Design](http://ui.kwd988.net/) for providing professional visual solutions for this project, using their design practical experience serving over a thousand enterprises to empower this project's product user experience |
|
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [Huiyuan Design](http://ui.kwd988.net/) | Thanks to [Huiyuan Design](http://ui.kwd988.net/) for providing professional visual solutions for this project, using their design practical experience serving over a thousand enterprises to empower this project's product user experience |
|
||||||
| <img src="./docs/images/logo_qinren.png" width="160"> | [Xi'an Qinren Information Technology](https://www.029app.com/) | Thanks to [Xi'an Qinren Information Technology](https://www.029app.com/) for deepening this project's visual system, ensuring consistency and extensibility of overall design style in multi-scenario applications |
|
| <img src="./docs/images/logo_qinren.png" width="160"> | [Xi'an Qinren Information Technology](https://www.029app.com/) | Thanks to [Xi'an Qinren Information Technology](https://www.029app.com/) for deepening this project's visual system, ensuring consistency and extensibility of overall design style in multi-scenario applications |
|
||||||
|
| <img src="./docs/images/logo_contributors.png" width="160"> | [Code Contributors](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | Thanks to [all code contributors](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors), your efforts have made the project more robust and powerful. |
|
||||||
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ cat << "EOF"
|
|||||||
\/ \__,_||_| |_||_||_||_| \__,_| |_| \_| \__,_||_| |_||_| \__,_| \__,_|
|
\/ \__,_||_| |_||_||_||_| \__,_| |_| \_| \__,_||_| |_||_| \__,_| \__,_|
|
||||||
EOF
|
EOF
|
||||||
echo -e "\e[0m" # 重置颜色
|
echo -e "\e[0m" # 重置颜色
|
||||||
echo -e "\e[1;36m 小智服务端全量部署一键安装脚本 Ver 0.2 \e[0m\n"
|
echo -e "\e[1;36m 小智服务端全量部署一键安装脚本 Ver 0.2 2025年8月20日更新 \e[0m\n"
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ done
|
|||||||
|
|
||||||
echo "服务端启动成功!正在完成配置..."
|
echo "服务端启动成功!正在完成配置..."
|
||||||
echo "正在启动服务..."
|
echo "正在启动服务..."
|
||||||
docker compose -f docker-compose_all.yml up -d
|
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d
|
||||||
echo "服务启动完成!"
|
echo "服务启动完成!"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -402,13 +402,12 @@ fi
|
|||||||
|
|
||||||
# 获取并显示地址信息
|
# 获取并显示地址信息
|
||||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||||
WEBSOCKET_ADDR=$(docker logs xiaozhi-esp32-server 2>&1 | tac | grep -m 1 -E -o "ws://[^ ]+")
|
|
||||||
VISION_ADDR=$(docker logs xiaozhi-esp32-server 2>&1 | tac | grep -m 1 "视觉" | grep -m 1 -E -o "http://[^ ]+")
|
|
||||||
|
|
||||||
|
# 修复日志文件获取不到ws的问题,改为硬编码
|
||||||
whiptail --title "安装完成!" --msgbox "\
|
whiptail --title "安装完成!" --msgbox "\
|
||||||
服务端相关地址如下:\n\
|
服务端相关地址如下:\n\
|
||||||
管理后台访问地址: http://$LOCAL_IP:8002\n\
|
管理后台访问地址: http://$LOCAL_IP:8002\n\
|
||||||
OTA 地址: http://$LOCAL_IP:8002/xiaozhi/ota/\n\
|
OTA 地址: http://$LOCAL_IP:8002/xiaozhi/ota/\n\
|
||||||
视觉分析接口地址: $VISION_ADDR\n\
|
视觉分析接口地址: http://$LOCAL_IP:8003/mcp/vision/explain\n\
|
||||||
WebSocket 地址: $WEBSOCKET_ADDR\n\
|
WebSocket 地址: ws://$LOCAL_IP:8000/xiaozhi/v1/\n\
|
||||||
\n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70
|
\n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70
|
||||||
@@ -12,11 +12,9 @@ docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统
|
|||||||
|
|
||||||
### 1.1 手动部署
|
### 1.1 手动部署
|
||||||
|
|
||||||
如果懒人脚本无法正常运行,请按本章节1.2进行手动部署。
|
|
||||||
|
|
||||||
#### 1.1.1 创建目录
|
#### 1.1.1 创建目录
|
||||||
|
|
||||||
安装完后,你需要为这个项目找一个安放配置文件的目录,例如我们可以新建一个文件夹叫`xiaozhi-server`。
|
安装完docker后,你需要为这个项目找一个安放配置文件的目录,例如我们可以新建一个文件夹叫`xiaozhi-server`。
|
||||||
|
|
||||||
创建好目录后,你需要在`xiaozhi-server`下面创建`data`文件夹和`models`文件夹,`models`下面还要再创建`SenseVoiceSmall`文件夹。
|
创建好目录后,你需要在`xiaozhi-server`下面创建`data`文件夹和`models`文件夹,`models`下面还要再创建`SenseVoiceSmall`文件夹。
|
||||||
|
|
||||||
@@ -258,19 +256,30 @@ LLM:
|
|||||||
|
|
||||||
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
||||||
|
|
||||||
|
# 常见问题
|
||||||
以下是一些常见问题,供参考:
|
以下是一些常见问题,供参考:
|
||||||
|
|
||||||
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
|
1、[为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)<br/>
|
||||||
|
2、[为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)<br/>
|
||||||
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
|
3、[TTS 经常失败,经常超时](./FAQ.md)<br/>
|
||||||
|
4、[使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)<br/>
|
||||||
[3、TTS 经常失败,经常超时](./FAQ.md)
|
5、[如何提高小智对话响应速度?](./FAQ.md)<br/>
|
||||||
|
6、[我说话很慢,停顿时小智老是抢话](./FAQ.md)<br/>
|
||||||
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
|
## 部署相关教程
|
||||||
|
1、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||||
[5、如何提高小智对话响应速度?](./FAQ.md)
|
2、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||||
|
## 拓展相关教程
|
||||||
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
|
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||||
|
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||||
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
|
3、[如何开启视觉模型实现拍照识物](./mcp-vision-integration.md)<br/>
|
||||||
|
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||||
|
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||||
|
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||||
|
10、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||||
|
## 语音克隆、本地语音部署相关教程
|
||||||
|
1、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||||
|
2、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||||
|
3、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||||
|
## 性能测试教程
|
||||||
|
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||||
|
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统
|
|||||||
|
|
||||||
如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html)
|
如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html)
|
||||||
|
|
||||||
docker 安装全模块有两种方式,你可以[1.1使用懒人脚本](#1.1 懒人脚本)(作者[@VanillaNahida](https://github.com/VanillaNahida))自动帮你下载所需的文件和配置文件,你可以使用[1.2手动部署](#1.2 手动部署)从零搭建。
|
docker 安装全模块有两种方式,你可以[使用懒人脚本](./Deployment_all.md#11-懒人脚本)(作者[@VanillaNahida](https://github.com/VanillaNahida))
|
||||||
|
脚本会自动帮你下载所需的文件和配置文件,你也可以使用[手动部署](./Deployment_all.md#12-手动部署)从零搭建。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 1.1 懒人脚本
|
### 1.1 懒人脚本
|
||||||
|
部署简便,可以参考[视频教程](https://www.bilibili.com/video/BV17bbvzHExd/) ,文字版教程如下:
|
||||||
你可以使用以下命令一键安装全模块版小智服务端:
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 暂且只支持Ubuntu服务器一键部署,其他系统未尝试,可能会有一些奇怪的bug
|
> 暂且只支持Ubuntu服务器一键部署,其他系统未尝试,可能会有一些奇怪的bug
|
||||||
|
|
||||||
@@ -456,19 +458,29 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
|||||||
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
||||||
|
|
||||||
# 常见问题
|
# 常见问题
|
||||||
|
|
||||||
以下是一些常见问题,供参考:
|
以下是一些常见问题,供参考:
|
||||||
|
|
||||||
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
|
1、[为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)<br/>
|
||||||
|
2、[为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)<br/>
|
||||||
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
|
3、[TTS 经常失败,经常超时](./FAQ.md)<br/>
|
||||||
|
4、[使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)<br/>
|
||||||
[3、TTS 经常失败,经常超时](./FAQ.md)
|
5、[如何提高小智对话响应速度?](./FAQ.md)<br/>
|
||||||
|
6、[我说话很慢,停顿时小智老是抢话](./FAQ.md)<br/>
|
||||||
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
|
## 部署相关教程
|
||||||
|
1、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||||
[5、如何提高小智对话响应速度?](./FAQ.md)
|
2、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||||
|
## 拓展相关教程
|
||||||
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
|
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||||
|
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||||
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
|
3、[如何开启视觉模型实现拍照识物](./mcp-vision-integration.md)<br/>
|
||||||
|
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||||
|
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||||
|
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||||
|
10、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||||
|
## 语音克隆、本地语音部署相关教程
|
||||||
|
1、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||||
|
2、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||||
|
3、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||||
|
## 性能测试教程
|
||||||
|
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||||
|
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||||
|
|||||||
@@ -34,59 +34,18 @@ conda install conda-forge::ffmpeg
|
|||||||
### 5、如何提高小智对话响应速度? ⚡
|
### 5、如何提高小智对话响应速度? ⚡
|
||||||
|
|
||||||
本项目默认配置为低成本方案,建议初学者先使用默认免费模型,解决"跑得动"的问题,再优化"跑得快"。
|
本项目默认配置为低成本方案,建议初学者先使用默认免费模型,解决"跑得动"的问题,再优化"跑得快"。
|
||||||
如需提升响应速度,可尝试更换各组件。以下为各组件的响应速度测试数据(仅供参考,不构成承诺):
|
如需提升响应速度,可尝试更换各组件。自`0.5.2`版本起,项目支持流式配置,相比早期版本,响应速度提升约`2.5秒`,显著改善用户体验。
|
||||||
|
|
||||||
| 影响因素 | 因素值 |
|
| 模块名称 | 入门全免费设置 | 流式配置 |
|
||||||
|:-----:|:----------------:|
|
|:---:|:---:|:---:|
|
||||||
| 测试地点 | 广东省广州市海珠区 |
|
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) |
|
||||||
| 测试时间 | 2025年2月19日 12:52 |
|
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) |
|
||||||
| 宽带运营商 | 中国联通 |
|
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) |
|
||||||
|
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) |
|
||||||
|
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
|
||||||
|
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
||||||
|
|
||||||
测试方法:
|
如果您关心各组件的耗时,请查阅[小智各组件性能测试报告](https://github.com/xinnan-tech/xiaozhi-performance-research),可按报告中的测试方法在您的环境中实际测试。
|
||||||
|
|
||||||
1、把各组件的密钥配置上去,只有配置了密钥的组件才参与测试。
|
|
||||||
|
|
||||||
2、配置完密钥后,执行以下方法
|
|
||||||
|
|
||||||
```
|
|
||||||
# 进入项目根目录,执行以下命令:
|
|
||||||
conda activate xiaozhi-esp32-server
|
|
||||||
python performance_tester.py
|
|
||||||
```
|
|
||||||
|
|
||||||
生成报告如下
|
|
||||||
|
|
||||||
LLM 性能排行:
|
|
||||||
|
|
||||||
| 模块名称 | 平均首Token时间 | 平均总响应时间 |
|
|
||||||
|:-----------|:-----------|:--------|
|
|
||||||
| AliLLM | 0.547s | 1.485s |
|
|
||||||
| ChatGLMLLM | 0.677s | 3.057s |
|
|
||||||
|
|
||||||
TTS 性能排行:
|
|
||||||
|
|
||||||
| 模块名称 | 平均合成时间 |
|
|
||||||
|----------------------|--------|
|
|
||||||
| EdgeTTS | 1.019s |
|
|
||||||
| DoubaoTTS | 0.503s |
|
|
||||||
| CosyVoiceSiliconflow | 3.732s |
|
|
||||||
|
|
||||||
推荐配置组合 (综合响应速度):
|
|
||||||
|
|
||||||
| 组合方案 | 综合得分 | LLM首Token | TTS合成 |
|
|
||||||
|-------------------------------|-------|-----------|--------|
|
|
||||||
| AliLLM + DoubaoTTS | 0.539 | 0.547s | 0.503s |
|
|
||||||
| AliLLM + EdgeTTS | 0.642 | 0.547s | 1.019s |
|
|
||||||
| ChatGLMLLM + DoubaoTTS | 0.642 | 0.677s | 0.503s |
|
|
||||||
| ChatGLMLLM + EdgeTTS | 0.745 | 0.677s | 1.019s |
|
|
||||||
| AliLLM + CosyVoiceSiliconflow | 1.184 | 0.547s | 3.732s |
|
|
||||||
|
|
||||||
### 结论 🔍
|
|
||||||
|
|
||||||
`2025年2月19日`,如果我的电脑在`广东省广州市海珠区`,且使用的是`中国联通`网络,我会优先使用:
|
|
||||||
|
|
||||||
- LLM:`AliLLM`
|
|
||||||
- TTS:`DoubaoTTS`
|
|
||||||
|
|
||||||
### 6、我说话很慢,停顿时小智老是抢话 🗣️
|
### 6、我说话很慢,停顿时小智老是抢话 🗣️
|
||||||
|
|
||||||
@@ -100,30 +59,34 @@ VAD:
|
|||||||
min_silence_duration_ms: 700 # 如果说话停顿较长,可将此值调大
|
min_silence_duration_ms: 700 # 如果说话停顿较长,可将此值调大
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡
|
### 7、部署相关教程
|
||||||
|
1、[如何进行最简化部署](./Deployment.md)<br/>
|
||||||
|
2、[如何进行全模块部署](./Deployment_all.md)<br/>
|
||||||
|
3、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||||
|
4、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||||
|
|
||||||
参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md)
|
### 8、编译固件相关教程
|
||||||
|
1、[如何自己编译小智固件](./firmware-build.md)<br/>
|
||||||
|
2、[如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md)<br/>
|
||||||
|
|
||||||
### 8、如何开启手机注册智控台 📱
|
### 8、拓展相关教程
|
||||||
|
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||||
|
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||||
|
3、[如何开启视觉模型实现拍照识物](./mcp-vision-integration.md)<br/>
|
||||||
|
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||||
|
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||||
|
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||||
|
10、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||||
|
|
||||||
参考教程[阿里云短信集成指南](./ali-sms-integration.md)
|
### 9、语音克隆、本地语音部署相关教程
|
||||||
|
1、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||||
|
2、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||||
|
3、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||||
|
|
||||||
### 9、如何开启视觉模型实现拍照识物 📷
|
### 10、性能测试教程
|
||||||
|
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||||
参考教程[视觉模型使用指南](./mcp-vision-integration.md)
|
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||||
|
|
||||||
### 10、如何开启MCP接入点 🔧
|
|
||||||
|
|
||||||
1、先参考教程[MCP 接入点部署使用指南](./mcp-endpoint-enable.md)
|
|
||||||
|
|
||||||
2、再参考教程[MCP 接入点使用指南](./mcp-endpoint-integration.md)
|
|
||||||
|
|
||||||
### 12、如何开启声纹识别 🔊
|
|
||||||
|
|
||||||
参考教程[声纹识别启用指南](./voiceprint-integration.md)
|
|
||||||
|
|
||||||
### 13、更多问题,可联系我们反馈 💬
|
### 13、更多问题,可联系我们反馈 💬
|
||||||
|
|
||||||
可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。
|
可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。
|
||||||
|
|
||||||
也可以发邮件我们取得联系:huangrongzhuang@xin-nan.com
|
|
||||||
@@ -105,6 +105,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev &
|
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev &
|
||||||
|
|
||||||
|
tail tail -f nohup.out
|
||||||
```
|
```
|
||||||
|
|
||||||
保存好后执行赋权命令
|
保存好后执行赋权命令
|
||||||
@@ -134,8 +136,12 @@ else
|
|||||||
echo "已杀掉进程 $PID"
|
echo "已杀掉进程 $PID"
|
||||||
fi
|
fi
|
||||||
cd main/xiaozhi-server
|
cd main/xiaozhi-server
|
||||||
|
# 初始化conda环境
|
||||||
|
source ~/.bashrc
|
||||||
|
conda activate xiaozhi-esp32-server
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
nohup python app.py >/dev/null &
|
nohup python app.py >/dev/null &
|
||||||
|
tail -f /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/tmp/server.log
|
||||||
```
|
```
|
||||||
|
|
||||||
保存好后执行赋权命令
|
保存好后执行赋权命令
|
||||||
@@ -149,8 +155,6 @@ chmod 777 update_8000.sh
|
|||||||
以上的脚本都建立好后,日常更新,我们只要依次执行以下命令就可以做到自动更新和启动
|
以上的脚本都建立好后,日常更新,我们只要依次执行以下命令就可以做到自动更新和启动
|
||||||
|
|
||||||
```
|
```
|
||||||
# 进入pyhton环境
|
|
||||||
conda activate xiaozhi-esp32-server
|
|
||||||
cd /home/system/xiaozhi
|
cd /home/system/xiaozhi
|
||||||
# 更新并启动Java程序
|
# 更新并启动Java程序
|
||||||
./update_8001.sh
|
./update_8001.sh
|
||||||
@@ -158,9 +162,11 @@ cd /home/system/xiaozhi
|
|||||||
./update_8002.sh
|
./update_8002.sh
|
||||||
# 更新并启动python程序
|
# 更新并启动python程序
|
||||||
./update_8000.sh
|
./update_8000.sh
|
||||||
# 查看Java日志
|
|
||||||
|
|
||||||
|
# 后期想查看java日志,执行以下命令
|
||||||
tail -f nohup.out
|
tail -f nohup.out
|
||||||
# 查看Python日志
|
# 后期想查看python日志,执行以下命令
|
||||||
tail -f /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/tmp/server.log
|
tail -f /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/tmp/server.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 655 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 579 KiB |
|
After Width: | Height: | Size: 418 KiB |
@@ -0,0 +1,165 @@
|
|||||||
|
# IndexStreamTTS 使用指南
|
||||||
|
|
||||||
|
## 环境准备
|
||||||
|
### 1. 克隆项目 (这里使用的为VLLM的版本)
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Ksuriuri/index-tts-vllm.git
|
||||||
|
cd index-tts-vllm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建并激活 conda 环境
|
||||||
|
```bash
|
||||||
|
conda create -n index-tts-vllm python=3.12
|
||||||
|
conda activate index-tts-vllm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 安装PyTorch
|
||||||
|
#### 查看显卡最高支持的版本和实际安装的版本
|
||||||
|
```bash
|
||||||
|
nvidia-smi
|
||||||
|
nvcc --version
|
||||||
|
```
|
||||||
|
#### 驱动支持的最高 CUDA 版本
|
||||||
|
```bash
|
||||||
|
CUDA Version: 12.8
|
||||||
|
```
|
||||||
|
#### 实际安装的 CUDA 编译器版本
|
||||||
|
```bash
|
||||||
|
Cuda compilation tools, release 12.8, V12.8.89
|
||||||
|
```
|
||||||
|
#### 那么对应的安装命令 (请注意不要横跨大版本!!!)
|
||||||
|
```bash
|
||||||
|
pip install torch==2.7.0 torchvision==0.22.0 torchaudio==2.7.0 --index-url https://download.pytorch.org/whl/cu128
|
||||||
|
```
|
||||||
|
优先建议安装 pytorch 2.7.0(对应 vllm 0.9.0),具体安装指令请参考:[pytorch 官网](https://pytorch.org/get-started/locally/]\)
|
||||||
|
若显卡不支持,请安装 pytorch 2.5.1(对应 vllm 0.7.3),并将 requirements.txt 中 vllm==0.9.0 修改为 vllm==0.7.3
|
||||||
|
|
||||||
|
### 4. 安装依赖
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 下载模型权重
|
||||||
|
此为官方权重文件,下载到本地任意路径即可,支持 IndexTTS-1.5 的权重
|
||||||
|
| HuggingFace | ModelScope |
|
||||||
|
|---------------------------------------------------------------|---------------------------------------------------------------------|
|
||||||
|
| [IndexTTS](https://huggingface.co/IndexTeam/Index-TTS) | [IndexTTS](https://modelscope.cn/models/IndexTeam/Index-TTS) |
|
||||||
|
| [IndexTTS-1.5](https://huggingface.co/IndexTeam/IndexTTS-1.5) | [IndexTTS-1.5](https://modelscope.cn/models/IndexTeam/IndexTTS-1.5) |
|
||||||
|
|
||||||
|
下面以ModelScope的安装方法为例
|
||||||
|
### 请注意:git需要安装并初始化启用lfs(如已安装可以跳过)
|
||||||
|
```bash
|
||||||
|
sudo apt-get install git-lfs
|
||||||
|
git lfs install
|
||||||
|
```
|
||||||
|
创建模型目录,并拉取模型
|
||||||
|
```bash
|
||||||
|
mkkdir model_dir
|
||||||
|
cd model_dir
|
||||||
|
git clone https://www.modelscope.cn/IndexTeam/IndexTTS-1.5.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 模型权重转换
|
||||||
|
```bash
|
||||||
|
bash convert_hf_format.sh /path/to/your/model_dir
|
||||||
|
```
|
||||||
|
例如:你下载的IndexTTS-1.5模型存放在model_dir目录下,则执行以下命令
|
||||||
|
```bash
|
||||||
|
bash convert_hf_format.sh model_dir/IndexTTS-1.5
|
||||||
|
```
|
||||||
|
此操作会将官方的模型权重转换为 transformers 库兼容的版本,保存在模型权重路径下的 vllm 文件夹中,方便后续 vllm 库加载模型权重
|
||||||
|
|
||||||
|
### 6. 更改接口适配一下项目
|
||||||
|
接口返回数据与项目不适配需要调整一下,使其直接返回音频数据
|
||||||
|
```bash
|
||||||
|
@app.post("/tts", responses={
|
||||||
|
200: {"content": {"application/octet-stream": {}}},
|
||||||
|
500: {"content": {"application/json": {}}}
|
||||||
|
})
|
||||||
|
async def tts_api(request: Request):
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
text = data["text"]
|
||||||
|
character = data["character"]
|
||||||
|
|
||||||
|
global tts
|
||||||
|
sr, wav = await tts.infer_with_ref_audio_embed(character, text)
|
||||||
|
|
||||||
|
return Response(content=wav.tobytes(), media_type="application/octet-stream")
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
tb_str = ''.join(traceback.format_exception(type(ex), ex, ex.__traceback__))
|
||||||
|
print(tb_str)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={
|
||||||
|
"status": "error",
|
||||||
|
"error": str(tb_str)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.编写sh启动脚本(请注意要在相应的conda环境下运行)
|
||||||
|
```bash
|
||||||
|
vi start_api.sh
|
||||||
|
```
|
||||||
|
### 将下面内容粘贴进去并按:输入wq保存
|
||||||
|
#### 脚本中的/home/system/indexTTS/index-tts-vllm/model_dir/IndexTTS-1.5 请自行修改为实际路径
|
||||||
|
```bash
|
||||||
|
# 激活conda环境
|
||||||
|
conda activate index-tts-vllm
|
||||||
|
echo "激活项目conda环境"
|
||||||
|
sleep 2
|
||||||
|
# 查找占用11996端口的进程号
|
||||||
|
PID_VLLM=$(sudo netstat -tulnp | grep 11996 | awk '{print $7}' | cut -d'/' -f1)
|
||||||
|
|
||||||
|
# 检查是否找到进程号
|
||||||
|
if [ -z "$PID_VLLM" ]; then
|
||||||
|
echo "没有找到占用11996端口的进程"
|
||||||
|
else
|
||||||
|
echo "找到占用11996端口的进程,进程号为: $PID_VLLM"
|
||||||
|
# 先尝试普通kill,等待2秒
|
||||||
|
kill $PID_VLLM
|
||||||
|
sleep 2
|
||||||
|
# 检查进程是否还在
|
||||||
|
if ps -p $PID_VLLM > /dev/null; then
|
||||||
|
echo "进程仍在运行,强制终止..."
|
||||||
|
kill -9 $PID_VLLM
|
||||||
|
fi
|
||||||
|
echo "已终止进程 $PID_VLLM"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建tmp目录(如果不存在)
|
||||||
|
mkdir -p tmp
|
||||||
|
|
||||||
|
# 后台运行api_server.py,日志重定向到tmp/server.log
|
||||||
|
export VLLM_USE_V1=0
|
||||||
|
nohup python api_server.py --model_dir /home/system/indexTTS/index-tts-vllm/model_dir/IndexTTS-1.5 --port 11996 > tmp/server.log 2>&1 &
|
||||||
|
echo "api_server.py 已在后台运行,日志请查看 tmp/server.log"
|
||||||
|
```
|
||||||
|
给脚本执行权限并运行脚本
|
||||||
|
```bash
|
||||||
|
chmod +x tmp
|
||||||
|
./start_api.sh
|
||||||
|
```
|
||||||
|
日志会在tmp/server.log中输出,可以通过以下命令查看日志情况
|
||||||
|
```bash
|
||||||
|
tail -f tmp/server.log
|
||||||
|
```
|
||||||
|
## 音色配置
|
||||||
|
index-tts-vllm支持通过配置文件注册自定义音色,支持单音色和混合音色配置。
|
||||||
|
在项目根目录下的assets/speaker.json文件中配置自定义音色
|
||||||
|
### 配置格式说明
|
||||||
|
```bash
|
||||||
|
{
|
||||||
|
"说话人名称1": [
|
||||||
|
"音频文件路径1.wav",
|
||||||
|
"音频文件路径2.wav"
|
||||||
|
],
|
||||||
|
"说话人名称2": [
|
||||||
|
"音频文件路径3.wav"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
### 注意
|
||||||
|
添加后需在智控台中添加相应的说话人(单模块则更换相应的voice)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# PaddleSpeechTTS集成xiaozhi服务
|
||||||
|
|
||||||
|
## 一、基础环境要求
|
||||||
|
操作系统:Windows / Linux / WSL 2
|
||||||
|
|
||||||
|
Python 版本:3.9以上(请根据Paddle官方教程调整)
|
||||||
|
|
||||||
|
Paddle 版本:官方最新版本 ```https://www.paddlepaddle.org.cn/install```
|
||||||
|
|
||||||
|
依赖管理工具:conda 或 venv
|
||||||
|
|
||||||
|
## 二、启动paddlespeech服务
|
||||||
|
### 1.从paddlespeech官方仓库拉取源码
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/PaddlePaddle/PaddleSpeech.git
|
||||||
|
```
|
||||||
|
### 2.建立虚拟环境
|
||||||
|
```bash
|
||||||
|
#请根据Paddle官方支持的python版本建立环境 ```https://www.paddlepaddle.org.cn/install```
|
||||||
|
conda create -n paddle_env python=3.10 -y
|
||||||
|
conda activate paddle_env
|
||||||
|
```
|
||||||
|
### 3.进入paddlespeech目录
|
||||||
|
```bash
|
||||||
|
cd PaddleSpeech
|
||||||
|
```
|
||||||
|
### 4.安装paddlespeech
|
||||||
|
```bash
|
||||||
|
pip install pytest-runner -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
|
#以下命令使用任意一个
|
||||||
|
pip install paddlepaddle -i https://mirror.baidu.com/pypi/simple
|
||||||
|
pip install paddlespeech -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
```
|
||||||
|
### 5.使用命令自动下载语音模型
|
||||||
|
```bash
|
||||||
|
paddlespeech tts --input "你好,这是一次测试"
|
||||||
|
```
|
||||||
|
此步骤会自动下载模型缓存至本地 .paddlespeech/models 目录
|
||||||
|
|
||||||
|
### 6.修改tts_online_application.yaml配置
|
||||||
|
参考目录 ```"PaddleSpeech\demos\streaming_tts_server\conf\tts_online_application.yaml"```
|
||||||
|
选择```tts_online_application.yaml```文件用编辑器打开,设置```protocol```为```websocket```
|
||||||
|
|
||||||
|
### 7.启动服务
|
||||||
|
```yaml
|
||||||
|
paddlespeech_server start --config_file ./demos/streaming_tts_server/conf/tts_online_application.yaml
|
||||||
|
#官方默认启动命令:
|
||||||
|
paddlespeech_server start --config_file ./conf/tts_online_application.yaml
|
||||||
|
```
|
||||||
|
请根据你的```tts_online_application.yaml```的实际目录来启动命令,看到如下日志即启动成功
|
||||||
|
```
|
||||||
|
Prefix dict has been built successfully.
|
||||||
|
[2025-08-07 10:03:11,312] [ DEBUG] __init__.py:166 - Prefix dict has been built successfully.
|
||||||
|
INFO: Started server process [2298]
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: Uvicorn running on http://0.0.0.0:8092 (Press CTRL+C to quit)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、修改小智的配置文件
|
||||||
|
### 1.```main/xiaozhi-server/core/providers/tts/paddle_speech.py```
|
||||||
|
|
||||||
|
### 2.```main/xiaozhi-server/data/.config.yaml```
|
||||||
|
使用单模块部署
|
||||||
|
```yaml
|
||||||
|
selected_module:
|
||||||
|
TTS: PaddleSpeechTTS
|
||||||
|
TTS:
|
||||||
|
PaddleSpeechTTS:
|
||||||
|
type: paddle_speech
|
||||||
|
protocol: websocket
|
||||||
|
url: ws://127.0.0.1:8092/paddlespeech/tts/streaming # TTS 服务的 URL 地址,指向本地服务器 [websocket默认ws://127.0.0.1:8092/paddlespeech/tts/streaming]
|
||||||
|
spk_id: 0 # 发音人 ID,0 通常表示默认的发音人
|
||||||
|
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
||||||
|
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
||||||
|
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
||||||
|
save_path: # 保存路径
|
||||||
|
```
|
||||||
|
### 3.启动xiaozhi服务
|
||||||
|
```py
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
打开test目录下的test_page.html,测试连接和发送消息时paddlespeech端是否有输出日志
|
||||||
|
|
||||||
|
输出日志参考:
|
||||||
|
```
|
||||||
|
INFO: 127.0.0.1:44312 - "WebSocket /paddlespeech/tts/streaming" [accepted]
|
||||||
|
INFO: connection open
|
||||||
|
[2025-08-07 11:16:33,355] [ INFO] - sentence: 哈哈,怎么突然找我聊天啦?
|
||||||
|
[2025-08-07 11:16:33,356] [ INFO] - The durations of audio is: 2.4625 s
|
||||||
|
[2025-08-07 11:16:33,356] [ INFO] - first response time: 0.1143045425415039 s
|
||||||
|
[2025-08-07 11:16:33,356] [ INFO] - final response time: 0.4777836799621582 s
|
||||||
|
[2025-08-07 11:16:33,356] [ INFO] - RTF: 0.19402382942625715
|
||||||
|
[2025-08-07 11:16:33,356] [ INFO] - Other info: front time: 0.06514096260070801 s, first am infer time: 0.008037090301513672 s, first voc infer time: 0.04112648963928223 s,
|
||||||
|
[2025-08-07 11:16:33,356] [ INFO] - Complete the synthesis of the audio streams
|
||||||
|
INFO: connection closed
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的性能测试工具使用指南
|
||||||
|
|
||||||
|
1.在main/xiaozhi-server目录下创建data目录
|
||||||
|
2.在data目录下创建.config.yaml文件
|
||||||
|
3.在.data/config.yaml中,写入你的语音识别、大语言模型、流式语音合成、视觉模型的参数
|
||||||
|
例如:
|
||||||
|
```
|
||||||
|
LLM:
|
||||||
|
ChatGLMLLM:
|
||||||
|
# 定义LLM API类型
|
||||||
|
type: openai
|
||||||
|
# glm-4-flash 是免费的,但是还是需要注册填写api_key的
|
||||||
|
# 可在这里找到你的api key https://bigmodel.cn/usercenter/proj-mgmt/apikeys
|
||||||
|
model_name: glm-4-flash
|
||||||
|
url: https://open.bigmodel.cn/api/paas/v4/
|
||||||
|
api_key: 你的chat-glm web key
|
||||||
|
|
||||||
|
TTS:
|
||||||
|
|
||||||
|
VLLM:
|
||||||
|
|
||||||
|
ASR:
|
||||||
|
```
|
||||||
|
4.在main/xiaozhi-server目录下运行performance_tester.py:
|
||||||
|
```
|
||||||
|
python performance_tester.py
|
||||||
|
```
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
* [3.1. `xiaozhi-server` (核心AI引擎 - Python实现)](#31-xiaozhi-server-核心ai引擎---python实现)
|
* [3.1. `xiaozhi-server` (核心AI引擎 - Python实现)](#31-xiaozhi-server-核心ai引擎---python实现)
|
||||||
* [3.2. `manager-api` (管理后端 - Java Spring Boot实现)](#32-manager-api-管理后端---java-spring-boot实现)
|
* [3.2. `manager-api` (管理后端 - Java Spring Boot实现)](#32-manager-api-管理后端---java-spring-boot实现)
|
||||||
* [3.3. `manager-web` (Web管理前端 - Vue.js实现)](#33-manager-web-web管理前端---vuejs实现)
|
* [3.3. `manager-web` (Web管理前端 - Vue.js实现)](#33-manager-web-web管理前端---vuejs实现)
|
||||||
|
* [3.4. `manager-mobile` (移动管理端 - uni-app+Vue3实现)](#34-manager-mobile-移动管理端---uni-appvue3实现)
|
||||||
4. [数据流与交互机制](#4-数据流与交互机制)
|
4. [数据流与交互机制](#4-数据流与交互机制)
|
||||||
5. [核心功能概要](#5-核心功能概要)
|
5. [核心功能概要](#5-核心功能概要)
|
||||||
6. [部署与配置概述](#6-部署与配置概述)
|
6. [部署与配置概述](#6-部署与配置概述)
|
||||||
@@ -58,6 +59,14 @@
|
|||||||
* (潜在功能)监控系统运行状态、查看日志、进行故障排查等。
|
* (潜在功能)监控系统运行状态、查看日志、进行故障排查等。
|
||||||
* 与 `manager-api` 提供的所有后端管理功能进行全面的交互。
|
* 与 `manager-api` 提供的所有后端管理功能进行全面的交互。
|
||||||
|
|
||||||
|
5. **`manager-mobile` (智控台移动版 - uni-app实现):**
|
||||||
|
这是一个基于uni-app v3 + Vue 3 + Vite的跨端移动管理端,支持App(Android & iOS)和微信小程序。其主要能力包括:
|
||||||
|
* 提供移动设备上的便捷管理界面,与manager-web功能类似但针对移动端进行了优化。
|
||||||
|
* 支持用户登录、设备管理、AI服务配置等核心功能。
|
||||||
|
* 跨平台适配,一套代码可同时运行在iOS、Android和微信小程序上。
|
||||||
|
* 基于alova + @alova/adapter-uniapp实现网络请求,与manager-api无缝集成。
|
||||||
|
* 使用pinia进行状态管理,确保数据一致性。
|
||||||
|
|
||||||
**高层交互流程概述:**
|
**高层交互流程概述:**
|
||||||
|
|
||||||
* **语音交互主线:** **ESP32设备**捕捉到用户语音后,通过**WebSocket**将音频数据实时传输给**`xiaozhi-server`**。`xiaozhi-server`完成一系列AI处理(VAD、ASR、LLM交互、TTS)后,再通过WebSocket将合成的语音回复发送回ESP32设备进行播放。所有与语音直接相关的实时交互均在此链路完成。
|
* **语音交互主线:** **ESP32设备**捕捉到用户语音后,通过**WebSocket**将音频数据实时传输给**`xiaozhi-server`**。`xiaozhi-server`完成一系列AI处理(VAD、ASR、LLM交互、TTS)后,再通过WebSocket将合成的语音回复发送回ESP32设备进行播放。所有与语音直接相关的实时交互均在此链路完成。
|
||||||
@@ -71,6 +80,7 @@ xiaozhi-esp32-server
|
|||||||
├─ xiaozhi-server 8000 端口 Python语言开发 负责与esp32通信
|
├─ xiaozhi-server 8000 端口 Python语言开发 负责与esp32通信
|
||||||
├─ manager-web 8001 端口 Node.js+Vue开发 负责提供控制台的web界面
|
├─ manager-web 8001 端口 Node.js+Vue开发 负责提供控制台的web界面
|
||||||
├─ manager-api 8002 端口 Java语言开发 负责提供控制台的api
|
├─ manager-api 8002 端口 Java语言开发 负责提供控制台的api
|
||||||
|
└─ manager-mobile 跨平台移动应用 uni-app+Vue3开发 负责提供移动版智控台管理
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -294,6 +304,68 @@ xiaozhi-esp32-server
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 3.4. `manager-mobile` (智控台移动版 - uni-app实现)
|
||||||
|
|
||||||
|
`manager-mobile` 组件是一个基于uni-app v3 + Vue 3 + Vite的跨端移动管理端,支持App(Android & iOS)和微信小程序。它为系统管理员提供了移动端的管理界面,使得管理操作更加便捷。
|
||||||
|
|
||||||
|
* **核心目标:**
|
||||||
|
* 提供移动设备上的便捷管理界面,与manager-web功能类似但针对移动端进行了优化。
|
||||||
|
* 支持用户登录、设备管理、AI服务配置等核心功能。
|
||||||
|
* 跨平台适配,一套代码可同时运行在iOS、Android和微信小程序上。
|
||||||
|
* 为移动用户提供流畅、高效的管理体验。
|
||||||
|
|
||||||
|
* **平台兼容性:**
|
||||||
|
|
||||||
|
| H5 | iOS | Android | 微信小程序 |
|
||||||
|
| -- | --- | ------- | ---------- |
|
||||||
|
| √ | √ | √ | √ |
|
||||||
|
|
||||||
|
* **核心技术栈:**
|
||||||
|
* **uni-app v3:** 一个使用Vue.js开发所有前端应用的框架,支持iOS、Android、H5、以及各种小程序。
|
||||||
|
* **Vue 3:** 用于构建用户界面的渐进式框架,提供了更好的性能和新特性。
|
||||||
|
* **Vite:** 下一代前端开发与构建工具,提供极速的开发体验。
|
||||||
|
* **pnpm:** 快速、节省磁盘空间的包管理器。
|
||||||
|
* **alova:** 轻量级、灵活的请求策略库,搭配@alova/adapter-uniapp适配uni-app环境。
|
||||||
|
* **pinia:** Vue的状态管理库,替代Vuex,提供更简洁的API和更好的TypeScript支持。
|
||||||
|
* **UnoCSS:** 具有高性能且极具灵活性的即时原子化CSS引擎。
|
||||||
|
* **TypeScript:** 提供类型安全的开发体验。
|
||||||
|
|
||||||
|
* **关键实现细节:**
|
||||||
|
|
||||||
|
1. **跨平台架构:**
|
||||||
|
* 基于uni-app框架,实现了一套代码多端运行的目标,大幅减少了开发和维护成本。
|
||||||
|
* 针对不同平台的特性和限制,通过条件编译进行平台特定的代码处理。
|
||||||
|
|
||||||
|
2. **项目结构:**
|
||||||
|
* **`src/App.vue`:** 应用的根组件,定义了全局的样式和配置。
|
||||||
|
* **`src/main.ts`:** 应用的入口文件,负责初始化Vue实例、注册插件和路由拦截器。
|
||||||
|
* **`src/pages/`:** 存放应用的页面组件,如登录页、设备管理页等。
|
||||||
|
* **`src/layouts/`:** 定义应用的布局组件,如默认布局、带tabbar的布局等。
|
||||||
|
* **`src/api/`:** 封装与后端API的通信逻辑。
|
||||||
|
* **`src/store/`:** 使用pinia进行状态管理。
|
||||||
|
* **`src/components/`:** 存放可复用的组件。
|
||||||
|
* **`src/utils/`:** 提供通用的工具函数。
|
||||||
|
|
||||||
|
3. **网络请求:**
|
||||||
|
* 基于alova + @alova/adapter-uniapp实现网络请求,统一处理请求头、认证、错误等。
|
||||||
|
* 请求地址和环境配置通过.env文件管理,支持不同环境的切换。
|
||||||
|
|
||||||
|
4. **路由与鉴权:**
|
||||||
|
* 使用uni-app的路由系统,结合路由拦截器实现页面的登录验证和权限控制。
|
||||||
|
* 未登录用户访问需要认证的页面时,会被重定向到登录页。
|
||||||
|
|
||||||
|
5. **状态管理:**
|
||||||
|
* 使用pinia管理应用状态,如用户信息、设备列表等。
|
||||||
|
* 通过pinia-plugin-persistedstate插件实现状态的持久化存储。
|
||||||
|
|
||||||
|
6. **构建与发布:**
|
||||||
|
* 支持多种构建命令,如构建微信小程序、Android和iOS App等。
|
||||||
|
* 使用HBuilderX进行App的云打包,简化了打包流程。
|
||||||
|
|
||||||
|
`manager-mobile` 通过这些技术的应用,为用户提供了一个功能完备、体验流畅的移动端管理工具,使得管理员可以随时随地进行系统管理和配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 4. 数据流与交互机制
|
## 4. 数据流与交互机制
|
||||||
|
|
||||||
`xiaozhi-esp32-server` 系统通过各组件间定义清晰的数据流和交互协议来协同工作。主要的通信方式依赖于针对实时交互优化的WebSocket协议和适用于客户端-服务器请求的RESTful API。
|
`xiaozhi-esp32-server` 系统通过各组件间定义清晰的数据流和交互协议来协同工作。主要的通信方式依赖于针对实时交互优化的WebSocket协议和适用于客户端-服务器请求的RESTful API。
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* [3.1. `xiaozhi-server` (Core AI Engine - Python Implementation)](#31-xiaozhi-server-core-ai-engine---python-implementation)
|
* [3.1. `xiaozhi-server` (Core AI Engine - Python Implementation)](#31-xiaozhi-server-core-ai-engine---python-implementation)
|
||||||
* [3.2. `manager-api` (Management Backend - Java Spring Boot Implementation)](#32-manager-api-management-backend---java-spring-boot-implementation)
|
* [3.2. `manager-api` (Management Backend - Java Spring Boot Implementation)](#32-manager-api-management-backend---java-spring-boot-implementation)
|
||||||
* [3.3. `manager-web` (Web Management Frontend - Vue.js Implementation)](#33-manager-web-web-management-frontend---vuejs-implementation)
|
* [3.3. `manager-web` (Web Management Frontend - Vue.js Implementation)](#33-manager-web-web-management-frontend---vuejs-implementation)
|
||||||
|
* [3.4. `manager-mobile` (Mobile Management Console - uni-app Implementation)](#34-manager-mobile-mobile-management-console---uniapp-implementation)
|
||||||
4. [Data Flow and Interaction Mechanisms](#4-data-flow-and-interaction-mechanisms)
|
4. [Data Flow and Interaction Mechanisms](#4-data-flow-and-interaction-mechanisms)
|
||||||
5. [Key Features Summary](#5-key-features-summary)
|
5. [Key Features Summary](#5-key-features-summary)
|
||||||
6. [Deployment and Configuration Overview](#6-deployment-and-configuration-overview)
|
6. [Deployment and Configuration Overview](#6-deployment-and-configuration-overview)
|
||||||
@@ -72,6 +73,7 @@ xiaozhi-esp32-server
|
|||||||
├─ xiaozhi-server Port 8000 Python development Responsible for ESP32 communication
|
├─ xiaozhi-server Port 8000 Python development Responsible for ESP32 communication
|
||||||
├─ manager-web Port 8001 Node.js+Vue development Responsible for providing web interface for console
|
├─ manager-web Port 8001 Node.js+Vue development Responsible for providing web interface for console
|
||||||
├─ manager-api Port 8002 Java development Responsible for providing console API
|
├─ manager-api Port 8002 Java development Responsible for providing console API
|
||||||
|
└─ manager-mobile Cross-platform mobile application uni-app+Vue3 development Responsible for providing mobile console management
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -190,7 +192,70 @@ The `manager-web` is a Single Page Application (SPA) providing the administrativ
|
|||||||
4. **State Management (`src/store/index.js`):** Vuex manages global state (user info, device lists, etc.) via state, getters, mutations, and actions (often involving API calls).
|
4. **State Management (`src/store/index.js`):** Vuex manages global state (user info, device lists, etc.) via state, getters, mutations, and actions (often involving API calls).
|
||||||
5. **API Communication (`src/apis/`):** Modularized API service files make asynchronous calls to `manager-api`.
|
5. **API Communication (`src/apis/`):** Modularized API service files make asynchronous calls to `manager-api`.
|
||||||
6. **Build Process & PWA Features:** Vue CLI (Webpack) bundles assets. Workbox enables PWA features like caching.
|
6. **Build Process & PWA Features:** Vue CLI (Webpack) bundles assets. Workbox enables PWA features like caching.
|
||||||
7. **Environment Configuration (`.env` files):** Manages settings like the `manager-api` base URL for different environments.
|
7. **Environment Configuration (`.env` files):**
|
||||||
|
* The `.env` (and `.env.development`, `.env.production`, etc.) files in the project root directory are used to define environment variables. These variables (such as `VUE_APP_API_BASE_URL` to specify the base URL of `manager-api`) can be accessed in the application code through `process.env.VUE_APP_XXX`, allowing configuration of different parameters for different build environments (development, testing, production).
|
||||||
|
|
||||||
|
`manager-web` constructs a powerful, maintainable, and user-friendly management interface through the comprehensive application of these technologies, providing solid frontend support for the configuration and monitoring of the `xiaozhi-esp32-server` system.
|
||||||
|
|
||||||
|
### 3.4. `manager-mobile` (Mobile Management Console - uni-app Implementation)
|
||||||
|
|
||||||
|
The `manager-mobile` component is a cross-platform mobile management application based on uni-app v3 + Vue 3 + Vite, supporting App (Android & iOS) and WeChat Mini Program. It provides system administrators with a mobile management interface, making management operations more convenient.
|
||||||
|
|
||||||
|
* **Core Objectives:**
|
||||||
|
* Provide a convenient management interface on mobile devices, similar in functionality to manager-web but optimized for mobile platforms.
|
||||||
|
* Support core functions such as user login, device management, and AI service configuration.
|
||||||
|
* Cross-platform adaptation, allowing a single codebase to run on iOS, Android, and WeChat Mini Programs.
|
||||||
|
* Provide mobile users with a smooth and efficient management experience.
|
||||||
|
|
||||||
|
* **Platform Compatibility:**
|
||||||
|
|
||||||
|
| H5 | iOS | Android | WeChat Mini Program |
|
||||||
|
| -- | --- | ------- | ------------------ |
|
||||||
|
| × | √ | √ | √ |
|
||||||
|
|
||||||
|
* **Core Technologies:**
|
||||||
|
* **uni-app v3:** A framework for developing all frontend applications using Vue.js, supporting iOS, Android, H5, and various mini-programs.
|
||||||
|
* **Vue 3:** A progressive framework for building user interfaces, providing better performance and new features.
|
||||||
|
* **Vite:** The next generation frontend development and build tool, offering an extremely fast development experience.
|
||||||
|
* **pnpm:** A fast, disk space-efficient package manager.
|
||||||
|
* **alova:** A lightweight, flexible request strategy library, paired with @alova/adapter-uniapp to adapt to the uni-app environment.
|
||||||
|
* **pinia:** State management library for Vue, replacing Vuex, providing a simpler API and better TypeScript support.
|
||||||
|
* **UnoCSS:** A high-performance and extremely flexible instant atomic CSS engine.
|
||||||
|
* **TypeScript:** Provides a type-safe development experience.
|
||||||
|
|
||||||
|
* **Key Implementation Details:**
|
||||||
|
|
||||||
|
1. **Cross-Platform Architecture:**
|
||||||
|
* Based on the uni-app framework, achieving the goal of "write once, run anywhere," significantly reducing development and maintenance costs.
|
||||||
|
* Handling platform-specific code through conditional compilation to address the characteristics and limitations of different platforms.
|
||||||
|
|
||||||
|
2. **Project Structure:**
|
||||||
|
* **`src/App.vue`:** The root component of the application, defining global styles and configurations.
|
||||||
|
* **`src/main.ts`:** The entry file of the application, responsible for initializing the Vue instance, registering plugins, and setting up route interceptors.
|
||||||
|
* **`src/pages/`:** Stores application page components, such as login pages, device management pages, etc.
|
||||||
|
* **`src/layouts/`:** Defines application layout components, such as default layouts, layouts with tabbar, etc.
|
||||||
|
* **`src/api/`:** Encapsulates communication logic with backend APIs.
|
||||||
|
* **`src/store/`:** Uses pinia for state management.
|
||||||
|
* **`src/components/`:** Stores reusable components.
|
||||||
|
* **`src/utils/`:** Provides common utility functions.
|
||||||
|
|
||||||
|
3. **Network Requests:**
|
||||||
|
* Implements network requests based on alova + @alova/adapter-uniapp, unified handling of request headers, authentication, errors, etc.
|
||||||
|
* Request addresses and environment configurations are managed through .env files, supporting switching between different environments.
|
||||||
|
|
||||||
|
4. **Routing and Authentication:**
|
||||||
|
* Uses uni-app's routing system, combined with route interceptors to implement page login verification and permission control.
|
||||||
|
* When unlogged users access pages requiring authentication, they are redirected to the login page.
|
||||||
|
|
||||||
|
5. **State Management:**
|
||||||
|
* Uses pinia to manage application state, such as user information, device lists, etc.
|
||||||
|
* Implements persistent storage of state through the pinia-plugin-persistedstate plugin.
|
||||||
|
|
||||||
|
6. **Build and Release:**
|
||||||
|
* Supports multiple build commands, such as building WeChat Mini Programs, Android, and iOS Apps.
|
||||||
|
* Uses HBuilderX for cloud packaging of Apps, simplifying the packaging process.
|
||||||
|
|
||||||
|
`manager-mobile` provides users with a fully functional, smooth mobile management tool through the application of these technologies, allowing administrators to perform system management and configuration anytime, anywhere.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
<hutool.version>5.8.24</hutool.version>
|
<hutool.version>5.8.24</hutool.version>
|
||||||
<jsoup.version>1.19.1</jsoup.version>
|
<jsoup.version>1.19.1</jsoup.version>
|
||||||
<knife4j.version>4.6.0</knife4j.version>
|
<knife4j.version>4.6.0</knife4j.version>
|
||||||
|
<springdoc.version>2.8.8</springdoc.version>
|
||||||
|
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||||
<shiro.version>2.0.2</shiro.version>
|
<shiro.version>2.0.2</shiro.version>
|
||||||
<captcha.version>1.6.2</captcha.version>
|
<captcha.version>1.6.2</captcha.version>
|
||||||
<guava.version>33.0.0-jre</guava.version>
|
<guava.version>33.0.0-jre</guava.version>
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
<version>${captcha.version}</version>
|
<version>${captcha.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
@@ -190,11 +193,24 @@
|
|||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>${jsoup.version}</version>
|
<version>${jsoup.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!--knife4j -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.xingfudeshi</groupId>
|
<groupId>com.github.xingfudeshi</groupId>
|
||||||
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
|
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
|
||||||
<version>${knife4j.version}</version>
|
<version>${knife4j.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- springdoc -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>${springdoc.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- 日常工具包 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-lang3</artifactId>
|
||||||
|
<version>${commons-lang3.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ public interface Constant {
|
|||||||
/**
|
/**
|
||||||
* 版本号
|
* 版本号
|
||||||
*/
|
*/
|
||||||
public static final String VERSION = "0.7.3";
|
public static final String VERSION = "0.7.6";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 无效固件URL
|
* 无效固件URL
|
||||||
|
|||||||
@@ -341,6 +341,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
|||||||
* @return T 匹配 : F 不匹配
|
* @return T 匹配 : F 不匹配
|
||||||
*/
|
*/
|
||||||
private boolean validateLLMIntentParams(String llmModelId, String intentModelId) {
|
private boolean validateLLMIntentParams(String llmModelId, String intentModelId) {
|
||||||
|
if (StringUtils.isBlank(llmModelId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
ModelConfigEntity llmModelData = modelConfigService.selectById(llmModelId);
|
ModelConfigEntity llmModelData = modelConfigService.selectById(llmModelId);
|
||||||
String type = llmModelData.getConfigJson().get("type").toString();
|
String type = llmModelData.getConfigJson().get("type").toString();
|
||||||
// 如果查询大语言模型是openai或者ollama,意图识别选参数都可以
|
// 如果查询大语言模型是openai或者ollama,意图识别选参数都可以
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- 添加Index-TTS-vLLM流式TTS供应器
|
||||||
|
delete from `ai_model_provider` where id = 'SYSTEM_TTS_IndexStreamTTS';
|
||||||
|
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||||
|
('SYSTEM_TTS_IndexStreamTTS', 'TTS', 'index_stream', 'Index-TTS-vLLM流式语音合成', '[{"key":"api_url","label":"API服务地址","type":"string"},{"key":"voice","label":"默认音色","type":"string"},{"key":"audio_format","label":"音频格式","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 16, 1, NOW(), 1, NOW());
|
||||||
|
|
||||||
|
-- 添加Index-TTS-vLLM流式TTS模型配置
|
||||||
|
delete from `ai_model_config` where id = 'TTS_IndexStreamTTS';
|
||||||
|
INSERT INTO `ai_model_config` VALUES ('TTS_IndexStreamTTS', 'TTS', 'IndexStreamTTS', 'Index-TTS-vLLM流式语音合成', 0, 1, '{\"type\": \"index_stream\", \"api_url\": \"http://127.0.0.1:11996/tts\", \"voice\": \"jay_klee\", \"audio_format\": \"pcm\", \"output_dir\": \"tmp/\"}', NULL, NULL, 19, NULL, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
-- 更新Index-TTS-vLLM流式TTS配置说明
|
||||||
|
UPDATE `ai_model_config` SET
|
||||||
|
`doc_link` = 'https://github.com/Ksuriuri/index-tts-vllm',
|
||||||
|
`remark` = 'Index-TTS-vLLM流式TTS配置说明:
|
||||||
|
1. Index-TTS-vLLM是基于Index-TTS项目的vLLM推理服务,提供流式语音合成功能
|
||||||
|
2. 支持多种音色,音质自然,适合各种语音交互场景
|
||||||
|
3. 需要先部署Index-TTS-vLLM服务,然后配置API地址
|
||||||
|
4. 支持实时流式合成,具有较低的延迟
|
||||||
|
5. 支持自定义音色,可在项目assets文件夹下注册新音色
|
||||||
|
部署步骤:
|
||||||
|
1. 克隆项目:git clone https://github.com/Ksuriuri/index-tts-vllm.git
|
||||||
|
2. 安装依赖:pip install -r requirements.txt
|
||||||
|
3. 启动服务:python app.py
|
||||||
|
4. 服务默认运行在 http://127.0.0.1:11996
|
||||||
|
5. 如需其他音色,可到项目assets文件夹下注册
|
||||||
|
6. 支持多种音频格式:pcm、wav、mp3等
|
||||||
|
如需了解更多配置,请参考:https://github.com/Ksuriuri/index-tts-vllm/blob/master/README.md
|
||||||
|
' WHERE `id` = 'TTS_IndexStreamTTS';
|
||||||
|
|
||||||
|
-- 添加Index-TTS-vLLM流式TTS音色
|
||||||
|
delete from `ai_tts_voice` where tts_model_id = 'TTS_IndexStreamTTS';
|
||||||
|
-- 默认音色
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_IndexStreamTTS_0001', 'TTS_IndexStreamTTS', 'Jay Klee', 'jay_klee', '中文及中英文混合', NULL, NULL, NULL, NULL, 1, NULL, NULL, NULL, NULL);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 更新HuoshanDoubleStreamTTS供应器增加语速,音调等配置
|
||||||
|
UPDATE `ai_model_provider`
|
||||||
|
SET fields = '[{"key": "ws_url", "type": "string", "label": "WebSocket地址"}, {"key": "appid", "type": "string", "label": "应用ID"}, {"key": "access_token", "type": "string", "label": "访问令牌"}, {"key": "resource_id", "type": "string", "label": "资源ID"}, {"key": "speaker", "type": "string", "label": "默认音色"}, {"key": "speech_rate", "type": "number", "label": "语速(-50~100)"}, {"key": "loudness_rate", "type": "number", "label": "音量(-50~100)"}, {"key": "pitch", "type": "number", "label": "音高(-12~12)"}]'
|
||||||
|
WHERE id = 'SYSTEM_TTS_HSDSTTS';
|
||||||
|
|
||||||
|
UPDATE `ai_model_config` SET
|
||||||
|
`doc_link` = 'https://console.volcengine.com/speech/service/10007',
|
||||||
|
`remark` = '火山引擎语音合成服务配置说明:
|
||||||
|
1. 访问 https://www.volcengine.com/ 注册并开通火山引擎账号
|
||||||
|
2. 访问 https://console.volcengine.com/speech/service/10007 开通语音合成大模型,购买音色
|
||||||
|
3. 在页面底部获取appid和access_token
|
||||||
|
5. 资源ID固定为:volc.service_type.10029(大模型语音合成及混音)
|
||||||
|
6. 语速:-50~100,可不填,正常默认值0,可填-50~100
|
||||||
|
7. 音量:-50~100,可不填,正常默认值0,可填-50~100
|
||||||
|
8. 音高:-12~12,可不填,正常默认值0,可填-12~12
|
||||||
|
9. 填入配置文件中' WHERE `id` = 'TTS_HuoshanDoubleStreamTTS';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 添加 paddle_speech 流式 TTS 供应器
|
||||||
|
DELETE FROM `ai_model_provider` WHERE id = 'SYSTEM_TTS_PaddleSpeechTTS';
|
||||||
|
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`)
|
||||||
|
VALUES ('SYSTEM_TTS_PaddleSpeechTTS', 'TTS', 'paddle_speech', 'PaddleSpeechTTS',
|
||||||
|
'[{"key":"protocol","label":"协议类型","type":"string","options":["websocket","http"]},{"key":"url","label":"服务地址","type":"string"},{"key":"spk_id","label":"音色","type":"int"},{"key":"sample_rate","label":"采样率","type":"float"},{"key":"speed","label":"语速","type":"float"},{"key":"volume","label":"音量","type":"float"},{"key":"save_path","label":"保存路径","type":"string"}]',
|
||||||
|
17, 1, NOW(), 1, NOW());
|
||||||
|
|
||||||
|
-- 添加 paddle_speech 流式 TTS 模型配置
|
||||||
|
DELETE FROM `ai_model_config` WHERE id = 'TTS_PaddleSpeechTTS';
|
||||||
|
INSERT INTO `ai_model_config` VALUES ('TTS_PaddleSpeechTTS', 'TTS', 'PaddleSpeechTTS', 'PaddleSpeechTTS', 0, 1,
|
||||||
|
'{"type": "paddle_speech", "protocol": "websocket", "url": "ws://127.0.0.1:8092/paddlespeech/tts/streaming", "spk_id": "0", "sample_rate": 24000, "speed": 1.0, "volume": 1.0, "save_path": "./streaming_tts.wav"}',
|
||||||
|
NULL, NULL, 20, NULL, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
-- 更新 PaddleSpeechTTS 配置说明
|
||||||
|
UPDATE `ai_model_config` SET
|
||||||
|
`doc_link` = 'https://github.com/PaddlePaddle/PaddleSpeech',
|
||||||
|
`remark` = 'PaddleSpeechTTS 配置说明:
|
||||||
|
1. PaddleSpeech 是百度飞桨开源的语音合成工具,支持本地离线部署和模型训练。paddlepaddle百度飞浆框架地址:https://www.paddlepaddle.org.cn/
|
||||||
|
2. 支持 WebSocket 和 HTTP 协议,默认使用 WebSocket 进行流式传输(参考部署文档:https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/paddlespeech-deploy.md)。
|
||||||
|
3. 使用前要在本地部署 paddlespeech 服务,服务默认运行在 ws://127.0.0.1:8092/paddlespeech/tts/streaming
|
||||||
|
4. 支持自定义发音人、语速、音量和采样率。
|
||||||
|
' WHERE `id` = 'TTS_PaddleSpeechTTS';
|
||||||
|
|
||||||
|
-- 删除旧音色并添加默认音色
|
||||||
|
DELETE FROM `ai_tts_voice` WHERE tts_model_id = 'TTS_PaddleSpeechTTS';
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_PaddleSpeechTTS_0000', 'TTS_PaddleSpeechTTS', '默认', '0', '中文', NULL, NULL, NULL, NULL, 1, NULL, NULL, NULL, NULL);
|
||||||
@@ -282,3 +282,24 @@ databaseChangeLog:
|
|||||||
- sqlFile:
|
- sqlFile:
|
||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202507081646.sql
|
path: classpath:db/changelog/202507081646.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202508081701
|
||||||
|
author: hrz
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202508081701.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202508111734
|
||||||
|
author: RanChen
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202508111734.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202508131557
|
||||||
|
author: RanChen
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202508131557.sql
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<mapper namespace="xiaozhi.modules.security.dao.SysUserTokenDao">
|
<mapper namespace="xiaozhi.modules.security.dao.SysUserTokenDao">
|
||||||
|
|
||||||
<select id="getByToken" resultType="xiaozhi.modules.security.entity.SysUserTokenEntity">
|
<select id="getByToken" resultType="xiaozhi.modules.security.entity.SysUserTokenEntity">
|
||||||
select * from sys_user_token where token = #{value}
|
select * from sys_user_token where token = #{token}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="getByUserId" resultType="xiaozhi.modules.security.entity.SysUserTokenEntity">
|
<select id="getByUserId" resultType="xiaozhi.modules.security.entity.SysUserTokenEntity">
|
||||||
select * from sys_user_token where user_id = #{value}
|
select * from sys_user_token where user_id = #{userId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<update id="logout">
|
<update id="logout">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<!-- 根据参数编码,查询value -->
|
<!-- 根据参数编码,查询value -->
|
||||||
<select id="getValueByCode" resultType="String">
|
<select id="getValueByCode" resultType="String">
|
||||||
select param_value from sys_params where param_code = #{value}
|
select param_value from sys_params where param_code = #{paramCode}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 获取参数编码列表 -->
|
<!-- 获取参数编码列表 -->
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*] # 表示所有文件适用
|
||||||
|
charset = utf-8 # 设置文件字符集为 utf-8
|
||||||
|
indent_style = space # 缩进风格(tab | space)
|
||||||
|
indent_size = 2 # 缩进大小
|
||||||
|
end_of_line = lf # 控制换行类型(lf | cr | crlf)
|
||||||
|
trim_trailing_whitespace = true # 去除行首的任意空白字符
|
||||||
|
insert_final_newline = true # 始终在文件末尾插入一个新行
|
||||||
|
|
||||||
|
[*.md] # 表示仅 md 文件适用以下规则
|
||||||
|
max_line_length = off # 关闭最大行长度限制
|
||||||
|
trim_trailing_whitespace = false # 关闭末尾空格修剪
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.hbuilderx
|
||||||
|
|
||||||
|
.stylelintcache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
docs/.vitepress/dist
|
||||||
|
docs/.vitepress/cache
|
||||||
|
|
||||||
|
src/types
|
||||||
|
|
||||||
|
# lock 文件还是不要了,我主要的版本写死就好了
|
||||||
|
# pnpm-lock.yaml
|
||||||
|
# package-lock.json
|
||||||
|
|
||||||
|
# TIPS:如果某些文件已经加入了版本管理,现在重新加入 .gitignore 是不生效的,需要执行下面的操作
|
||||||
|
# `git rm -r --cached .` 然后提交 commit 即可。
|
||||||
|
|
||||||
|
# git rm -r --cached file1 file2 ## 针对某些文件
|
||||||
|
# git rm -r --cached dir1 dir2 ## 针对某些文件夹
|
||||||
|
# git rm -r --cached . ## 针对所有文件
|
||||||
|
|
||||||
|
# 更新 uni-app 官方版本
|
||||||
|
# npx @dcloudio/uvm@latest
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# registry = https://registry.npmjs.org
|
||||||
|
registry = https://registry.npmmirror.com
|
||||||
|
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
auto-install-peers=true
|
||||||
|
shamefully-hoist=true
|
||||||
|
ignore-workspace-root-check=true
|
||||||
|
install-workspace-root=true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Junsen
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
## 智控台移动版(manager-mobile)
|
||||||
|
基于 uni-app v3 + Vue 3 + Vite 的跨端移动管理端,支持 App(Android & iOS)和微信小程序。
|
||||||
|
|
||||||
|
### 平台兼容性
|
||||||
|
|
||||||
|
| H5 | iOS | Android | 微信小程序 |
|
||||||
|
| -- | --- | ------- | ---------- |
|
||||||
|
| √ | √ | √ | √ |
|
||||||
|
|
||||||
|
提示:不同 UI 组件在不同平台的适配度略有差异,请以对应组件库文档为准。
|
||||||
|
|
||||||
|
### 开发环境要求
|
||||||
|
- Node >= 18
|
||||||
|
- pnpm >= 7.30(建议使用项目中声明的 `pnpm@10.x`)
|
||||||
|
- 可选:HBuilderX(App 调试/打包)、微信开发者工具(微信小程序)
|
||||||
|
|
||||||
|
### 快速开始
|
||||||
|
1) 配置环境变量
|
||||||
|
- 复制 `env/.env.example` 为 `env/.env.development`
|
||||||
|
- 根据实际情况修改配置项(特别是 `VITE_SERVER_BASEURL`、`VITE_UNI_APPID`、`VITE_WX_APPID`)
|
||||||
|
|
||||||
|
2) 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm i
|
||||||
|
```
|
||||||
|
|
||||||
|
3) 本地开发(热更新)
|
||||||
|
- h5: `pnpm dev:h5`,然后观察启动日志显示的ip端口号
|
||||||
|
- 微信小程序:`pnpm dev:mp` 或 `pnpm dev:mp-weixin`,然后用微信开发者工具导入 `dist/dev/mp-weixin`
|
||||||
|
- App:用 HBuilderX 导入 `manager-mobile`,然后参考下面的教程就能运行了
|
||||||
|
|
||||||
|
### 环境变量与配置
|
||||||
|
项目使用自定义 `env` 目录存放环境文件,按 Vite 规范命名:`.env.development`、`.env.production` 等。
|
||||||
|
|
||||||
|
关键变量(部分):
|
||||||
|
- VITE_APP_TITLE:应用名称(写入 `manifest.config.ts`)
|
||||||
|
- VITE_UNI_APPID:uni-app 应用 appid(App)
|
||||||
|
- VITE_WX_APPID:微信小程序 appid(mp-weixin)
|
||||||
|
- VITE_FALLBACK_LOCALE:默认语言,如 `zh-Hans`
|
||||||
|
- VITE_SERVER_BASEURL:服务端基础地址(HTTP 请求 baseURL)
|
||||||
|
- VITE_DELETE_CONSOLE:构建时是否移除 console(`true`/`false`)
|
||||||
|
- VITE_SHOW_SOURCEMAP:是否生成 sourcemap(默认关闭)
|
||||||
|
- VITE_LOGIN_URL:未登录跳转的登录页路径(路由拦截器使用)
|
||||||
|
|
||||||
|
示例(`env/.env.development`):
|
||||||
|
```env
|
||||||
|
VITE_APP_TITLE=小智
|
||||||
|
VITE_FALLBACK_LOCALE=zh-Hans
|
||||||
|
VITE_UNI_APPID=
|
||||||
|
VITE_WX_APPID=
|
||||||
|
|
||||||
|
VITE_SERVER_BASEURL=http://localhost:8080
|
||||||
|
|
||||||
|
VITE_DELETE_CONSOLE=false
|
||||||
|
VITE_SHOW_SOURCEMAP=false
|
||||||
|
VITE_LOGIN_URL=/pages/login/index
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `manifest.config.ts` 会从 `env` 读取标题、appid、语言等配置。
|
||||||
|
|
||||||
|
### 重要注意事项
|
||||||
|
⚠️ **部署前必须修改的配置项:**
|
||||||
|
|
||||||
|
1. **应用 ID 配置**
|
||||||
|
- `VITE_UNI_APPID`:需要在 [DCloud 开发者中心](https://dev.dcloud.net.cn/) 创建应用并获取 AppID
|
||||||
|
- `VITE_WX_APPID`:需要在 [微信公众平台](https://mp.weixin.qq.com/) 注册小程序并获取 AppID
|
||||||
|
|
||||||
|
2. **服务端地址**
|
||||||
|
- `VITE_SERVER_BASEURL`:修改为您的实际服务端地址
|
||||||
|
|
||||||
|
3. **应用信息**
|
||||||
|
- `VITE_APP_TITLE`:修改为您的应用名称
|
||||||
|
- 更新 `src/static/logo.png` 等图标资源
|
||||||
|
|
||||||
|
4. **其他配置**
|
||||||
|
- 检查 `manifest.config.ts` 中的应用配置信息
|
||||||
|
- 根据需要修改 `src/layouts/fg-tabbar/tabbarList.ts` 中的 tabbar 配置
|
||||||
|
|
||||||
|
### 详细操作指南
|
||||||
|
|
||||||
|
#### 1. 获取 uni-app AppID
|
||||||
|

|
||||||
|
- 复制生成的 AppID 到环境变量 `VITE_UNI_APPID` 中
|
||||||
|
|
||||||
|
#### 2. 本地运行步骤
|
||||||
|

|
||||||
|
|
||||||
|
**App 本地调试:**
|
||||||
|
1. 用 HBuilderX 导入 `manager-mobile` 目录
|
||||||
|
2. 重新识别项目
|
||||||
|
3. 连接手机或使用模拟器进行真机调试
|
||||||
|
|
||||||
|
**项目识别问题解决:**
|
||||||
|

|
||||||
|
|
||||||
|
如果 HBuilderX 无法正确识别项目类型:
|
||||||
|
- 在项目根目录右键选择"重新识别项目类型"
|
||||||
|
- 确保项目被识别为 "uni-app" 项目
|
||||||
|
|
||||||
|
### 路由与鉴权
|
||||||
|
- 在 `src/main.ts` 中注册了路由拦截插件 `routeInterceptor`。
|
||||||
|
- 黑名单拦截:仅对配置为需要登录的页面进行校验(来源 `@/utils` 的 `getNeedLoginPages`)。
|
||||||
|
- 登录判断:基于用户信息(`pinia` 的 `useUserStore`),未登录将跳转到 `VITE_LOGIN_URL`,并附带重定向回原页面的参数。
|
||||||
|
|
||||||
|
### 网络请求
|
||||||
|
- 基于 `alova` + `@alova/adapter-uniapp`,统一在 `src/http/request/alova.ts` 创建实例。
|
||||||
|
- `baseURL` 读取环境配置(`getEnvBaseUrl`),可通过 `method.config.meta.domain` 动态切换域名。
|
||||||
|
- 认证:默认从本地 `token`(`uni.getStorageSync('token')`)注入 `Authorization` 头,缺失则重定向登录。
|
||||||
|
- 响应:统一处理 `statusCode !== 200` 的 HTTP 错误与业务 `code !== 0` 的错误;`401` 会清除 token 并跳转登录。
|
||||||
|
|
||||||
|
### 构建与发布
|
||||||
|
|
||||||
|
**微信小程序:**
|
||||||
|
1. 确保已配置正确的 `VITE_WX_APPID`
|
||||||
|
2. 运行 `pnpm build:mp`,产物在 `dist/build/mp-weixin`
|
||||||
|
3. 用微信开发者工具导入项目目录,并上传代码
|
||||||
|
4. 在微信公众平台提交审核
|
||||||
|
|
||||||
|
**Android & iOS App:**
|
||||||
|
|
||||||
|
#### 3. App 打包发行步骤
|
||||||
|
|
||||||
|
**步骤一:准备打包**
|
||||||
|

|
||||||
|
|
||||||
|
1. 确保已配置正确的 `VITE_UNI_APPID`
|
||||||
|
2. 运行 `pnpm build:app`,产物在 `dist/build/app`
|
||||||
|
3. 用 HBuilderX 导入项目目录
|
||||||
|
4. 在 HBuilderX 中点击"发行" → "原生App-云打包"
|
||||||
|
|
||||||
|
**步骤二:配置打包参数**
|
||||||
|

|
||||||
|
|
||||||
|
1. **应用图标和启动图**:上传应用图标和启动页面图片
|
||||||
|
2. **应用版本号**:设置版本号和版本名称
|
||||||
|
3. **签名证书**:
|
||||||
|
- Android:上传 keystore 证书文件
|
||||||
|
- iOS:配置开发者证书和描述文件
|
||||||
|
4. **包名配置**:设置应用的包名(Bundle ID)
|
||||||
|
5. **打包类型**:选择测试包或正式包
|
||||||
|
6. 点击"打包"开始云打包流程
|
||||||
|
|
||||||
|
**发布到应用商店:**
|
||||||
|
- **Android**:将生成的 APK 文件上传到各大 Android 应用市场
|
||||||
|
- **iOS**:将生成的 IPA 文件通过 App Store Connect 上传到 App Store(需要 Apple 开发者账号)
|
||||||
|
|
||||||
|
### 约定与工程化
|
||||||
|
- 页面与分包:由 `@uni-helper/vite-plugin-uni-pages` 与 `pages.config.ts` 统一生成;tabbar 配置在 `src/layouts/fg-tabbar/tabbarList.ts`。
|
||||||
|
- 组件与 hooks 自动导入:见 `vite.config.ts` 中 `unplugin-auto-import` 与 `@uni-helper/vite-plugin-uni-components`。
|
||||||
|
- 样式:使用 UnoCSS 与 `src/style/index.scss`。
|
||||||
|
- 状态管理:`pinia` + `pinia-plugin-persistedstate`。
|
||||||
|
- 代码规范:内置 `eslint`、`husky`、`lint-staged`,提交前自动格式化(`lint-staged`)。
|
||||||
|
|
||||||
|
### 常用脚本
|
||||||
|
```bash
|
||||||
|
# 开发
|
||||||
|
pnpm dev:mp # 等价 dev:mp-weixin
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
pnpm build:mp # 等价 build:mp-weixin
|
||||||
|
|
||||||
|
# 其他
|
||||||
|
pnpm type-check
|
||||||
|
pnpm lint && pnpm lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### License
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
VITE_APP_TITLE = '小智'
|
||||||
|
VITE_APP_PORT = 9000
|
||||||
|
|
||||||
|
VITE_UNI_APPID = '__UNI__36A515E'
|
||||||
|
VITE_WX_APPID = 'wxa2abb91f64032a2b'
|
||||||
|
|
||||||
|
# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base
|
||||||
|
VITE_APP_PUBLIC_BASE=/
|
||||||
|
|
||||||
|
# 登录页面
|
||||||
|
VITE_LOGIN_URL = '/pages/login/index'
|
||||||
|
# 第一个请求地址
|
||||||
|
VITE_SERVER_BASEURL = 'https://2662r3426b.vicp.fun/xiaozhi'
|
||||||
|
|
||||||
|
VITE_UPLOAD_BASEURL = '/otaMag/upload'
|
||||||
|
|
||||||
|
# h5是否需要配置代理
|
||||||
|
VITE_APP_PROXY=true
|
||||||
|
VITE_APP_PROXY_PREFIX = '/xiaozhi'
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||||
|
NODE_ENV = 'development'
|
||||||
|
# 是否去除console 和 debugger
|
||||||
|
VITE_DELETE_CONSOLE = false
|
||||||
|
# 是否开启sourcemap
|
||||||
|
VITE_SHOW_SOURCEMAP = true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||||
|
NODE_ENV = 'development'
|
||||||
|
# 是否去除console 和 debugger
|
||||||
|
VITE_DELETE_CONSOLE = true
|
||||||
|
# 是否开启sourcemap
|
||||||
|
VITE_SHOW_SOURCEMAP = false
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||||
|
NODE_ENV = 'development'
|
||||||
|
# 是否去除console 和 debugger
|
||||||
|
VITE_DELETE_CONSOLE = false
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import uniHelper from '@uni-helper/eslint-config'
|
||||||
|
|
||||||
|
export default uniHelper({
|
||||||
|
unocss: true,
|
||||||
|
vue: true,
|
||||||
|
markdown: false,
|
||||||
|
ignores: [
|
||||||
|
'src/uni_modules/',
|
||||||
|
'dist',
|
||||||
|
// unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用
|
||||||
|
'auto-import.d.ts',
|
||||||
|
// vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore
|
||||||
|
'uni-pages.d.ts',
|
||||||
|
// 插件生成的文件
|
||||||
|
'src/pages.json',
|
||||||
|
'src/manifest.json',
|
||||||
|
// 忽略自动生成文件
|
||||||
|
'src/service/app/**',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'vue/no-unused-refs': 'off',
|
||||||
|
'unused-imports/no-unused-vars': 'off',
|
||||||
|
'eslint-comments/no-unlimited-disable': 'off',
|
||||||
|
'jsdoc/check-param-names': 'off',
|
||||||
|
'jsdoc/require-returns-description': 'off',
|
||||||
|
'ts/no-empty-object-type': 'off',
|
||||||
|
'no-extend-native': 'off',
|
||||||
|
},
|
||||||
|
formatters: {
|
||||||
|
/**
|
||||||
|
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
|
||||||
|
* By default uses Prettier
|
||||||
|
*/
|
||||||
|
css: true,
|
||||||
|
/**
|
||||||
|
* Format HTML files
|
||||||
|
* By default uses Prettier
|
||||||
|
*/
|
||||||
|
html: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html build-time="%BUILD_TIME%">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
<script>
|
||||||
|
var coverSupport =
|
||||||
|
'CSS' in window &&
|
||||||
|
typeof CSS.supports === 'function' &&
|
||||||
|
(CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
||||||
|
document.write(
|
||||||
|
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||||
|
(coverSupport ? ', viewport-fit=cover' : '') +
|
||||||
|
'" />',
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
<title>unibest</title>
|
||||||
|
<!--preload-links-->
|
||||||
|
<!--app-context-->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"><!--app-html--></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
// manifest.config.ts
|
||||||
|
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
|
||||||
|
import { loadEnv } from 'vite'
|
||||||
|
|
||||||
|
// 手动解析命令行参数获取 mode
|
||||||
|
function getMode() {
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const modeFlagIndex = args.findIndex(arg => arg === '--mode')
|
||||||
|
return modeFlagIndex !== -1 ? args[modeFlagIndex + 1] : args[0] === 'build' ? 'production' : 'development' // 默认 development
|
||||||
|
}
|
||||||
|
// 获取环境变量的范例
|
||||||
|
const env = loadEnv(getMode(), path.resolve(process.cwd(), 'env'))
|
||||||
|
const {
|
||||||
|
VITE_APP_TITLE,
|
||||||
|
VITE_UNI_APPID,
|
||||||
|
VITE_WX_APPID,
|
||||||
|
VITE_APP_PUBLIC_BASE,
|
||||||
|
VITE_FALLBACK_LOCALE,
|
||||||
|
} = env
|
||||||
|
|
||||||
|
export default defineManifestConfig({
|
||||||
|
'name': VITE_APP_TITLE,
|
||||||
|
'appid': VITE_UNI_APPID,
|
||||||
|
'description': '',
|
||||||
|
'versionName': '1.0.0',
|
||||||
|
'versionCode': '100',
|
||||||
|
'transformPx': false,
|
||||||
|
'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
|
||||||
|
'h5': {
|
||||||
|
router: {
|
||||||
|
// base: VITE_APP_PUBLIC_BASE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/* 5+App特有相关 */
|
||||||
|
'app-plus': {
|
||||||
|
usingComponents: true,
|
||||||
|
nvueStyleCompiler: 'uni-app',
|
||||||
|
compilerVersion: 3,
|
||||||
|
compatible: {
|
||||||
|
ignoreVersion: true,
|
||||||
|
},
|
||||||
|
splashscreen: {
|
||||||
|
alwaysShowBeforeRender: true,
|
||||||
|
waiting: true,
|
||||||
|
autoclose: true,
|
||||||
|
delay: 0,
|
||||||
|
},
|
||||||
|
/* 模块配置 */
|
||||||
|
modules: {},
|
||||||
|
/* 应用发布信息 */
|
||||||
|
distribute: {
|
||||||
|
/* android打包配置 */
|
||||||
|
android: {
|
||||||
|
minSdkVersion: 30,
|
||||||
|
targetSdkVersion: 30,
|
||||||
|
abiFilters: ['armeabi-v7a', 'arm64-v8a'],
|
||||||
|
permissions: [
|
||||||
|
'<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
|
||||||
|
'<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',
|
||||||
|
'<uses-permission android:name="android.permission.VIBRATE"/>',
|
||||||
|
'<uses-permission android:name="android.permission.READ_LOGS"/>',
|
||||||
|
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>',
|
||||||
|
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
|
||||||
|
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
|
||||||
|
'<uses-permission android:name="android.permission.CAMERA"/>',
|
||||||
|
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
|
||||||
|
'<uses-permission android:name="android.permission.READ_PHONE_STATE"/>',
|
||||||
|
'<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>',
|
||||||
|
'<uses-permission android:name="android.permission.WAKE_LOCK"/>',
|
||||||
|
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
|
||||||
|
'<uses-feature android:name="android.hardware.camera"/>',
|
||||||
|
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
|
||||||
|
'<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>',
|
||||||
|
'<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
/* ios打包配置 */
|
||||||
|
ios: {},
|
||||||
|
/* SDK配置 */
|
||||||
|
sdkConfigs: {},
|
||||||
|
/* 图标配置 */
|
||||||
|
icons: {
|
||||||
|
android: {
|
||||||
|
hdpi: 'unpackage/res/icons/72x72.png',
|
||||||
|
xhdpi: 'unpackage/res/icons/96x96.png',
|
||||||
|
xxhdpi: 'unpackage/res/icons/144x144.png',
|
||||||
|
xxxhdpi: 'unpackage/res/icons/192x192.png',
|
||||||
|
},
|
||||||
|
ios: {
|
||||||
|
appstore: 'unpackage/res/icons/1024x1024.png',
|
||||||
|
ipad: {
|
||||||
|
'app': 'unpackage/res/icons/76x76.png',
|
||||||
|
'app@2x': 'unpackage/res/icons/152x152.png',
|
||||||
|
'notification': 'unpackage/res/icons/20x20.png',
|
||||||
|
'notification@2x': 'unpackage/res/icons/40x40.png',
|
||||||
|
'proapp@2x': 'unpackage/res/icons/167x167.png',
|
||||||
|
'settings': 'unpackage/res/icons/29x29.png',
|
||||||
|
'settings@2x': 'unpackage/res/icons/58x58.png',
|
||||||
|
'spotlight': 'unpackage/res/icons/40x40.png',
|
||||||
|
'spotlight@2x': 'unpackage/res/icons/80x80.png',
|
||||||
|
},
|
||||||
|
iphone: {
|
||||||
|
'app@2x': 'unpackage/res/icons/120x120.png',
|
||||||
|
'app@3x': 'unpackage/res/icons/180x180.png',
|
||||||
|
'notification@2x': 'unpackage/res/icons/40x40.png',
|
||||||
|
'notification@3x': 'unpackage/res/icons/60x60.png',
|
||||||
|
'settings@2x': 'unpackage/res/icons/58x58.png',
|
||||||
|
'settings@3x': 'unpackage/res/icons/87x87.png',
|
||||||
|
'spotlight@2x': 'unpackage/res/icons/80x80.png',
|
||||||
|
'spotlight@3x': 'unpackage/res/icons/120x120.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/* 快应用特有相关 */
|
||||||
|
'quickapp': {},
|
||||||
|
/* 小程序特有相关 */
|
||||||
|
'mp-weixin': {
|
||||||
|
appid: VITE_WX_APPID,
|
||||||
|
setting: {
|
||||||
|
urlCheck: false,
|
||||||
|
// 是否启用 ES6 转 ES5
|
||||||
|
es6: true,
|
||||||
|
minified: true,
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
subPackages: true,
|
||||||
|
},
|
||||||
|
usingComponents: true,
|
||||||
|
// __usePrivacyCheck__: true,
|
||||||
|
permission: {
|
||||||
|
'scope.userLocation': {
|
||||||
|
desc: 'WiFi配网功能需要获取位置权限',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requiredPrivateInfos: ['getLocation'],
|
||||||
|
},
|
||||||
|
'mp-alipay': {
|
||||||
|
usingComponents: true,
|
||||||
|
styleIsolation: 'shared',
|
||||||
|
},
|
||||||
|
'mp-baidu': {
|
||||||
|
usingComponents: true,
|
||||||
|
},
|
||||||
|
'mp-toutiao': {
|
||||||
|
usingComponents: true,
|
||||||
|
},
|
||||||
|
'uniStatistics': {
|
||||||
|
enable: false,
|
||||||
|
},
|
||||||
|
'vueVersion': '3',
|
||||||
|
})
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
{
|
||||||
|
"name": "xiaozhi-mobile-admin",
|
||||||
|
"type": "commonjs",
|
||||||
|
"version": "3.4.0",
|
||||||
|
"unibest-version": "3.4.0",
|
||||||
|
"packageManager": "pnpm@10.10.0",
|
||||||
|
"description": "xiaozhi-esp32-server的移动端管理后台",
|
||||||
|
"generate-time": "2025-08-04",
|
||||||
|
"author": {
|
||||||
|
"name": "huangjunsen",
|
||||||
|
"zhName": "黄俊森",
|
||||||
|
"email": "huangjunsen@xiaozhi.com",
|
||||||
|
"github": "https://github.com/huangjunsen0406",
|
||||||
|
"gitee": "https://gitee.com/huang-jun-sen"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/xinnan-tech/xiaozhi-esp32-server",
|
||||||
|
"repository": "https://github.com/xinnan-tech/xiaozhi-esp32-server",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/xinnan-tech/xiaozhi-esp32-server/issues"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18",
|
||||||
|
"pnpm": ">=7.30"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"preinstall": "npx only-allow pnpm",
|
||||||
|
"uvm": "npx @dcloudio/uvm@latest",
|
||||||
|
"uvm-rm": "node ./scripts/postupgrade.js",
|
||||||
|
"postuvm": "echo upgrade uni-app success!",
|
||||||
|
"dev:app": "uni -p app",
|
||||||
|
"dev:app-android": "uni -p app-android",
|
||||||
|
"dev:app-ios": "uni -p app-ios",
|
||||||
|
"dev:custom": "uni -p",
|
||||||
|
"dev": "uni",
|
||||||
|
"dev:h5": "uni",
|
||||||
|
"dev:h5:ssr": "uni --ssr",
|
||||||
|
"dev:mp": "uni -p mp-weixin",
|
||||||
|
"dev:mp-alipay": "uni -p mp-alipay",
|
||||||
|
"dev:mp-baidu": "uni -p mp-baidu",
|
||||||
|
"dev:mp-jd": "uni -p mp-jd",
|
||||||
|
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||||
|
"dev:mp-lark": "uni -p mp-lark",
|
||||||
|
"dev:mp-qq": "uni -p mp-qq",
|
||||||
|
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||||
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
|
"dev:mp-xhs": "uni -p mp-xhs",
|
||||||
|
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||||
|
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||||
|
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||||
|
"build:app": "uni build -p app",
|
||||||
|
"build:app-android": "uni build -p app-android",
|
||||||
|
"build:app-ios": "uni build -p app-ios",
|
||||||
|
"build:custom": "uni build -p",
|
||||||
|
"build:h5": "uni build",
|
||||||
|
"build": "uni build",
|
||||||
|
"build:h5:ssr": "uni build --ssr",
|
||||||
|
"build:mp-alipay": "uni build -p mp-alipay",
|
||||||
|
"build:mp": "uni build -p mp-weixin",
|
||||||
|
"build:mp-baidu": "uni build -p mp-baidu",
|
||||||
|
"build:mp-jd": "uni build -p mp-jd",
|
||||||
|
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||||
|
"build:mp-lark": "uni build -p mp-lark",
|
||||||
|
"build:mp-qq": "uni build -p mp-qq",
|
||||||
|
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||||
|
"build:mp-weixin": "uni build -p mp-weixin",
|
||||||
|
"build:mp-xhs": "uni build -p mp-xhs",
|
||||||
|
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||||
|
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||||
|
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||||
|
"type-check": "vue-tsc --noEmit",
|
||||||
|
"openapi-ts-request": "openapi-ts",
|
||||||
|
"prepare": "git init && husky",
|
||||||
|
"lint": "eslint",
|
||||||
|
"lint:fix": "eslint --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@alova/adapter-uniapp": "^2.0.14",
|
||||||
|
"@alova/shared": "^1.3.1",
|
||||||
|
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-app-harmony": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-alipay": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-baidu": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-harmony": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-jd": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-kuaishou": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-lark": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-qq": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-toutiao": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-xhs": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||||
|
"@tanstack/vue-query": "^5.62.16",
|
||||||
|
"abortcontroller-polyfill": "^1.7.8",
|
||||||
|
"alova": "^3.3.3",
|
||||||
|
"dayjs": "1.11.10",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"pinia": "2.0.36",
|
||||||
|
"pinia-plugin-persistedstate": "3.2.1",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"wot-design-uni": "^1.9.1",
|
||||||
|
"z-paging": "2.8.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@alova/wormhole": "^1.0.8",
|
||||||
|
"@antfu/eslint-config": "^4.15.0",
|
||||||
|
"@commitlint/cli": "^19.8.1",
|
||||||
|
"@commitlint/config-conventional": "^19.8.1",
|
||||||
|
"@dcloudio/types": "^3.4.8",
|
||||||
|
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-uts-v1": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||||
|
"@esbuild/darwin-arm64": "0.20.2",
|
||||||
|
"@esbuild/darwin-x64": "0.20.2",
|
||||||
|
"@iconify-json/carbon": "^1.2.4",
|
||||||
|
"@rollup/rollup-darwin-x64": "^4.28.0",
|
||||||
|
"@types/node": "^20.17.9",
|
||||||
|
"@types/wechat-miniprogram": "^3.4.8",
|
||||||
|
"@uni-helper/eslint-config": "^0.4.0",
|
||||||
|
"@uni-helper/uni-types": "1.0.0-alpha.3",
|
||||||
|
"@uni-helper/unocss-preset-uni": "^0.2.11",
|
||||||
|
"@uni-helper/vite-plugin-uni-components": "0.2.0",
|
||||||
|
"@uni-helper/vite-plugin-uni-layouts": "0.1.10",
|
||||||
|
"@uni-helper/vite-plugin-uni-manifest": "0.2.8",
|
||||||
|
"@uni-helper/vite-plugin-uni-pages": "0.2.28",
|
||||||
|
"@uni-helper/vite-plugin-uni-platform": "0.0.4",
|
||||||
|
"@uni-ku/bundle-optimizer": "^1.3.3",
|
||||||
|
"@unocss/eslint-plugin": "^66.2.3",
|
||||||
|
"@unocss/preset-legacy-compat": "^0.59.4",
|
||||||
|
"@vue/runtime-core": "^3.4.21",
|
||||||
|
"@vue/tsconfig": "^0.1.3",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.29.0",
|
||||||
|
"eslint-plugin-format": "^1.0.1",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^15.2.10",
|
||||||
|
"openapi-ts-request": "^1.1.2",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"postcss-html": "^1.7.0",
|
||||||
|
"postcss-scss": "^4.0.9",
|
||||||
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
|
"sass": "1.77.8",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"unocss": "65.4.2",
|
||||||
|
"unplugin-auto-import": "^0.17.8",
|
||||||
|
"vite": "5.2.8",
|
||||||
|
"vite-plugin-restart": "^0.4.2",
|
||||||
|
"vue-tsc": "^2.2.10"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"bin-wrapper": "npm:bin-wrapper-china"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*": "eslint --fix"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
|
||||||
|
import { tabBar } from './src/layouts/fg-tabbar/tabbarList'
|
||||||
|
|
||||||
|
export default defineUniPages({
|
||||||
|
globalStyle: {
|
||||||
|
navigationStyle: 'default',
|
||||||
|
navigationBarTitleText: '小智',
|
||||||
|
navigationBarBackgroundColor: '#f8f8f8',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
easycom: {
|
||||||
|
autoscan: true,
|
||||||
|
custom: {
|
||||||
|
'^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
|
||||||
|
'^wd-(.*)': 'wot-design-uni/components/wd-$1/wd-$1.vue',
|
||||||
|
'^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
|
||||||
|
'z-paging/components/z-paging$1/z-paging$1.vue',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// tabbar 的配置统一在 "./src/layouts/fg-tabbar/tabbarList.ts" 文件中
|
||||||
|
tabBar: tabBar as any,
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
diff --git a/dist/uni-h5.es.js b/dist/uni-h5.es.js
|
||||||
|
index 7421bad97d94ad34a3d4d94292a9ee9071430662..19c6071ee4036ceb8d1cfa09030e471c002d2cda 100644
|
||||||
|
--- a/dist/uni-h5.es.js
|
||||||
|
+++ b/dist/uni-h5.es.js
|
||||||
|
@@ -23410,7 +23410,7 @@ function useShowTabBar(emit2) {
|
||||||
|
const tabBar2 = useTabBar();
|
||||||
|
const showTabBar2 = computed(() => route.meta.isTabBar && tabBar2.shown);
|
||||||
|
updateCssVar({
|
||||||
|
- "--tab-bar-height": tabBar2.height
|
||||||
|
+ "--tab-bar-height": tabBar2?.height || 0
|
||||||
|
});
|
||||||
|
return showTabBar2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- '**'
|
||||||
|
- '!node_modules'
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
'@dcloudio/uni-h5': patches/@dcloudio__uni-h5.patch
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// # 执行 `pnpm upgrade` 后会升级 `uniapp` 相关依赖
|
||||||
|
// # 在升级完后,会自动添加很多无用依赖,这需要删除以减小依赖包体积
|
||||||
|
// # 只需要执行下面的命令即可
|
||||||
|
|
||||||
|
const { exec } = require('node:child_process')
|
||||||
|
|
||||||
|
// 定义要执行的命令
|
||||||
|
const dependencies = [
|
||||||
|
'@dcloudio/uni-app-harmony',
|
||||||
|
// TODO: 如果不需要某个平台的小程序,请手动删除或注释掉
|
||||||
|
'@dcloudio/uni-mp-alipay',
|
||||||
|
'@dcloudio/uni-mp-baidu',
|
||||||
|
'@dcloudio/uni-mp-jd',
|
||||||
|
'@dcloudio/uni-mp-kuaishou',
|
||||||
|
'@dcloudio/uni-mp-lark',
|
||||||
|
'@dcloudio/uni-mp-qq',
|
||||||
|
'@dcloudio/uni-mp-toutiao',
|
||||||
|
'@dcloudio/uni-mp-xhs',
|
||||||
|
'@dcloudio/uni-quickapp-webview',
|
||||||
|
// i18n模板要注释掉下面的
|
||||||
|
'vue-i18n',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 使用exec执行命令
|
||||||
|
exec(`pnpm un ${dependencies.join(' ')}`, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
// 如果有错误,打印错误信息
|
||||||
|
console.error(`执行出错: ${error}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 打印正常输出
|
||||||
|
console.log(`stdout: ${stdout}`)
|
||||||
|
// 如果有错误输出,也打印出来
|
||||||
|
console.error(`stderr: ${stderr}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
|
||||||
|
import { usePageAuth } from '@/hooks/usePageAuth'
|
||||||
|
import { useConfigStore } from '@/store'
|
||||||
|
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
|
||||||
|
|
||||||
|
usePageAuth()
|
||||||
|
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
onLaunch(() => {
|
||||||
|
console.log('App Launch')
|
||||||
|
// 获取公共配置
|
||||||
|
configStore.fetchPublicConfig().catch((error) => {
|
||||||
|
console.error('获取公共配置失败:', error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
onShow(() => {
|
||||||
|
console.log('App Show')
|
||||||
|
})
|
||||||
|
onHide(() => {
|
||||||
|
console.log('App Hide')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
swiper,
|
||||||
|
scroll-view {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
AgentCreateData,
|
||||||
|
AgentDetail,
|
||||||
|
ModelOption,
|
||||||
|
RoleTemplate,
|
||||||
|
} from './types'
|
||||||
|
import { http } from '@/http/request/alova'
|
||||||
|
|
||||||
|
// 获取智能体详情
|
||||||
|
export function getAgentDetail(id: string) {
|
||||||
|
return http.Get<AgentDetail>(`/agent/${id}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取角色模板列表
|
||||||
|
export function getRoleTemplates() {
|
||||||
|
return http.Get<RoleTemplate[]>('/agent/template', {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型选项
|
||||||
|
export function getModelOptions(modelType: string, modelName: string = '') {
|
||||||
|
return http.Get<ModelOption[]>('/models/names', {
|
||||||
|
params: {
|
||||||
|
modelType,
|
||||||
|
modelName,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取智能体列表
|
||||||
|
export function getAgentList() {
|
||||||
|
return http.Get<Agent[]>('/agent/list', {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建智能体
|
||||||
|
export function createAgent(data: AgentCreateData) {
|
||||||
|
return http.Post<string>('/agent', data, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除智能体
|
||||||
|
export function deleteAgent(id: string) {
|
||||||
|
return http.Delete(`/agent/${id}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取TTS音色列表
|
||||||
|
export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
|
||||||
|
return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, {
|
||||||
|
params: {
|
||||||
|
voiceName,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新智能体
|
||||||
|
export function updateAgent(id: string, data: Partial<AgentDetail>) {
|
||||||
|
return http.Put(`/agent/${id}`, data, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取插件列表
|
||||||
|
export function getPluginFunctions() {
|
||||||
|
return http.Get<any[]>(`/models/provider/plugin/names`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取mcp接入点
|
||||||
|
export function getMcpAddress(agentId: string) {
|
||||||
|
return http.Get<string>(`/agent/mcp/address/${agentId}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取mcp工具
|
||||||
|
export function getMcpTools(agentId: string) {
|
||||||
|
return http.Get<string[]>(`/agent/mcp/tools/${agentId}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取声纹列表
|
||||||
|
export function getVoicePrintList(agentId: string) {
|
||||||
|
return http.Get<any[]>(`/agent/voice-print/list/${agentId}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取语音对话记录
|
||||||
|
export function getChatHistoryUser(agentId: string) {
|
||||||
|
return http.Get<any[]>(`/agent/${agentId}/chat-history/user`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增声纹说话人
|
||||||
|
export function createVoicePrint(data: { agentId: string, audioId: string, sourceName: string, introduce: string }) {
|
||||||
|
return http.Post('/agent/voice-print', data, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// 智能体列表数据类型
|
||||||
|
export interface Agent {
|
||||||
|
id: string
|
||||||
|
agentName: string
|
||||||
|
ttsModelName: string
|
||||||
|
ttsVoiceName: string
|
||||||
|
llmModelName: string
|
||||||
|
vllmModelName: string
|
||||||
|
memModelId: string
|
||||||
|
systemPrompt: string
|
||||||
|
summaryMemory: string | null
|
||||||
|
lastConnectedAt: string | null
|
||||||
|
deviceCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能体创建数据类型
|
||||||
|
export interface AgentCreateData {
|
||||||
|
agentName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能体详情数据类型
|
||||||
|
export interface AgentDetail {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
agentCode: string
|
||||||
|
agentName: string
|
||||||
|
asrModelId: string
|
||||||
|
vadModelId: string
|
||||||
|
llmModelId: string
|
||||||
|
vllmModelId: string
|
||||||
|
ttsModelId: string
|
||||||
|
ttsVoiceId: string
|
||||||
|
memModelId: string
|
||||||
|
intentModelId: string
|
||||||
|
chatHistoryConf: number
|
||||||
|
systemPrompt: string
|
||||||
|
summaryMemory: string
|
||||||
|
langCode: string
|
||||||
|
language: string
|
||||||
|
sort: number
|
||||||
|
creator: string
|
||||||
|
createdAt: string
|
||||||
|
updater: string
|
||||||
|
updatedAt: string
|
||||||
|
functions: AgentFunction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentFunction {
|
||||||
|
id?: string
|
||||||
|
agentId?: string
|
||||||
|
pluginId: string
|
||||||
|
paramInfo: Record<string, string | number | boolean> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色模板数据类型
|
||||||
|
export interface RoleTemplate {
|
||||||
|
id: string
|
||||||
|
agentCode: string
|
||||||
|
agentName: string
|
||||||
|
asrModelId: string
|
||||||
|
vadModelId: string
|
||||||
|
llmModelId: string
|
||||||
|
vllmModelId: string
|
||||||
|
ttsModelId: string
|
||||||
|
ttsVoiceId: string
|
||||||
|
memModelId: string
|
||||||
|
intentModelId: string
|
||||||
|
chatHistoryConf: number
|
||||||
|
systemPrompt: string
|
||||||
|
summaryMemory: string
|
||||||
|
langCode: string
|
||||||
|
language: string
|
||||||
|
sort: number
|
||||||
|
creator: string
|
||||||
|
createdAt: string
|
||||||
|
updater: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型选项数据类型
|
||||||
|
export interface ModelOption {
|
||||||
|
id: string
|
||||||
|
modelName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginField {
|
||||||
|
key: string
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
default: string
|
||||||
|
selected?: boolean
|
||||||
|
editing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDefinition {
|
||||||
|
id: string
|
||||||
|
modelType: string
|
||||||
|
providerCode: string
|
||||||
|
name: string
|
||||||
|
fields: PluginField[] // 注意:原始是字符串,需要先 JSON.parse
|
||||||
|
sort: number
|
||||||
|
updater: string
|
||||||
|
updateDate: string
|
||||||
|
creator: string
|
||||||
|
createDate: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { http } from '@/http/request/alova'
|
||||||
|
|
||||||
|
// 登录接口数据类型
|
||||||
|
export interface LoginData {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
captcha: string
|
||||||
|
captchaId: string
|
||||||
|
areaCode?: string
|
||||||
|
mobile?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录响应数据类型
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
expire: number
|
||||||
|
clientHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证码响应数据类型
|
||||||
|
export interface CaptchaResponse {
|
||||||
|
captchaId: string
|
||||||
|
captchaImage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取验证码
|
||||||
|
export function getCaptcha(uuid: string) {
|
||||||
|
return http.Get<string>('/user/captcha', {
|
||||||
|
params: { uuid },
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: true,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户登录
|
||||||
|
export function login(data: LoginData) {
|
||||||
|
return http.Post<LoginResponse>('/user/login', data, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: true,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户信息响应数据类型
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
realName: string
|
||||||
|
email: string
|
||||||
|
mobile: string
|
||||||
|
status: number
|
||||||
|
superAdmin: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公共配置响应数据类型
|
||||||
|
export interface PublicConfig {
|
||||||
|
enableMobileRegister: boolean
|
||||||
|
version: string
|
||||||
|
year: string
|
||||||
|
allowUserRegister: boolean
|
||||||
|
mobileAreaList: Array<{
|
||||||
|
name: string
|
||||||
|
key: string
|
||||||
|
}>
|
||||||
|
beianIcpNum: string
|
||||||
|
beianGaNum: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
export function getUserInfo() {
|
||||||
|
return http.Get<UserInfo>('/user/info', {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取公共配置
|
||||||
|
export function getPublicConfig() {
|
||||||
|
return http.Get<PublicConfig>('/user/pub-config', {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: true,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册数据类型
|
||||||
|
export interface RegisterData {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
captcha: string
|
||||||
|
captchaId: string
|
||||||
|
areaCode: string
|
||||||
|
mobile: string
|
||||||
|
mobileCaptcha: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送短信验证码
|
||||||
|
export function sendSmsCode(data: {
|
||||||
|
phone: string
|
||||||
|
captcha: string
|
||||||
|
captchaId: string
|
||||||
|
}) {
|
||||||
|
return http.Post('/user/smsVerification', data, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: true,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户注册
|
||||||
|
export function register(data: RegisterData) {
|
||||||
|
return http.Post('/user/register', data, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: true,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
ChatMessage,
|
||||||
|
ChatSessionsResponse,
|
||||||
|
GetSessionsParams,
|
||||||
|
} from './types'
|
||||||
|
import { http } from '@/http/request/alova'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天会话列表
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
* @param params 分页参数
|
||||||
|
*/
|
||||||
|
export function getChatSessions(agentId: string, params: GetSessionsParams) {
|
||||||
|
return http.Get<ChatSessionsResponse>(`/agent/${agentId}/sessions`, {
|
||||||
|
params,
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天记录详情
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
* @param sessionId 会话ID
|
||||||
|
*/
|
||||||
|
export function getChatHistory(agentId: string, sessionId: string) {
|
||||||
|
return http.Get<ChatMessage[]>(`/agent/${agentId}/chat-history/${sessionId}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取音频下载ID
|
||||||
|
* @param audioId 音频ID
|
||||||
|
*/
|
||||||
|
export function getAudioId(audioId: string) {
|
||||||
|
return http.Post<string>(`/agent/audio/${audioId}`, {}, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取音频播放地址
|
||||||
|
* @param downloadId 下载ID
|
||||||
|
*/
|
||||||
|
export function getAudioPlayUrl(downloadId: string) {
|
||||||
|
// 根据需求文档,这个是直接返回二进制的,所以我们直接构造URL
|
||||||
|
return `/agent/play/${downloadId}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './chat-history'
|
||||||
|
export * from './types'
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// 聊天会话列表项
|
||||||
|
export interface ChatSession {
|
||||||
|
sessionId: string
|
||||||
|
createdAt: string
|
||||||
|
chatCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聊天会话列表响应
|
||||||
|
export interface ChatSessionsResponse {
|
||||||
|
total: number
|
||||||
|
list: ChatSession[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聊天消息
|
||||||
|
export interface ChatMessage {
|
||||||
|
createdAt: string
|
||||||
|
chatType: 1 | 2 // 1是用户,2是AI
|
||||||
|
content: string
|
||||||
|
audioId: string | null
|
||||||
|
macAddress: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户消息内容(需要解析JSON)
|
||||||
|
export interface UserMessageContent {
|
||||||
|
speaker: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取聊天会话列表参数
|
||||||
|
export interface GetSessionsParams {
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频播放相关
|
||||||
|
export interface AudioResponse {
|
||||||
|
data: string // 音频下载ID
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Device, FirmwareType } from './types'
|
||||||
|
import { http } from '@/http/request/alova'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备类型列表
|
||||||
|
*/
|
||||||
|
export function getFirmwareTypes() {
|
||||||
|
return http.Get<FirmwareType[]>('/admin/dict/data/type/FIRMWARE_TYPE')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取绑定设备列表
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
*/
|
||||||
|
export function getBindDevices(agentId: string) {
|
||||||
|
return http.Get<Device[]>(`/device/bind/${agentId}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加设备
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
* @param code 验证码
|
||||||
|
*/
|
||||||
|
export function bindDevice(agentId: string, code: string) {
|
||||||
|
return http.Post(`/device/bind/${agentId}/${code}`, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置设备OTA升级开关
|
||||||
|
* @param deviceId 设备ID (MAC地址)
|
||||||
|
* @param autoUpdate 是否自动升级 0|1
|
||||||
|
*/
|
||||||
|
export function updateDeviceAutoUpdate(deviceId: string, autoUpdate: number) {
|
||||||
|
return http.Put(`/device/update/${deviceId}`, {
|
||||||
|
autoUpdate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑设备
|
||||||
|
* @param deviceId 设备ID (MAC地址)
|
||||||
|
*/
|
||||||
|
export function unbindDevice(deviceId: string) {
|
||||||
|
return http.Post('/device/unbind', {
|
||||||
|
deviceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './device'
|
||||||
|
export * from './types'
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export interface FirmwareType {
|
||||||
|
name: string
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
macAddress: string
|
||||||
|
lastConnectedAt: string
|
||||||
|
autoUpdate: number
|
||||||
|
board: string
|
||||||
|
alias?: string
|
||||||
|
agentId: string
|
||||||
|
appVersion: string
|
||||||
|
sort: number
|
||||||
|
updater?: string
|
||||||
|
updateDate: string
|
||||||
|
creator: string
|
||||||
|
createDate: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types'
|
||||||
|
export * from './voiceprint'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// 声纹信息响应类型
|
||||||
|
export interface VoicePrint {
|
||||||
|
id: string
|
||||||
|
audioId: string
|
||||||
|
sourceName: string
|
||||||
|
introduce: string
|
||||||
|
createDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音对话记录类型
|
||||||
|
export interface ChatHistory {
|
||||||
|
content: string
|
||||||
|
audioId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建说话人数据类型
|
||||||
|
export interface CreateSpeakerData {
|
||||||
|
agentId: string
|
||||||
|
audioId: string
|
||||||
|
sourceName: string
|
||||||
|
introduce: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用响应类型
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type {
|
||||||
|
ChatHistory,
|
||||||
|
CreateSpeakerData,
|
||||||
|
VoicePrint,
|
||||||
|
} from './types'
|
||||||
|
import { http } from '@/http/request/alova'
|
||||||
|
|
||||||
|
// 获取声纹列表
|
||||||
|
export function getVoicePrintList(agentId: string) {
|
||||||
|
return http.Get<VoicePrint[]>(`/agent/voice-print/list/${agentId}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取语音对话记录(用于选择声纹向量)
|
||||||
|
export function getChatHistory(agentId: string) {
|
||||||
|
return http.Get<ChatHistory[]>(`/agent/${agentId}/chat-history/user`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
cacheFor: {
|
||||||
|
expire: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增说话人
|
||||||
|
export function createVoicePrint(data: CreateSpeakerData) {
|
||||||
|
return http.Post<null>('/agent/voice-print', data, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除声纹
|
||||||
|
export function deleteVoicePrint(id: string) {
|
||||||
|
return http.Delete<null>(`/agent/voice-print/${id}`, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新声纹信息
|
||||||
|
export function updateVoicePrint(data: VoicePrint) {
|
||||||
|
return http.Put<null>('/agent/voice-print', data, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface TabItem {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
icon: string
|
||||||
|
activeIcon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tabList: TabItem[]
|
||||||
|
modelValue?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:modelValue', value: string | number): void
|
||||||
|
(e: 'change', item: TabItem, index: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const activeValue = computed(() => {
|
||||||
|
return props.modelValue || (props.tabList[0]?.value ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleTabClick(item: TabItem, index: number) {
|
||||||
|
emit('update:modelValue', item.value)
|
||||||
|
emit('change', item, index)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="custom-tabs">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in tabList"
|
||||||
|
:key="item.value"
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeValue === item.value }"
|
||||||
|
@click="handleTabClick(item, index)"
|
||||||
|
>
|
||||||
|
<view class="tab-icon">
|
||||||
|
<image
|
||||||
|
:src="activeValue === item.value ? item.activeIcon : item.icon"
|
||||||
|
class="icon-img"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="tab-text">
|
||||||
|
{{ item.label }}
|
||||||
|
</view>
|
||||||
|
<view v-if="activeValue === item.value" class="tab-indicator" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border-top: 1rpx solid #eeeeee;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16rpx 12rpx 12rpx;
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9d9ea3;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .tab-text {
|
||||||
|
color: #336cff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 48rpx;
|
||||||
|
height: 4rpx;
|
||||||
|
background-color: #336cff;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 适配不同尺寸屏幕 */
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
.tab-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-svg-loader" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** 网站标题,应用名称 */
|
||||||
|
readonly VITE_APP_TITLE: string
|
||||||
|
/** 服务端口号 */
|
||||||
|
readonly VITE_SERVER_PORT: string
|
||||||
|
/** 后台接口地址 */
|
||||||
|
readonly VITE_SERVER_BASEURL: string
|
||||||
|
/** H5是否需要代理 */
|
||||||
|
readonly VITE_APP_PROXY: 'true' | 'false'
|
||||||
|
/** H5是否需要代理,需要的话有个前缀 */
|
||||||
|
readonly VITE_APP_PROXY_PREFIX: string // 一般是/api
|
||||||
|
/** 上传图片地址 */
|
||||||
|
readonly VITE_UPLOAD_BASEURL: string
|
||||||
|
/** 是否清除console */
|
||||||
|
readonly VITE_DELETE_CONSOLE: string
|
||||||
|
// 更多环境变量...
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const __VITE_APP_PROXY__: 'true' | 'false'
|
||||||
|
declare const __UNI_PLATFORM__: 'app' | 'h5' | 'mp-alipay' | 'mp-baidu' | 'mp-kuaishou' | 'mp-lark' | 'mp-qq' | 'mp-tiktok' | 'mp-weixin' | 'mp-xiaochengxu'
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
import { needLoginPages as _needLoginPages, getNeedLoginPages } from '@/utils'
|
||||||
|
|
||||||
|
const loginRoute = import.meta.env.VITE_LOGIN_URL
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
function isLogined() {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
return !!userStore.userInfo.username
|
||||||
|
}
|
||||||
|
// 检查当前页面是否需要登录
|
||||||
|
export function usePageAuth() {
|
||||||
|
onLoad((options) => {
|
||||||
|
// 获取当前页面路径
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
const currentPath = `/${currentPage.route}`
|
||||||
|
|
||||||
|
// 获取需要登录的页面列表
|
||||||
|
let needLoginPages: string[] = []
|
||||||
|
if (isDev) {
|
||||||
|
needLoginPages = getNeedLoginPages()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
needLoginPages = _needLoginPages
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查当前页面是否需要登录
|
||||||
|
const isNeedLogin = needLoginPages.includes(currentPath)
|
||||||
|
if (!isNeedLogin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLogin = isLogined()
|
||||||
|
if (hasLogin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建重定向URL
|
||||||
|
const queryString = Object.entries(options || {})
|
||||||
|
.map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
const currentFullPath = queryString ? `${currentPath}?${queryString}` : currentPath
|
||||||
|
const redirectRoute = `${loginRoute}?redirect=${encodeURIComponent(currentFullPath)}`
|
||||||
|
|
||||||
|
// 重定向到登录页
|
||||||
|
uni.redirectTo({ url: redirectRoute })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
interface IUseRequestOptions<T> {
|
||||||
|
/** 是否立即执行 */
|
||||||
|
immediate?: boolean
|
||||||
|
/** 初始化数据 */
|
||||||
|
initialData?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUseRequestReturn<T> {
|
||||||
|
loading: Ref<boolean>
|
||||||
|
error: Ref<boolean | Error>
|
||||||
|
data: Ref<T | undefined>
|
||||||
|
run: () => Promise<T | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useRequest是一个定制化的请求钩子,用于处理异步请求和响应。
|
||||||
|
* @param func 一个执行异步请求的函数,返回一个包含响应数据的Promise。
|
||||||
|
* @param options 包含请求选项的对象 {immediate, initialData}。
|
||||||
|
* @param options.immediate 是否立即执行请求,默认为false。
|
||||||
|
* @param options.initialData 初始化数据,默认为undefined。
|
||||||
|
* @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。
|
||||||
|
*/
|
||||||
|
export default function useRequest<T>(
|
||||||
|
func: () => Promise<IResData<T>>,
|
||||||
|
options: IUseRequestOptions<T> = { immediate: false },
|
||||||
|
): IUseRequestReturn<T> {
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
|
||||||
|
const run = async () => {
|
||||||
|
loading.value = true
|
||||||
|
return func()
|
||||||
|
.then((res) => {
|
||||||
|
data.value = res.data
|
||||||
|
error.value = false
|
||||||
|
return data.value
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error.value = err
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options.immediate && run()
|
||||||
|
return { loading, error, data, run }
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { getEnvBaseUploadUrl } from '@/utils'
|
||||||
|
|
||||||
|
const VITE_UPLOAD_BASEURL = `${getEnvBaseUploadUrl()}`
|
||||||
|
|
||||||
|
type TfileType = 'image' | 'file'
|
||||||
|
type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*'
|
||||||
|
type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage
|
||||||
|
|
||||||
|
interface TOptions<T extends TfileType> {
|
||||||
|
formData?: Record<string, any>
|
||||||
|
maxSize?: number
|
||||||
|
accept?: T extends 'image' ? TImage[] : TFile[]
|
||||||
|
fileType?: T
|
||||||
|
success?: (params: any) => void
|
||||||
|
error?: (err: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useUpload<T extends TfileType>(options: TOptions<T> = {} as TOptions<T>) {
|
||||||
|
const {
|
||||||
|
formData = {},
|
||||||
|
maxSize = 5 * 1024 * 1024,
|
||||||
|
accept = ['*'],
|
||||||
|
fileType = 'image',
|
||||||
|
success,
|
||||||
|
error: onError,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const data = ref<any>(null)
|
||||||
|
|
||||||
|
const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => {
|
||||||
|
if (size > maxSize) {
|
||||||
|
uni.showToast({
|
||||||
|
title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase()
|
||||||
|
// const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension)
|
||||||
|
|
||||||
|
// if (!isTypeValid) {
|
||||||
|
// uni.showToast({
|
||||||
|
// title: `仅支持 ${accept.join(', ')} 格式的文件`,
|
||||||
|
// icon: 'none',
|
||||||
|
// })
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
uploadFile({
|
||||||
|
tempFilePath,
|
||||||
|
formData,
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const { data: _data } = JSON.parse(res)
|
||||||
|
data.value = _data
|
||||||
|
// console.log('上传成功', res)
|
||||||
|
success?.(_data)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
error.value = err
|
||||||
|
onError?.(err)
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
loading.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
|
||||||
|
// 微信小程序在2023年10月17日之后,使用本API需要配置隐私协议
|
||||||
|
const chooseFileOptions = {
|
||||||
|
count: 1,
|
||||||
|
success: (res: any) => {
|
||||||
|
console.log('File selected successfully:', res)
|
||||||
|
// 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]}
|
||||||
|
// h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]}
|
||||||
|
// h5的File有以下字段:{name: "girl.jpeg", size: 48976, type: "image/jpeg"}
|
||||||
|
// App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]}
|
||||||
|
// App的File有以下字段:{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976}
|
||||||
|
let tempFilePath = ''
|
||||||
|
let size = 0
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
tempFilePath = res.tempFiles[0].tempFilePath
|
||||||
|
size = res.tempFiles[0].size
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
tempFilePath = res.tempFilePaths[0]
|
||||||
|
size = res.tempFiles[0].size
|
||||||
|
// #endif
|
||||||
|
handleFileChoose({ tempFilePath, size })
|
||||||
|
},
|
||||||
|
fail: (err: any) => {
|
||||||
|
console.error('File selection failed:', err)
|
||||||
|
error.value = err
|
||||||
|
onError?.(err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === 'image') {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
uni.chooseMedia({
|
||||||
|
...chooseFileOptions,
|
||||||
|
mediaType: ['image'],
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
uni.chooseImage(chooseFileOptions)
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
uni.chooseFile({
|
||||||
|
...chooseFileOptions,
|
||||||
|
type: 'all',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, error, data, run }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile({
|
||||||
|
tempFilePath,
|
||||||
|
formData,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
tempFilePath: string
|
||||||
|
formData: Record<string, any>
|
||||||
|
onSuccess: (data: any) => void
|
||||||
|
onError: (err: any) => void
|
||||||
|
onComplete: () => void
|
||||||
|
}) {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: VITE_UPLOAD_BASEURL,
|
||||||
|
filePath: tempFilePath,
|
||||||
|
name: 'file',
|
||||||
|
formData,
|
||||||
|
success: (uploadFileRes) => {
|
||||||
|
try {
|
||||||
|
const data = uploadFileRes.data
|
||||||
|
onSuccess(data)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
onError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('Upload failed:', err)
|
||||||
|
onError(err)
|
||||||
|
},
|
||||||
|
complete: onComplete,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 请求库
|
||||||
|
|
||||||
|
当前项目使用 Alova 作为唯一的 HTTP 请求库:
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
- **Alova HTTP**:路径(src/http/request/alova.ts)
|
||||||
|
- **示例代码**:src/api/foo-alova.ts 和 src/api/foo.ts
|
||||||
|
- **API文档**:https://alova.js.org/
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
Alova 实例已配置:
|
||||||
|
- 自动 Token 认证和刷新
|
||||||
|
- 统一错误处理和提示
|
||||||
|
- 支持动态域名切换
|
||||||
|
- 内置请求/响应拦截器
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { http } from '@/http/request/alova'
|
||||||
|
|
||||||
|
// GET 请求
|
||||||
|
http.Get<ResponseType>('/api/path', {
|
||||||
|
params: { id: 1 },
|
||||||
|
headers: { 'Custom-Header': 'value' },
|
||||||
|
meta: { toast: false } // 关闭错误提示
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST 请求
|
||||||
|
http.Post<ResponseType>('/api/path', data, {
|
||||||
|
params: { query: 'param' },
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { uniappRequestAdapter } from '@alova/adapter-uniapp'
|
||||||
|
import type { IResponse } from './types'
|
||||||
|
import AdapterUniapp from '@alova/adapter-uniapp'
|
||||||
|
import { createAlova } from 'alova'
|
||||||
|
import { createServerTokenAuthentication } from 'alova/client'
|
||||||
|
import VueHook from 'alova/vue'
|
||||||
|
import { getEnvBaseUrl } from '@/utils'
|
||||||
|
import { toast } from '@/utils/toast'
|
||||||
|
import { ContentTypeEnum, ResultEnum, ShowMessage } from './enum'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建请求实例
|
||||||
|
*/
|
||||||
|
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
|
||||||
|
typeof VueHook,
|
||||||
|
typeof uniappRequestAdapter
|
||||||
|
>({
|
||||||
|
refreshTokenOnError: {
|
||||||
|
isExpired: (error) => {
|
||||||
|
return error.response?.status === ResultEnum.Unauthorized
|
||||||
|
},
|
||||||
|
handler: async () => {
|
||||||
|
try {
|
||||||
|
// await authLogin();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// 切换到登录页
|
||||||
|
await uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* alova 请求实例
|
||||||
|
*/
|
||||||
|
const alovaInstance = createAlova({
|
||||||
|
baseURL: getEnvBaseUrl(),
|
||||||
|
...AdapterUniapp(),
|
||||||
|
timeout: 5000,
|
||||||
|
statesHook: VueHook,
|
||||||
|
|
||||||
|
beforeRequest: onAuthRequired((method) => {
|
||||||
|
// 设置默认 Content-Type
|
||||||
|
method.config.headers = {
|
||||||
|
'Content-Type': ContentTypeEnum.JSON,
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
...method.config.headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { config } = method
|
||||||
|
const ignoreAuth = config.meta?.ignoreAuth
|
||||||
|
console.log('ignoreAuth===>', ignoreAuth)
|
||||||
|
|
||||||
|
// 处理认证信息
|
||||||
|
if (!ignoreAuth) {
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
if (!token) {
|
||||||
|
// 跳转到登录页
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
throw new Error('[请求错误]:未登录')
|
||||||
|
}
|
||||||
|
// 添加 Authorization 头
|
||||||
|
method.config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理动态域名
|
||||||
|
if (config.meta?.domain) {
|
||||||
|
method.baseURL = config.meta.domain
|
||||||
|
console.log('当前域名', method.baseURL)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
responded: onResponseRefreshToken((response, method) => {
|
||||||
|
const { config } = method
|
||||||
|
const { requestType } = config
|
||||||
|
const {
|
||||||
|
statusCode,
|
||||||
|
data: rawData,
|
||||||
|
errMsg,
|
||||||
|
} = response as UniNamespace.RequestSuccessCallbackResult
|
||||||
|
|
||||||
|
console.log(response)
|
||||||
|
|
||||||
|
// 处理特殊请求类型(上传/下载)
|
||||||
|
if (requestType === 'upload' || requestType === 'download') {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 HTTP 状态码错误
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`
|
||||||
|
console.error('errorMessage===>', errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
|
throw new Error(`${errorMessage}:${errMsg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理业务逻辑错误
|
||||||
|
const { code, msg, data } = rawData as IResponse
|
||||||
|
if (code !== ResultEnum.Success) {
|
||||||
|
// 检查是否为token失效
|
||||||
|
if (code === ResultEnum.Unauthorized) {
|
||||||
|
// 清除token并跳转到登录页
|
||||||
|
uni.removeStorageSync('token')
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
throw new Error(`请求错误[${code}]:${msg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.meta?.toast !== false) {
|
||||||
|
toast.warning(msg)
|
||||||
|
}
|
||||||
|
throw new Error(`请求错误[${code}]:${msg}`)
|
||||||
|
}
|
||||||
|
// 处理成功响应,返回业务数据
|
||||||
|
return data
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const http = alovaInstance
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export enum ResultEnum {
|
||||||
|
Success = 0, // 成功
|
||||||
|
Error = 400, // 错误
|
||||||
|
Unauthorized = 401, // 未授权
|
||||||
|
Forbidden = 403, // 禁止访问(原为forbidden)
|
||||||
|
NotFound = 404, // 未找到(原为notFound)
|
||||||
|
MethodNotAllowed = 405, // 方法不允许(原为methodNotAllowed)
|
||||||
|
RequestTimeout = 408, // 请求超时(原为requestTimeout)
|
||||||
|
InternalServerError = 500, // 服务器错误(原为internalServerError)
|
||||||
|
NotImplemented = 501, // 未实现(原为notImplemented)
|
||||||
|
BadGateway = 502, // 网关错误(原为badGateway)
|
||||||
|
ServiceUnavailable = 503, // 服务不可用(原为serviceUnavailable)
|
||||||
|
GatewayTimeout = 504, // 网关超时(原为gatewayTimeout)
|
||||||
|
HttpVersionNotSupported = 505, // HTTP版本不支持(原为httpVersionNotSupported)
|
||||||
|
}
|
||||||
|
export enum ContentTypeEnum {
|
||||||
|
JSON = 'application/json;charset=UTF-8',
|
||||||
|
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||||
|
FORM_DATA = 'multipart/form-data;charset=UTF-8',
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 根据状态码,生成对应的错误信息
|
||||||
|
* @param {number|string} status 状态码
|
||||||
|
* @returns {string} 错误信息
|
||||||
|
*/
|
||||||
|
export function ShowMessage(status: number | string): string {
|
||||||
|
let message: string
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
message = '请求错误(400)'
|
||||||
|
break
|
||||||
|
case 401:
|
||||||
|
message = '未授权,请重新登录(401)'
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
message = '拒绝访问(403)'
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
message = '请求出错(404)'
|
||||||
|
break
|
||||||
|
case 408:
|
||||||
|
message = '请求超时(408)'
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
message = '服务器错误(500)'
|
||||||
|
break
|
||||||
|
case 501:
|
||||||
|
message = '服务未实现(501)'
|
||||||
|
break
|
||||||
|
case 502:
|
||||||
|
message = '网络错误(502)'
|
||||||
|
break
|
||||||
|
case 503:
|
||||||
|
message = '服务不可用(503)'
|
||||||
|
break
|
||||||
|
case 504:
|
||||||
|
message = '网络超时(504)'
|
||||||
|
break
|
||||||
|
case 505:
|
||||||
|
message = 'HTTP版本不受支持(505)'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message = `连接出错(${status})!`
|
||||||
|
}
|
||||||
|
return `${message},请检查网络或联系管理员!`
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// 通用响应格式
|
||||||
|
export interface IResponse<T = any> {
|
||||||
|
code: number | string
|
||||||
|
data: T
|
||||||
|
msg: string
|
||||||
|
status: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页请求参数
|
||||||
|
export interface PageParams {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页响应数据
|
||||||
|
export interface PageResult<T> {
|
||||||
|
list: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ConfigProviderThemeVars } from 'wot-design-uni'
|
||||||
|
|
||||||
|
const themeVars: ConfigProviderThemeVars = {
|
||||||
|
// colorTheme: 'red',
|
||||||
|
// buttonPrimaryBgColor: '#07c160',
|
||||||
|
// buttonPrimaryColor: '#07c160',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<wd-config-provider :theme-vars="themeVars">
|
||||||
|
<slot />
|
||||||
|
<wd-toast />
|
||||||
|
<wd-message-box />
|
||||||
|
</wd-config-provider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { tabbarStore } from './tabbar'
|
||||||
|
// 'i-carbon-code',
|
||||||
|
import { tabbarList as _tabBarList, cacheTabbarEnable, selectedTabbarStrategy, TABBAR_MAP } from './tabbarList'
|
||||||
|
|
||||||
|
const customTabbarEnable
|
||||||
|
= selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITH_CACHE
|
||||||
|
|| selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITHOUT_CACHE
|
||||||
|
|
||||||
|
/** tabbarList 里面的 path 从 pages.config.ts 得到 */
|
||||||
|
const tabbarList = _tabBarList.map(item => ({ ...item, path: `/${item.pagePath}` }))
|
||||||
|
function selectTabBar({ value: index }: { value: number }) {
|
||||||
|
const url = tabbarList[index].path
|
||||||
|
tabbarStore.setCurIdx(index)
|
||||||
|
if (cacheTabbarEnable) {
|
||||||
|
uni.switchTab({ url })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onLoad(() => {
|
||||||
|
// 解决原生 tabBar 未隐藏导致有2个 tabBar 的问题
|
||||||
|
const hideRedundantTabbarEnable = selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITH_CACHE
|
||||||
|
hideRedundantTabbarEnable
|
||||||
|
&& uni.hideTabBar({
|
||||||
|
fail(err) {
|
||||||
|
console.log('hideTabBar fail: ', err)
|
||||||
|
},
|
||||||
|
success(res) {
|
||||||
|
console.log('hideTabBar success: ', res)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<wd-tabbar
|
||||||
|
v-if="customTabbarEnable"
|
||||||
|
v-model="tabbarStore.curIdx"
|
||||||
|
bordered
|
||||||
|
safe-area-inset-bottom
|
||||||
|
placeholder
|
||||||
|
fixed
|
||||||
|
@change="selectTabBar"
|
||||||
|
>
|
||||||
|
<block v-for="(item, idx) in tabbarList" :key="item.path">
|
||||||
|
<wd-tabbar-item v-if="item.iconType === 'uiLib'" :title="item.text" :icon="item.icon" />
|
||||||
|
<wd-tabbar-item
|
||||||
|
v-else-if="item.iconType === 'unocss' || item.iconType === 'iconfont'"
|
||||||
|
:title="item.text"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<view
|
||||||
|
h-40rpx
|
||||||
|
w-40rpx
|
||||||
|
:class="[item.icon, idx === tabbarStore.curIdx ? 'is-active' : 'is-inactive']"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</wd-tabbar-item>
|
||||||
|
<wd-tabbar-item v-else-if="item.iconType === 'local'" :title="item.text">
|
||||||
|
<template #icon>
|
||||||
|
<image :src="item.icon" h-40rpx w-40rpx />
|
||||||
|
</template>
|
||||||
|
</wd-tabbar-item>
|
||||||
|
</block>
|
||||||
|
</wd-tabbar>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# tabbar 说明
|
||||||
|
|
||||||
|
`tabbar` 分为 `4 种` 情况:
|
||||||
|
|
||||||
|
- 0 `无 tabbar`,只有一个页面入口,底部无 `tabbar` 显示;常用语临时活动页。
|
||||||
|
- 1 `原生 tabbar`,使用 `switchTab` 切换 tabbar,`tabbar` 页面有缓存。
|
||||||
|
- 优势:原生自带的 tabbar,最先渲染,有缓存。
|
||||||
|
- 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。
|
||||||
|
- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 tabbar,`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。
|
||||||
|
- 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。
|
||||||
|
- 劣势:首次点击 tababr 会闪烁。
|
||||||
|
- 3 `无缓存自定义 tabbar`,使用 `navigateTo` 切换 `tabbar`,`tabbar` 页面无缓存。使用了第三方 UI 库的 `tabbar` 组件。
|
||||||
|
- 优势:可以随意配置自己想要的 svg icon,切换字体颜色方便。可以实现各种花里胡哨的动效等。
|
||||||
|
- 劣势:首次点击 `tababr` 会闪烁,无缓存。
|
||||||
|
|
||||||
|
|
||||||
|
> 注意:花里胡哨的效果需要自己实现,本模版不提供。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* tabbar 状态,增加 storageSync 保证刷新浏览器时在正确的 tabbar 页面
|
||||||
|
* 使用reactive简单状态,而不是 pinia 全局状态
|
||||||
|
*/
|
||||||
|
export const tabbarStore = reactive({
|
||||||
|
curIdx: uni.getStorageSync('app-tabbar-index') || 0,
|
||||||
|
setCurIdx(idx: number) {
|
||||||
|
this.curIdx = idx
|
||||||
|
uni.setStorageSync('app-tabbar-index', idx)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
|
||||||
|
|
||||||
|
type FgTabBarItem = TabBar['list'][0] & {
|
||||||
|
icon: string
|
||||||
|
iconType: 'uiLib' | 'unocss' | 'iconfont'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tabbar 选择的策略,更详细的介绍见 tabbar.md 文件
|
||||||
|
* 0: 'NO_TABBAR' `无 tabbar`
|
||||||
|
* 1: 'NATIVE_TABBAR' `完全原生 tabbar`
|
||||||
|
* 2: 'CUSTOM_TABBAR_WITH_CACHE' `有缓存自定义 tabbar`
|
||||||
|
* 3: 'CUSTOM_TABBAR_WITHOUT_CACHE' `无缓存自定义 tabbar`
|
||||||
|
*
|
||||||
|
* 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致错误
|
||||||
|
*/
|
||||||
|
export const TABBAR_MAP = {
|
||||||
|
NO_TABBAR: 0,
|
||||||
|
NATIVE_TABBAR: 1,
|
||||||
|
CUSTOM_TABBAR_WITH_CACHE: 2,
|
||||||
|
CUSTOM_TABBAR_WITHOUT_CACHE: 3,
|
||||||
|
}
|
||||||
|
// TODO:通过这里切换使用tabbar的策略
|
||||||
|
export const selectedTabbarStrategy = TABBAR_MAP.NATIVE_TABBAR
|
||||||
|
|
||||||
|
// selectedTabbarStrategy==NATIVE_TABBAR(1) 时,需要填 iconPath 和 selectedIconPath
|
||||||
|
// selectedTabbarStrategy==CUSTOM_TABBAR(2,3) 时,需要填 icon 和 iconType
|
||||||
|
// selectedTabbarStrategy==NO_TABBAR(0) 时,tabbarList 不生效
|
||||||
|
export const tabbarList: FgTabBarItem[] = [
|
||||||
|
{
|
||||||
|
iconPath: 'static/tabbar/robot.png',
|
||||||
|
selectedIconPath: 'static/tabbar/robot_activate.png',
|
||||||
|
pagePath: 'pages/index/index',
|
||||||
|
text: '首页',
|
||||||
|
icon: 'home',
|
||||||
|
// 选用 UI 框架自带的 icon 时,iconType 为 uiLib
|
||||||
|
iconType: 'uiLib',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconPath: 'static/tabbar/network.png',
|
||||||
|
selectedIconPath: 'static/tabbar/network_activate.png',
|
||||||
|
pagePath: 'pages/device-config/index',
|
||||||
|
text: '配网',
|
||||||
|
icon: 'i-carbon-network-3',
|
||||||
|
iconType: 'uiLib',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconPath: 'static/tabbar/system.png',
|
||||||
|
selectedIconPath: 'static/tabbar/system_activate.png',
|
||||||
|
pagePath: 'pages/settings/index',
|
||||||
|
text: '系统',
|
||||||
|
icon: 'i-carbon-settings',
|
||||||
|
iconType: 'uiLib',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// NATIVE_TABBAR(1) 和 CUSTOM_TABBAR_WITH_CACHE(2) 时,需要tabbar缓存
|
||||||
|
export const cacheTabbarEnable = selectedTabbarStrategy === TABBAR_MAP.NATIVE_TABBAR
|
||||||
|
|| selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITH_CACHE
|
||||||
|
|
||||||
|
const _tabbar: TabBar = {
|
||||||
|
// 只有微信小程序支持 custom。App 和 H5 不生效
|
||||||
|
custom: selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITH_CACHE,
|
||||||
|
color: '#e6e6e6',
|
||||||
|
selectedColor: '#667dea',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderStyle: 'black',
|
||||||
|
height: '50px',
|
||||||
|
fontSize: '10px',
|
||||||
|
iconWidth: '24px',
|
||||||
|
spacing: '3px',
|
||||||
|
list: tabbarList as unknown as TabBar['list'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0和1 需要显示底部的tabbar的各种配置,以利用缓存
|
||||||
|
export const tabBar = cacheTabbarEnable ? _tabbar : undefined
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ConfigProviderThemeVars } from 'wot-design-uni'
|
||||||
|
import FgTabbar from './fg-tabbar/fg-tabbar.vue'
|
||||||
|
|
||||||
|
const themeVars: ConfigProviderThemeVars = {
|
||||||
|
// colorTheme: 'red',
|
||||||
|
// buttonPrimaryBgColor: '#07c160',
|
||||||
|
// buttonPrimaryColor: '#07c160',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<wd-config-provider :theme-vars="themeVars">
|
||||||
|
<slot />
|
||||||
|
<FgTabbar />
|
||||||
|
<wd-toast />
|
||||||
|
<wd-message-box />
|
||||||
|
</wd-config-provider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||||
|
import { createSSRApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { routeInterceptor } from './router/interceptor'
|
||||||
|
|
||||||
|
import store from './store'
|
||||||
|
import '@/style/index.scss'
|
||||||
|
import 'virtual:uno.css'
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App)
|
||||||
|
app.use(store)
|
||||||
|
app.use(routeInterceptor)
|
||||||
|
app.use(VueQueryPlugin)
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"name": "小智",
|
||||||
|
"appid": "__UNI__36A515E",
|
||||||
|
"description": "",
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"versionCode": "100",
|
||||||
|
"transformPx": false,
|
||||||
|
"app-plus": {
|
||||||
|
"usingComponents": true,
|
||||||
|
"nvueStyleCompiler": "uni-app",
|
||||||
|
"compilerVersion": 3,
|
||||||
|
"splashscreen": {
|
||||||
|
"alwaysShowBeforeRender": true,
|
||||||
|
"waiting": true,
|
||||||
|
"autoclose": true,
|
||||||
|
"delay": 0
|
||||||
|
},
|
||||||
|
"modules": {},
|
||||||
|
"distribute": {
|
||||||
|
"android": {
|
||||||
|
"permissions": [
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>"
|
||||||
|
],
|
||||||
|
"minSdkVersion": 30,
|
||||||
|
"targetSdkVersion": 30,
|
||||||
|
"abiFilters": [
|
||||||
|
"armeabi-v7a",
|
||||||
|
"arm64-v8a"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {},
|
||||||
|
"sdkConfigs": {},
|
||||||
|
"icons": {
|
||||||
|
"android": {
|
||||||
|
"hdpi": "unpackage/res/icons/72x72.png",
|
||||||
|
"xhdpi": "unpackage/res/icons/96x96.png",
|
||||||
|
"xxhdpi": "unpackage/res/icons/144x144.png",
|
||||||
|
"xxxhdpi": "unpackage/res/icons/192x192.png"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"appstore": "unpackage/res/icons/1024x1024.png",
|
||||||
|
"ipad": {
|
||||||
|
"app": "unpackage/res/icons/76x76.png",
|
||||||
|
"app@2x": "unpackage/res/icons/152x152.png",
|
||||||
|
"notification": "unpackage/res/icons/20x20.png",
|
||||||
|
"notification@2x": "unpackage/res/icons/40x40.png",
|
||||||
|
"proapp@2x": "unpackage/res/icons/167x167.png",
|
||||||
|
"settings": "unpackage/res/icons/29x29.png",
|
||||||
|
"settings@2x": "unpackage/res/icons/58x58.png",
|
||||||
|
"spotlight": "unpackage/res/icons/40x40.png",
|
||||||
|
"spotlight@2x": "unpackage/res/icons/80x80.png"
|
||||||
|
},
|
||||||
|
"iphone": {
|
||||||
|
"app@2x": "unpackage/res/icons/120x120.png",
|
||||||
|
"app@3x": "unpackage/res/icons/180x180.png",
|
||||||
|
"notification@2x": "unpackage/res/icons/40x40.png",
|
||||||
|
"notification@3x": "unpackage/res/icons/60x60.png",
|
||||||
|
"settings@2x": "unpackage/res/icons/58x58.png",
|
||||||
|
"settings@3x": "unpackage/res/icons/87x87.png",
|
||||||
|
"spotlight@2x": "unpackage/res/icons/80x80.png",
|
||||||
|
"spotlight@3x": "unpackage/res/icons/120x120.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compatible": {
|
||||||
|
"ignoreVersion": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickapp": {},
|
||||||
|
"mp-weixin": {
|
||||||
|
"appid": "wxa2abb91f64032a2b",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": false,
|
||||||
|
"es6": true,
|
||||||
|
"minified": true
|
||||||
|
},
|
||||||
|
"usingComponents": true,
|
||||||
|
"optimization": {
|
||||||
|
"subPackages": true
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"scope.userLocation": {
|
||||||
|
"desc": "WiFi配网功能需要获取位置权限"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requiredPrivateInfos": [
|
||||||
|
"getLocation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mp-alipay": {
|
||||||
|
"usingComponents": true,
|
||||||
|
"styleIsolation": "shared"
|
||||||
|
},
|
||||||
|
"mp-baidu": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-toutiao": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"uniStatistics": {
|
||||||
|
"enable": false
|
||||||
|
},
|
||||||
|
"vueVersion": "3",
|
||||||
|
"h5": {
|
||||||
|
"router": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<route lang="jsonc" type="page">
|
||||||
|
{
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "分包页面"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// code here
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="text-center">
|
||||||
|
<view class="m-8">
|
||||||
|
http://localhost:9000/#/pages-sub/demo/index
|
||||||
|
</view>
|
||||||
|
<view class="text-green-500">
|
||||||
|
分包页面demo
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
//
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
{
|
||||||
|
"globalStyle": {
|
||||||
|
"navigationStyle": "default",
|
||||||
|
"navigationBarTitleText": "小智智控台",
|
||||||
|
"navigationBarBackgroundColor": "#f8f8f8",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#FFFFFF"
|
||||||
|
},
|
||||||
|
"easycom": {
|
||||||
|
"autoscan": true,
|
||||||
|
"custom": {
|
||||||
|
"^fg-(.*)": "@/components/fg-$1/fg-$1.vue",
|
||||||
|
"^wd-(.*)": "wot-design-uni/components/wd-$1/wd-$1.vue",
|
||||||
|
"^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)": "z-paging/components/z-paging$1/z-paging$1.vue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tabBar": {
|
||||||
|
"custom": false,
|
||||||
|
"color": "#e6e6e6",
|
||||||
|
"selectedColor": "#667dea",
|
||||||
|
"backgroundColor": "#fff",
|
||||||
|
"borderStyle": "black",
|
||||||
|
"height": "50px",
|
||||||
|
"fontSize": "10px",
|
||||||
|
"iconWidth": "24px",
|
||||||
|
"spacing": "3px",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"iconPath": "static/tabbar/robot.png",
|
||||||
|
"selectedIconPath": "static/tabbar/robot_activate.png",
|
||||||
|
"pagePath": "pages/index/index",
|
||||||
|
"text": "首页",
|
||||||
|
"icon": "home",
|
||||||
|
"iconType": "uiLib"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iconPath": "static/tabbar/network.png",
|
||||||
|
"selectedIconPath": "static/tabbar/network_activate.png",
|
||||||
|
"pagePath": "pages/device-config/index",
|
||||||
|
"text": "配网",
|
||||||
|
"icon": "i-carbon-network-3",
|
||||||
|
"iconType": "uiLib"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iconPath": "static/tabbar/system.png",
|
||||||
|
"selectedIconPath": "static/tabbar/system_activate.png",
|
||||||
|
"pagePath": "pages/settings/index",
|
||||||
|
"text": "系统",
|
||||||
|
"icon": "i-carbon-settings",
|
||||||
|
"iconType": "uiLib"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/index/index",
|
||||||
|
"type": "home",
|
||||||
|
"layout": "tabbar",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "首页"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/agent/edit",
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/agent/index",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "智能体",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/agent/tools",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "编辑功能",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/chat-history/detail",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "聊天详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/chat-history/index",
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/device/index",
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/device-config/index",
|
||||||
|
"type": "page",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "设备配网",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/login/index",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "登陆"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/register/index",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "注册"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/settings/index",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "设置",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/voiceprint/index",
|
||||||
|
"type": "page"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subPackages": [
|
||||||
|
{
|
||||||
|
"root": "pages-sub",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "demo/index",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "分包页面"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { getAgentDetail, getModelOptions, getPluginFunctions, getRoleTemplates, getTTSVoices, updateAgent } from '@/api/agent/agent'
|
||||||
|
import { usePluginStore } from '@/store'
|
||||||
|
import { toast } from '@/utils/toast'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'AgentEdit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
agentId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件参数
|
||||||
|
interface Props {
|
||||||
|
agentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentId = computed(() => props.agentId)
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<Partial<AgentDetail>>({
|
||||||
|
agentName: '',
|
||||||
|
systemPrompt: '',
|
||||||
|
summaryMemory: '',
|
||||||
|
vadModelId: '',
|
||||||
|
asrModelId: '',
|
||||||
|
llmModelId: '',
|
||||||
|
vllmModelId: '',
|
||||||
|
intentModelId: '',
|
||||||
|
memModelId: '',
|
||||||
|
ttsModelId: '',
|
||||||
|
ttsVoiceId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示名称数据
|
||||||
|
const displayNames = ref({
|
||||||
|
vad: '请选择',
|
||||||
|
asr: '请选择',
|
||||||
|
llm: '请选择',
|
||||||
|
vllm: '请选择',
|
||||||
|
intent: '请选择',
|
||||||
|
memory: '请选择',
|
||||||
|
tts: '请选择',
|
||||||
|
voiceprint: '请选择',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 角色模板数据
|
||||||
|
const roleTemplates = ref<RoleTemplate[]>([])
|
||||||
|
const selectedTemplateId = ref('')
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// 模型选项数据
|
||||||
|
const modelOptions = ref<{
|
||||||
|
[key: string]: ModelOption[]
|
||||||
|
}>({
|
||||||
|
VAD: [],
|
||||||
|
ASR: [],
|
||||||
|
LLM: [],
|
||||||
|
VLLM: [],
|
||||||
|
Intent: [],
|
||||||
|
Memory: [],
|
||||||
|
TTS: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 音色选项数据
|
||||||
|
const voiceOptions = ref<{ id: string, name: string }[]>([])
|
||||||
|
|
||||||
|
// 选择器显示状态
|
||||||
|
const pickerShow = ref<{
|
||||||
|
[key: string]: boolean
|
||||||
|
}>({
|
||||||
|
vad: false,
|
||||||
|
asr: false,
|
||||||
|
llm: false,
|
||||||
|
vllm: false,
|
||||||
|
intent: false,
|
||||||
|
memory: false,
|
||||||
|
tts: false,
|
||||||
|
voiceprint: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const allFunctions = ref<PluginDefinition[]>([])
|
||||||
|
|
||||||
|
// 使用插件store
|
||||||
|
const pluginStore = usePluginStore()
|
||||||
|
|
||||||
|
// tabs
|
||||||
|
const tabList = [
|
||||||
|
{
|
||||||
|
label: '角色配置',
|
||||||
|
value: 'home',
|
||||||
|
icon: '/static/tabbar/robot.png',
|
||||||
|
activeIcon: '/static/tabbar/robot_activate.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '设备管理',
|
||||||
|
value: 'category',
|
||||||
|
icon: '/static/tabbar/device.png',
|
||||||
|
activeIcon: '/static/tabbar/device_activate.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '聊天记录',
|
||||||
|
value: 'settings',
|
||||||
|
icon: '/static/tabbar/chat.png',
|
||||||
|
activeIcon: '/static/tabbar/chat_activate.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '声纹管理',
|
||||||
|
value: 'profile',
|
||||||
|
icon: '/static/tabbar/voiceprint.png',
|
||||||
|
activeIcon: '/static/tabbar/voiceprint_activate.png',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 加载智能体详情
|
||||||
|
async function loadAgentDetail() {
|
||||||
|
if (!agentId.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const detail = await getAgentDetail(agentId.value)
|
||||||
|
formData.value = { ...detail }
|
||||||
|
|
||||||
|
// 更新插件store
|
||||||
|
pluginStore.setCurrentAgentId(agentId.value)
|
||||||
|
pluginStore.setCurrentFunctions(detail.functions || [])
|
||||||
|
|
||||||
|
// 如果有TTS模型,加载对应的音色选项
|
||||||
|
if (detail.ttsModelId) {
|
||||||
|
await loadVoiceOptions(detail.ttsModelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待模型选项加载完成后再更新显示名称
|
||||||
|
await nextTick()
|
||||||
|
updateDisplayNames()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('加载智能体详情失败:', error)
|
||||||
|
toast.error('加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取音色显示名称
|
||||||
|
function getVoiceDisplayName(ttsVoiceId: string) {
|
||||||
|
if (!ttsVoiceId)
|
||||||
|
return '请选择'
|
||||||
|
|
||||||
|
console.log('=== 音色映射调试 ===')
|
||||||
|
console.log('当前音色ID:', ttsVoiceId)
|
||||||
|
console.log('当前TTS模型:', formData.value.ttsModelId)
|
||||||
|
console.log('可用音色选项:', voiceOptions.value)
|
||||||
|
|
||||||
|
// 首先尝试直接从音色选项中匹配ID
|
||||||
|
const voice = voiceOptions.value.find(v => v.id === ttsVoiceId)
|
||||||
|
if (voice) {
|
||||||
|
console.log('直接匹配成功:', voice)
|
||||||
|
return voice.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没找到,尝试兼容性映射
|
||||||
|
if (voiceOptions.value.length > 0) {
|
||||||
|
console.log('直接匹配失败,尝试兼容性映射')
|
||||||
|
|
||||||
|
// 创建索引映射:voice1 → 第1个音色,voice2 → 第2个音色
|
||||||
|
const indexMap = {
|
||||||
|
voice1: 0,
|
||||||
|
voice2: 1,
|
||||||
|
voice3: 2,
|
||||||
|
voice4: 3,
|
||||||
|
voice5: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = indexMap[ttsVoiceId]
|
||||||
|
if (index !== undefined && voiceOptions.value[index]) {
|
||||||
|
const mappedVoice = voiceOptions.value[index]
|
||||||
|
console.log(`索引映射: ${ttsVoiceId} → index ${index} → ${mappedVoice.name}`)
|
||||||
|
return mappedVoice.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('所有映射方式都失败,返回原始ID:', ttsVoiceId)
|
||||||
|
return ttsVoiceId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新显示名称
|
||||||
|
function updateDisplayNames() {
|
||||||
|
if (!formData.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
displayNames.value.vad = getModelDisplayName('VAD', formData.value.vadModelId)
|
||||||
|
displayNames.value.asr = getModelDisplayName('ASR', formData.value.asrModelId)
|
||||||
|
displayNames.value.llm = getModelDisplayName('LLM', formData.value.llmModelId)
|
||||||
|
displayNames.value.vllm = getModelDisplayName('VLLM', formData.value.vllmModelId)
|
||||||
|
displayNames.value.intent = getModelDisplayName('Intent', formData.value.intentModelId)
|
||||||
|
displayNames.value.memory = getModelDisplayName('Memory', formData.value.memModelId)
|
||||||
|
displayNames.value.tts = getModelDisplayName('TTS', formData.value.ttsModelId)
|
||||||
|
|
||||||
|
// 角色音色特殊处理
|
||||||
|
displayNames.value.voiceprint = getVoiceDisplayName(formData.value.ttsVoiceId || '')
|
||||||
|
|
||||||
|
console.log('最终音色显示名称:', displayNames.value.voiceprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载角色模板
|
||||||
|
async function loadRoleTemplates() {
|
||||||
|
try {
|
||||||
|
const templates = await getRoleTemplates()
|
||||||
|
roleTemplates.value = templates
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('加载角色模板失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载模型选项
|
||||||
|
async function loadModelOptions() {
|
||||||
|
const modelTypes = ['VAD', 'ASR', 'LLM', 'VLLM', 'Intent', 'Memory', 'TTS']
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
modelTypes?.map(async (type) => {
|
||||||
|
console.log(`加载模型类型: ${type}`)
|
||||||
|
const options = await getModelOptions(type)
|
||||||
|
modelOptions.value[type] = options
|
||||||
|
console.log(`${type} 选项:`, options)
|
||||||
|
}) || [],
|
||||||
|
)
|
||||||
|
console.log('所有模型选项加载完成:', modelOptions.value)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('加载模型选项失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载TTS音色选项
|
||||||
|
async function loadVoiceOptions(ttsModelId?: string) {
|
||||||
|
if (!ttsModelId)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`加载音色选项: ${ttsModelId}`)
|
||||||
|
const voices = await getTTSVoices(ttsModelId)
|
||||||
|
voiceOptions.value = voices
|
||||||
|
console.log('音色选项:', voices)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('加载音色选项失败:', error)
|
||||||
|
voiceOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择角色模板
|
||||||
|
function selectRoleTemplate(templateId: string) {
|
||||||
|
if (selectedTemplateId.value === templateId) {
|
||||||
|
selectedTemplateId.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTemplateId.value = templateId
|
||||||
|
const template = roleTemplates.value.find(t => t.id === templateId)
|
||||||
|
if (template) {
|
||||||
|
formData.value.systemPrompt = template.systemPrompt
|
||||||
|
formData.value.vadModelId = template.vadModelId
|
||||||
|
formData.value.asrModelId = template.asrModelId
|
||||||
|
formData.value.llmModelId = template.llmModelId
|
||||||
|
formData.value.vllmModelId = template.vllmModelId
|
||||||
|
formData.value.intentModelId = template.intentModelId
|
||||||
|
formData.value.memModelId = template.memModelId
|
||||||
|
formData.value.ttsModelId = template.ttsModelId
|
||||||
|
formData.value.ttsVoiceId = template.ttsVoiceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开选择器
|
||||||
|
function openPicker(type: string) {
|
||||||
|
pickerShow.value[type] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择器确认
|
||||||
|
async function onPickerConfirm(type: string, value: any, name: string) {
|
||||||
|
console.log('选择器确认:', type, value, name)
|
||||||
|
|
||||||
|
// 保存显示名称
|
||||||
|
displayNames.value[type] = name
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'vad':
|
||||||
|
formData.value.vadModelId = value
|
||||||
|
break
|
||||||
|
case 'asr':
|
||||||
|
formData.value.asrModelId = value
|
||||||
|
break
|
||||||
|
case 'llm':
|
||||||
|
formData.value.llmModelId = value
|
||||||
|
break
|
||||||
|
case 'vllm':
|
||||||
|
formData.value.vllmModelId = value
|
||||||
|
break
|
||||||
|
case 'intent':
|
||||||
|
formData.value.intentModelId = value
|
||||||
|
displayNames.value.intent = name // 确保显示名称正确更新
|
||||||
|
break
|
||||||
|
case 'memory':
|
||||||
|
formData.value.memModelId = value
|
||||||
|
displayNames.value.memory = name // 确保显示名称正确更新
|
||||||
|
break
|
||||||
|
case 'tts':
|
||||||
|
formData.value.ttsModelId = value
|
||||||
|
// 当选择TTS模型时,自动加载对应的音色选项
|
||||||
|
await loadVoiceOptions(value)
|
||||||
|
// 重置音色选择
|
||||||
|
formData.value.ttsVoiceId = ''
|
||||||
|
displayNames.value.voiceprint = '请选择'
|
||||||
|
break
|
||||||
|
case 'voiceprint':
|
||||||
|
formData.value.ttsVoiceId = value
|
||||||
|
displayNames.value.voiceprint = name // 确保显示名称正确更新
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
pickerShow.value[type] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择器取消
|
||||||
|
function onPickerCancel(type: string) {
|
||||||
|
pickerShow.value[type] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型显示名称
|
||||||
|
function getModelDisplayName(modelType: string, modelId: string) {
|
||||||
|
if (!modelId)
|
||||||
|
return '请选择'
|
||||||
|
|
||||||
|
// 直接从API配置数据中查找匹配的ID
|
||||||
|
const options = modelOptions.value[modelType]
|
||||||
|
|
||||||
|
if (!options || options.length === 0) {
|
||||||
|
return modelId
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = options.find(opt => opt.id === modelId)
|
||||||
|
if (option) {
|
||||||
|
return option.modelName
|
||||||
|
}
|
||||||
|
return modelId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存智能体
|
||||||
|
async function saveAgent() {
|
||||||
|
if (!formData.value.agentName?.trim()) {
|
||||||
|
toast.warning('请输入助手昵称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.value.systemPrompt?.trim()) {
|
||||||
|
toast.warning('请输入角色介绍')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
await updateAgent(agentId.value, formData.value)
|
||||||
|
|
||||||
|
toast.success('保存成功')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('保存失败:', error)
|
||||||
|
toast.error('保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPluginFunctions() {
|
||||||
|
getPluginFunctions().then((res) => {
|
||||||
|
const processedFunctions = res?.map((item) => {
|
||||||
|
const meta = JSON.parse(item.fields || '[]')
|
||||||
|
const params = meta.reduce((m: any, f: any) => {
|
||||||
|
m[f.key] = f.default
|
||||||
|
return m
|
||||||
|
}, {})
|
||||||
|
return { ...item, fieldsMeta: meta, params }
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
allFunctions.value = processedFunctions
|
||||||
|
// 同时更新到store
|
||||||
|
pluginStore.setAllFunctions(processedFunctions)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTools() {
|
||||||
|
console.log('当前插件配置:', formData.value.functions)
|
||||||
|
|
||||||
|
// 确保store中有最新数据
|
||||||
|
pluginStore.setCurrentAgentId(agentId.value)
|
||||||
|
pluginStore.setCurrentFunctions(formData.value.functions || [])
|
||||||
|
pluginStore.setAllFunctions(allFunctions.value)
|
||||||
|
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages/agent/tools',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听插件配置更新
|
||||||
|
function watchPluginUpdates() {
|
||||||
|
// 监听store中的插件配置变化
|
||||||
|
watch(() => pluginStore.currentFunctions, (newFunctions) => {
|
||||||
|
console.log('插件配置已更新:', newFunctions)
|
||||||
|
formData.value.functions = newFunctions
|
||||||
|
}, { deep: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 初始化插件配置监听
|
||||||
|
watchPluginUpdates()
|
||||||
|
|
||||||
|
// 先加载模型选项和角色模板
|
||||||
|
await Promise.all([
|
||||||
|
loadRoleTemplates(),
|
||||||
|
loadModelOptions(),
|
||||||
|
loadPluginFunctions(),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 然后加载智能体详情,这样可以正确映射显示名称
|
||||||
|
if (agentId.value) {
|
||||||
|
await loadAgentDetail()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="bg-[#f5f7fb] px-[20rpx]">
|
||||||
|
<!-- 基础信息标题 -->
|
||||||
|
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
基础信息
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 基础信息卡片 -->
|
||||||
|
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||||
|
<view class="mb-[24rpx] last:mb-0">
|
||||||
|
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
助手昵称
|
||||||
|
</text>
|
||||||
|
<input
|
||||||
|
v-model="formData.agentName"
|
||||||
|
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入助手昵称"
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-[24rpx] last:mb-0">
|
||||||
|
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
角色模式
|
||||||
|
</text>
|
||||||
|
<view class="mt-0 flex flex-wrap gap-[12rpx]">
|
||||||
|
<view
|
||||||
|
v-for="template in roleTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
class="cursor-pointer rounded-[20rpx] px-[24rpx] py-[12rpx] text-[24rpx] transition-all duration-300"
|
||||||
|
:class="selectedTemplateId === template.id
|
||||||
|
? 'bg-[#336cff] text-white border border-[#336cff]'
|
||||||
|
: 'bg-[rgba(51,108,255,0.1)] text-[#336cff] border border-[rgba(51,108,255,0.2)]'"
|
||||||
|
@click="selectRoleTemplate(template.id)"
|
||||||
|
>
|
||||||
|
{{ template.agentName }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-[24rpx] last:mb-0">
|
||||||
|
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
角色介绍
|
||||||
|
</text>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.systemPrompt"
|
||||||
|
:maxlength="2000"
|
||||||
|
placeholder="请输入角色介绍"
|
||||||
|
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
/>
|
||||||
|
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
|
||||||
|
{{ (formData.systemPrompt || '').length }}/2000
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 模型配置标题 -->
|
||||||
|
<view class="pb-[20rpx]">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
模型配置
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 模型配置卡片 -->
|
||||||
|
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||||
|
<view class="flex flex-col gap-[16rpx]">
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vad')">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
语音活动检测
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ displayNames.vad }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('asr')">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
语音识别
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ displayNames.asr }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('llm')">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
大语言模型
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ displayNames.llm }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vllm')">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
视觉大模型
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ displayNames.vllm }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('intent')">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
意图识别
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ displayNames.intent }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('memory')">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
记忆
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ displayNames.memory }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 语音设置标题 -->
|
||||||
|
<view class="pb-[20rpx]">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
语音设置
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 语音设置卡片 -->
|
||||||
|
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||||
|
<view class="flex flex-col gap-[16rpx]">
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('tts')">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
语音合成
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ displayNames.tts }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('voiceprint')">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
角色音色
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ displayNames.voiceprint }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx]">
|
||||||
|
<view class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
插件
|
||||||
|
</view>
|
||||||
|
<view class="cursor-pointer rounded-[20rpx] bg-[rgba(51,108,255,0.1)] px-[24rpx] py-[12rpx] text-[24rpx] text-[#336cff] transition-all duration-300 active:bg-[#336cff] active:text-white" @click="handleTools">
|
||||||
|
<text>编辑功能</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 记忆历史标题 -->
|
||||||
|
<view class="pb-[20rpx]">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
历史记忆
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 记忆历史卡片 -->
|
||||||
|
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||||
|
<view class="mb-[24rpx] last:mb-0">
|
||||||
|
<textarea
|
||||||
|
v-model="formData.summaryMemory"
|
||||||
|
placeholder="记忆内容"
|
||||||
|
disabled
|
||||||
|
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f0f0f0] p-[20rpx] text-[26rpx] text-[#65686f] leading-[1.6] opacity-80 outline-none"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 保存按钮 -->
|
||||||
|
<view class="mt-[40rpx] p-0">
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="saving"
|
||||||
|
custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
|
||||||
|
@click="saveAgent"
|
||||||
|
>
|
||||||
|
{{ saving ? '保存中...' : '保存' }}
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
<!-- 模型选择器 -->
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="pickerShow.vad"
|
||||||
|
:actions="modelOptions.VAD && modelOptions.VAD.map(item => ({ name: item.modelName, value: item.id }))"
|
||||||
|
@close="onPickerCancel('vad')"
|
||||||
|
@select="({ item }) => onPickerConfirm('vad', item.value, item.name)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="pickerShow.asr"
|
||||||
|
:actions="modelOptions.ASR && modelOptions.ASR.map(item => ({ name: item.modelName, value: item.id }))"
|
||||||
|
@close="onPickerCancel('asr')"
|
||||||
|
@select="({ item }) => onPickerConfirm('asr', item.value, item.name)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="pickerShow.llm"
|
||||||
|
:actions="modelOptions.LLM && modelOptions.LLM.map(item => ({ name: item.modelName, value: item.id }))"
|
||||||
|
@close="onPickerCancel('llm')"
|
||||||
|
@select="({ item }) => onPickerConfirm('llm', item.value, item.name)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="pickerShow.vllm"
|
||||||
|
:actions="modelOptions.VLLM && modelOptions.VLLM.map(item => ({ name: item.modelName, value: item.id }))"
|
||||||
|
@close="onPickerCancel('vllm')"
|
||||||
|
@select="({ item }) => onPickerConfirm('vllm', item.value, item.name)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="pickerShow.intent"
|
||||||
|
:actions="modelOptions.Intent && modelOptions.Intent.map(item => ({ name: item.modelName, value: item.id }))"
|
||||||
|
@close="onPickerCancel('intent')"
|
||||||
|
@select="({ item }) => onPickerConfirm('intent', item.value, item.name)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="pickerShow.memory"
|
||||||
|
:actions="modelOptions.Memory && modelOptions.Memory.map(item => ({ name: item.modelName, value: item.id }))"
|
||||||
|
@close="onPickerCancel('memory')"
|
||||||
|
@select="({ item }) => onPickerConfirm('memory', item.value, item.name)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="pickerShow.tts"
|
||||||
|
:actions="modelOptions.TTS && modelOptions.TTS.map(item => ({ name: item.modelName, value: item.id }))"
|
||||||
|
@close="onPickerCancel('tts')"
|
||||||
|
@select="({ item }) => onPickerConfirm('tts', item.value, item.name)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="pickerShow.voiceprint"
|
||||||
|
:actions="voiceOptions && voiceOptions.map(item => ({ name: item.name, value: item.id }))"
|
||||||
|
@close="onPickerCancel('voiceprint')"
|
||||||
|
@select="({ item }) => onPickerConfirm('voiceprint', item.value, item.name)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<route lang="jsonc" type="page">
|
||||||
|
{
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "智能体",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import CustomTabs from '@/components/custom-tabs/index.vue'
|
||||||
|
import ChatHistory from '@/pages/chat-history/index.vue'
|
||||||
|
import DeviceManagement from '@/pages/device/index.vue'
|
||||||
|
import VoiceprintManagement from '@/pages/voiceprint/index.vue'
|
||||||
|
import AgentEdit from './edit.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'AgentIndex',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取屏幕边界到安全区域距离
|
||||||
|
let safeAreaInsets: any
|
||||||
|
let systemInfo: any
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
systemInfo = uni.getWindowInfo()
|
||||||
|
safeAreaInsets = systemInfo.safeArea
|
||||||
|
? {
|
||||||
|
top: systemInfo.safeArea.top,
|
||||||
|
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||||
|
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||||
|
left: systemInfo.safeArea.left,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
systemInfo = uni.getSystemInfoSync()
|
||||||
|
safeAreaInsets = systemInfo.safeAreaInsets
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
|
||||||
|
// 智能体ID
|
||||||
|
const currentAgentId = ref('default')
|
||||||
|
|
||||||
|
// 当前 tab
|
||||||
|
const currentTab = ref('agent-config')
|
||||||
|
|
||||||
|
// 刷新和加载状态
|
||||||
|
const refreshing = ref(false)
|
||||||
|
|
||||||
|
// 计算是否启用下拉刷新(角色编辑页面不启用)
|
||||||
|
const refresherEnabled = computed(() => {
|
||||||
|
return currentTab.value !== 'agent-config'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 子组件引用
|
||||||
|
const deviceRef = ref()
|
||||||
|
const chatRef = ref()
|
||||||
|
const voiceprintRef = ref()
|
||||||
|
|
||||||
|
// Tab 配置
|
||||||
|
const tabList = [
|
||||||
|
{
|
||||||
|
label: '角色配置',
|
||||||
|
value: 'agent-config',
|
||||||
|
icon: '/static/tabbar/robot.png',
|
||||||
|
activeIcon: '/static/tabbar/robot_activate.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '设备管理',
|
||||||
|
value: 'device-management',
|
||||||
|
icon: '/static/tabbar/device.png',
|
||||||
|
activeIcon: '/static/tabbar/device_activate.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '聊天记录',
|
||||||
|
value: 'chat-history',
|
||||||
|
icon: '/static/tabbar/chat.png',
|
||||||
|
activeIcon: '/static/tabbar/chat_activate.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '声纹管理',
|
||||||
|
value: 'voiceprint-management',
|
||||||
|
icon: '/static/tabbar/microphone.png',
|
||||||
|
activeIcon: '/static/tabbar/microphone_activate.png',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 返回上一页
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 tab 切换
|
||||||
|
function handleTabChange(item: any) {
|
||||||
|
console.log('Tab changed:', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
async function onRefresh() {
|
||||||
|
// 角色编辑页面不需要刷新
|
||||||
|
if (currentTab.value === 'agent-config') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (currentTab.value) {
|
||||||
|
case 'device-management':
|
||||||
|
if (deviceRef.value?.refresh) {
|
||||||
|
await deviceRef.value.refresh()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'chat-history':
|
||||||
|
if (chatRef.value?.refresh) {
|
||||||
|
await chatRef.value.refresh()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'voiceprint-management':
|
||||||
|
if (voiceprintRef.value?.refresh) {
|
||||||
|
await voiceprintRef.value.refresh()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('刷新失败:', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触底加载更多
|
||||||
|
async function onLoadMore() {
|
||||||
|
// 只有聊天记录需要加载更多
|
||||||
|
if (currentTab.value === 'chat-history' && chatRef.value?.loadMore) {
|
||||||
|
await chatRef.value.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接收页面参数
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options?.agentId) {
|
||||||
|
currentAgentId.value = options.agentId
|
||||||
|
console.log('接收到智能体ID:', options.agentId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 页面初始化
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="h-screen flex flex-col bg-[#f5f7fb]">
|
||||||
|
<!-- 导航栏 -->
|
||||||
|
<wd-navbar title="智能体" safe-area-inset-top>
|
||||||
|
<template #left>
|
||||||
|
<wd-icon name="arrow-left" size="18" @click="goBack" />
|
||||||
|
</template>
|
||||||
|
</wd-navbar>
|
||||||
|
|
||||||
|
<!-- 自定义 Tabs -->
|
||||||
|
<CustomTabs
|
||||||
|
v-model="currentTab"
|
||||||
|
:tab-list="tabList"
|
||||||
|
@change="handleTabChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 主内容滚动区域 -->
|
||||||
|
<scroll-view
|
||||||
|
scroll-y
|
||||||
|
:style="{ height: `calc(100vh - ${safeAreaInsets?.top || 0}px - 180rpx)` }"
|
||||||
|
class="box-border flex-1 bg-[#f5f7fb]"
|
||||||
|
enable-back-to-top
|
||||||
|
:refresher-enabled="refresherEnabled"
|
||||||
|
:refresher-triggered="refreshing"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
@scrolltolower="onLoadMore"
|
||||||
|
>
|
||||||
|
<!-- Tab 内容 -->
|
||||||
|
<view class="flex-1">
|
||||||
|
<AgentEdit
|
||||||
|
v-if="currentTab === 'agent-config'"
|
||||||
|
:agent-id="currentAgentId"
|
||||||
|
/>
|
||||||
|
<DeviceManagement
|
||||||
|
v-else-if="currentTab === 'device-management'"
|
||||||
|
ref="deviceRef"
|
||||||
|
:agent-id="currentAgentId"
|
||||||
|
/>
|
||||||
|
<ChatHistory
|
||||||
|
v-else-if="currentTab === 'chat-history'"
|
||||||
|
ref="chatRef"
|
||||||
|
:agent-id="currentAgentId"
|
||||||
|
/>
|
||||||
|
<VoiceprintManagement
|
||||||
|
v-else-if="currentTab === 'voiceprint-management'"
|
||||||
|
ref="voiceprintRef"
|
||||||
|
:agent-id="currentAgentId"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
<route lang="jsonc" type="page">
|
||||||
|
{
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "编辑功能",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useMessage } from 'wot-design-uni'
|
||||||
|
import { getMcpAddress, getMcpTools } from '@/api/agent/agent'
|
||||||
|
import { usePluginStore } from '@/store'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const pluginStore = usePluginStore()
|
||||||
|
|
||||||
|
const segmentedList = ref<string[]>(['未选', '已选'])
|
||||||
|
const currentSegmented = ref('未选')
|
||||||
|
const notSelectedList = ref<any[]>([])
|
||||||
|
const selectedList = ref<any[]>([])
|
||||||
|
|
||||||
|
// 使用计算属性从store获取数据
|
||||||
|
const allFunctions = computed(() => pluginStore.allFunctions)
|
||||||
|
const functions = computed(() => pluginStore.currentFunctions)
|
||||||
|
const agentId = computed(() => pluginStore.currentAgentId)
|
||||||
|
const mcpAddress = ref('')
|
||||||
|
const mcpTools = ref<string[]>([])
|
||||||
|
|
||||||
|
// 初始化时从本地存储加载MCP地址
|
||||||
|
if (uni.getStorageSync('cachedMcpAddress_' + agentId.value)) {
|
||||||
|
mcpAddress.value = uni.getStorageSync('cachedMcpAddress_' + agentId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参数编辑相关
|
||||||
|
const showParamDialog = ref(false)
|
||||||
|
const currentFunction = ref<any>(null)
|
||||||
|
const tempParams = ref<Record<string, any>>({})
|
||||||
|
const arrayTextCache = ref<Record<string, string>>({})
|
||||||
|
const jsonTextCache = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
async function mergeFunctions() {
|
||||||
|
selectedList.value = functions.value.map((mapping) => {
|
||||||
|
const meta = allFunctions.value.find(f => f.id === mapping.pluginId)
|
||||||
|
if (!meta) {
|
||||||
|
return { id: mapping.pluginId, name: mapping.pluginId, params: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: mapping.pluginId,
|
||||||
|
name: meta.name,
|
||||||
|
params: mapping.paramInfo || { ...meta.params },
|
||||||
|
fieldsMeta: meta.fieldsMeta,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 未选的插件
|
||||||
|
notSelectedList.value = allFunctions.value.filter(
|
||||||
|
item => !selectedList.value.some(f => f.id === item.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (agentId.value) {
|
||||||
|
// 优先获取并显示MCP地址
|
||||||
|
try {
|
||||||
|
const address = await getMcpAddress(agentId.value)
|
||||||
|
mcpAddress.value = address
|
||||||
|
// 缓存到本地存储,下次打开页面可以立即显示
|
||||||
|
uni.setStorageSync('cachedMcpAddress_' + agentId.value, address)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取MCP地址失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步获取MCP工具列表,不阻塞UI显示
|
||||||
|
try {
|
||||||
|
const tools = await getMcpTools(agentId.value)
|
||||||
|
mcpTools.value = tools || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取MCP工具列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加插件到已选
|
||||||
|
function selectFunction(func: any) {
|
||||||
|
// 添加到已选列表
|
||||||
|
selectedList.value.push({
|
||||||
|
id: func.id,
|
||||||
|
name: func.name,
|
||||||
|
params: { ...func.params },
|
||||||
|
fieldsMeta: func.fieldsMeta,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从未选列表中移除
|
||||||
|
notSelectedList.value = notSelectedList.value.filter(
|
||||||
|
item => item.id !== func.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从已选中移除插件
|
||||||
|
function removeFunction(func: any) {
|
||||||
|
// 从已选列表中移除
|
||||||
|
selectedList.value = selectedList.value.filter(item => item.id !== func.id)
|
||||||
|
|
||||||
|
// 添加回未选列表
|
||||||
|
const originalFunc = allFunctions.value.find(f => f.id === func.id)
|
||||||
|
if (originalFunc) {
|
||||||
|
notSelectedList.value.push(originalFunc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑插件参数
|
||||||
|
function editFunction(func: any) {
|
||||||
|
currentFunction.value = func
|
||||||
|
|
||||||
|
// 直接使用当前函数的参数
|
||||||
|
tempParams.value = { ...func.params }
|
||||||
|
|
||||||
|
// 初始化文本缓存
|
||||||
|
if (func.fieldsMeta) {
|
||||||
|
func.fieldsMeta.forEach((field: any) => {
|
||||||
|
if (field.type === 'array') {
|
||||||
|
const value = tempParams.value[field.key]
|
||||||
|
arrayTextCache.value[field.key] = Array.isArray(value)
|
||||||
|
? value.join('\n')
|
||||||
|
: value || ''
|
||||||
|
}
|
||||||
|
else if (field.type === 'json') {
|
||||||
|
const value = tempParams.value[field.key]
|
||||||
|
try {
|
||||||
|
jsonTextCache.value[field.key] = JSON.stringify(value || {}, null, 2)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
jsonTextCache.value[field.key] = '{}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
showParamDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理参数变化 - 实时保存
|
||||||
|
function handleParamChange(key: string, value: any, field: any) {
|
||||||
|
tempParams.value[key] = value
|
||||||
|
|
||||||
|
// 实时更新到 selectedList
|
||||||
|
if (currentFunction.value) {
|
||||||
|
const index = selectedList.value.findIndex(
|
||||||
|
f => f.id === currentFunction.value.id,
|
||||||
|
)
|
||||||
|
if (index >= 0) {
|
||||||
|
selectedList.value[index].params = { ...tempParams.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理数组类型参数变化 - 实时保存
|
||||||
|
function handleArrayChange(key: string, value: string, field: any) {
|
||||||
|
arrayTextCache.value[key] = value
|
||||||
|
// 转换为数组存储
|
||||||
|
const arrayValue = value.split('\n').filter(Boolean)
|
||||||
|
tempParams.value[key] = arrayValue
|
||||||
|
|
||||||
|
// 实时更新到 selectedList
|
||||||
|
if (currentFunction.value) {
|
||||||
|
const index = selectedList.value.findIndex(
|
||||||
|
f => f.id === currentFunction.value.id,
|
||||||
|
)
|
||||||
|
if (index >= 0) {
|
||||||
|
selectedList.value[index].params = { ...tempParams.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理JSON类型参数变化 - 实时保存
|
||||||
|
function handleJsonChange(key: string, value: string, field: any) {
|
||||||
|
jsonTextCache.value[key] = value
|
||||||
|
try {
|
||||||
|
const jsonValue = JSON.parse(value)
|
||||||
|
tempParams.value[key] = jsonValue
|
||||||
|
|
||||||
|
// 实时更新到 selectedList
|
||||||
|
if (currentFunction.value) {
|
||||||
|
const index = selectedList.value.findIndex(
|
||||||
|
f => f.id === currentFunction.value.id,
|
||||||
|
)
|
||||||
|
if (index >= 0) {
|
||||||
|
selectedList.value[index].params = { ...tempParams.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
message.alert('JSON格式错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭参数编辑弹窗
|
||||||
|
function closeParamEdit() {
|
||||||
|
showParamDialog.value = false
|
||||||
|
tempParams.value = {}
|
||||||
|
arrayTextCache.value = {}
|
||||||
|
jsonTextCache.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上一页并更新配置
|
||||||
|
function goBack() {
|
||||||
|
const finalFunctions = selectedList.value.map(f => ({
|
||||||
|
pluginId: f.id,
|
||||||
|
paramInfo: f.params,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 更新到store中
|
||||||
|
pluginStore.updateFunctions(finalFunctions)
|
||||||
|
|
||||||
|
// 直接返回
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制MCP地址
|
||||||
|
function copyMcpAddress() {
|
||||||
|
if (!mcpAddress.value) {
|
||||||
|
message.alert('暂无MCP地址可复制')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: mcpAddress.value,
|
||||||
|
showToast: false,
|
||||||
|
success: () => {
|
||||||
|
message.alert('MCP地址已复制到剪贴板')
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
message.alert('复制失败,请重试')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染参数字段的辅助函数
|
||||||
|
function getFieldDisplayValue(field: any, value: any) {
|
||||||
|
if (field.type === 'array') {
|
||||||
|
return Array.isArray(value) ? value.join('\n') : value || ''
|
||||||
|
}
|
||||||
|
return value || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段说明
|
||||||
|
function getFieldRemark(field: any) {
|
||||||
|
let description = field.label || ''
|
||||||
|
if (field.default) {
|
||||||
|
description += `(默认值:${field.default})`
|
||||||
|
}
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 直接从store获取数据并合并
|
||||||
|
await mergeFunctions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="h-screen flex flex-col bg-[#f5f7fb]">
|
||||||
|
<!-- 头部导航 -->
|
||||||
|
<wd-navbar
|
||||||
|
title=""
|
||||||
|
safe-area-inset-top
|
||||||
|
left-arrow
|
||||||
|
:bordered="false"
|
||||||
|
@click-left="goBack"
|
||||||
|
>
|
||||||
|
<template #left>
|
||||||
|
<wd-icon name="arrow-left" size="18" />
|
||||||
|
</template>
|
||||||
|
</wd-navbar>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<scroll-view
|
||||||
|
scroll-y
|
||||||
|
class="box-border flex-1 bg-transparent px-[20rpx]"
|
||||||
|
:style="{ height: 'calc(100vh - 120rpx)' }"
|
||||||
|
:scroll-with-animation="true"
|
||||||
|
>
|
||||||
|
<!-- 内置插件区域 -->
|
||||||
|
<view class="mt-[20rpx] flex flex-1 flex-col">
|
||||||
|
<view class="text-[32rpx] text-[#333] font-medium">
|
||||||
|
内置插件
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]"
|
||||||
|
>
|
||||||
|
<!-- 分段控制器 -->
|
||||||
|
<wd-segmented
|
||||||
|
v-model:value="currentSegmented"
|
||||||
|
:options="segmentedList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 插件列表 -->
|
||||||
|
<view class="mt-[20rpx] flex-1 overflow-hidden">
|
||||||
|
<!-- 未选插件 -->
|
||||||
|
<scroll-view
|
||||||
|
v-if="currentSegmented === '未选'"
|
||||||
|
class="max-h-[600rpx] bg-transparent"
|
||||||
|
scroll-y
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
v-if="notSelectedList.length === 0"
|
||||||
|
class="h-[400rpx] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<wd-status-tip image="content" tip="暂无更多插件" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="p-[20rpx] space-y-[20rpx]">
|
||||||
|
<view
|
||||||
|
v-for="func in notSelectedList"
|
||||||
|
:key="func.id"
|
||||||
|
class="flex items-center justify-between border border-[#e9ecef] rounded-[10rpx] bg-[#f8f9fa] p-[20rpx]"
|
||||||
|
@click="selectFunction(func)"
|
||||||
|
>
|
||||||
|
<view class="flex-1">
|
||||||
|
<view
|
||||||
|
class="mb-[10rpx] text-[30rpx] text-[#333] font-medium"
|
||||||
|
>
|
||||||
|
{{ func.name }}
|
||||||
|
</view>
|
||||||
|
<view class="text-[24rpx] text-[#666]">
|
||||||
|
{{ func.providerCode }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#1677ff]"
|
||||||
|
>
|
||||||
|
<text class="text-[36rpx] text-white">
|
||||||
|
+
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 已选插件 -->
|
||||||
|
<scroll-view v-else class="max-h-[600rpx] bg-transparent" scroll-y>
|
||||||
|
<view
|
||||||
|
v-if="selectedList.length === 0"
|
||||||
|
class="h-[400rpx] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<wd-status-tip image="content" tip="请选择插件功能" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="p-[20rpx] space-y-[20rpx]">
|
||||||
|
<view
|
||||||
|
v-for="func in selectedList"
|
||||||
|
:key="func.id"
|
||||||
|
class="border border-[#d4edff] rounded-[10rpx] bg-[#f0f7ff] p-[20rpx]"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<view class="flex-1" @click="editFunction(func)">
|
||||||
|
<view
|
||||||
|
class="mb-[10rpx] text-[30rpx] text-[#333] font-medium"
|
||||||
|
>
|
||||||
|
{{ func.name }}
|
||||||
|
</view>
|
||||||
|
<view class="text-[24rpx] text-[#1677ff]">
|
||||||
|
点击配置参数
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="flex space-x-[20rpx]">
|
||||||
|
<!-- 配置按钮 -->
|
||||||
|
<view
|
||||||
|
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#1677ff]"
|
||||||
|
@click="editFunction(func)"
|
||||||
|
>
|
||||||
|
<text class="text-[24rpx] text-white">
|
||||||
|
⚙
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<!-- 移除按钮 -->
|
||||||
|
<view
|
||||||
|
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#ff4757]"
|
||||||
|
@click="removeFunction(func)"
|
||||||
|
>
|
||||||
|
<text class="text-[32rpx] text-white">
|
||||||
|
×
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- MCP接入点区域 -->
|
||||||
|
<view class="mt-[20rpx] flex flex-1 flex-col">
|
||||||
|
<view class="text-[32rpx] text-[#333] font-medium">
|
||||||
|
mcp接入点
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between text-[24rpx]">
|
||||||
|
<input
|
||||||
|
v-model="mcpAddress"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
class="flex-1 rounded-[10rpx] bg-[#f5f7fb] p-[20rpx]"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="ml-[20rpx] h-[70rpx] flex items-center justify-center rounded-[10rpx] bg-[#1677ff] px-[20rpx] text-[24rpx] text-white"
|
||||||
|
@click="copyMcpAddress"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 工具列表 -->
|
||||||
|
<view class="mt-[20rpx] flex-1 overflow-hidden">
|
||||||
|
<scroll-view class="max-h-[600rpx] bg-transparent" scroll-y>
|
||||||
|
<view
|
||||||
|
v-if="mcpTools && mcpTools.length === 0"
|
||||||
|
class="h-[400rpx] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<wd-status-tip image="content" tip="暂无工具" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="p-[20rpx]">
|
||||||
|
<view class="flex flex-wrap">
|
||||||
|
<view
|
||||||
|
v-for="tool in mcpTools"
|
||||||
|
:key="tool"
|
||||||
|
class="mb-[20rpx] mr-[20rpx] rounded-[10rpx] bg-[#f5f7fb] p-[20rpx]"
|
||||||
|
>
|
||||||
|
{{ tool }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 参数编辑弹窗 -->
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="showParamDialog"
|
||||||
|
:title="`参数配置 - ${currentFunction?.name || ''}`"
|
||||||
|
custom-header-class="h-[75vh]"
|
||||||
|
@close="closeParamEdit"
|
||||||
|
>
|
||||||
|
<scroll-view
|
||||||
|
scroll-y
|
||||||
|
class="bg-[#f5f7fb]"
|
||||||
|
:style="{ height: 'calc(75vh - 60rpx)' }"
|
||||||
|
>
|
||||||
|
<view class="p-[30rpx] pb-[40rpx]">
|
||||||
|
<!-- 无参数提示 -->
|
||||||
|
<view
|
||||||
|
v-if="
|
||||||
|
!currentFunction?.fieldsMeta
|
||||||
|
|| currentFunction.fieldsMeta.length === 0
|
||||||
|
"
|
||||||
|
class="h-[400rpx] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<text class="text-[28rpx] text-[#999]">
|
||||||
|
{{ currentFunction?.name }} 无需配置参数
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 参数表单 - 卡片式布局 -->
|
||||||
|
<view v-else class="flex flex-col gap-[24rpx]">
|
||||||
|
<view
|
||||||
|
v-for="field in currentFunction.fieldsMeta"
|
||||||
|
:key="field.key"
|
||||||
|
class="border border-[#eeeeee] rounded-[20rpx] bg-white p-[30rpx]"
|
||||||
|
style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);"
|
||||||
|
>
|
||||||
|
<!-- 字段信息 -->
|
||||||
|
<view class="mb-[24rpx]">
|
||||||
|
<text class="mb-[8rpx] block text-[32rpx] text-[#232338] font-medium">
|
||||||
|
{{ field.label }}
|
||||||
|
</text>
|
||||||
|
<text v-if="getFieldRemark(field)" class="block text-[24rpx] text-[#65686f] leading-[1.5]">
|
||||||
|
{{ getFieldRemark(field) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 输入控件 -->
|
||||||
|
<view>
|
||||||
|
<!-- 字符串类型 -->
|
||||||
|
<input
|
||||||
|
v-if="field.type === 'string'"
|
||||||
|
v-model="tempParams[field.key]"
|
||||||
|
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
type="text"
|
||||||
|
:placeholder="`请输入${field.label}`"
|
||||||
|
@input="
|
||||||
|
handleParamChange(field.key, $event.detail.value, field)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- 数组类型 -->
|
||||||
|
<view v-else-if="field.type === 'array'">
|
||||||
|
<text class="mb-[16rpx] block text-[24rpx] text-[#65686f]">
|
||||||
|
每行输入一个项目
|
||||||
|
</text>
|
||||||
|
<textarea
|
||||||
|
v-model="arrayTextCache[field.key]"
|
||||||
|
class="box-border min-h-[200rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
:placeholder="`请输入${field.label},每行一个`"
|
||||||
|
@input="
|
||||||
|
handleArrayChange(field.key, $event.detail.value, field)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- JSON类型 -->
|
||||||
|
<view v-else-if="field.type === 'json'">
|
||||||
|
<text class="mb-[16rpx] block text-[24rpx] text-[#65686f]">
|
||||||
|
请输入有效的JSON格式
|
||||||
|
</text>
|
||||||
|
<textarea
|
||||||
|
v-model="jsonTextCache[field.key]"
|
||||||
|
class="box-border min-h-[300rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] font-mono focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
placeholder="请输入合法的JSON格式"
|
||||||
|
@blur="
|
||||||
|
handleJsonChange(field.key, $event.detail.value, field)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 数字类型 -->
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
v-model="tempParams[field.key]"
|
||||||
|
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
type="number"
|
||||||
|
:placeholder="`请输入${field.label}`"
|
||||||
|
@input="
|
||||||
|
handleParamChange(
|
||||||
|
field.key,
|
||||||
|
Number($event.detail.value),
|
||||||
|
field,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- 布尔类型 -->
|
||||||
|
<view
|
||||||
|
v-else-if="field.type === 'boolean' || field.type === 'bool'"
|
||||||
|
class="flex items-center justify-between py-[20rpx]"
|
||||||
|
>
|
||||||
|
<view class="flex-1">
|
||||||
|
<text class="mb-[8rpx] block text-[28rpx] text-[#232338]">
|
||||||
|
启用功能
|
||||||
|
</text>
|
||||||
|
<text class="block text-[24rpx] text-[#65686f]">
|
||||||
|
开启或关闭此功能
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<switch
|
||||||
|
:checked="tempParams[field.key]"
|
||||||
|
@change="
|
||||||
|
handleParamChange(field.key, $event.detail.value, field)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 默认字符串类型 -->
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="tempParams[field.key]"
|
||||||
|
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
type="text"
|
||||||
|
:placeholder="`请输入${field.label}`"
|
||||||
|
@input="
|
||||||
|
handleParamChange(field.key, $event.detail.value, field)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</wd-action-sheet>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<route lang="jsonc" type="page">
|
||||||
|
{
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "聊天详情"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ChatMessage, UserMessageContent } from '@/api/chat-history/types'
|
||||||
|
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history'
|
||||||
|
import { getEnvBaseUrl } from '@/utils'
|
||||||
|
import { toast } from '@/utils/toast'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChatDetail',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取屏幕边界到安全区域距离
|
||||||
|
let safeAreaInsets: any
|
||||||
|
let systemInfo: any
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
systemInfo = uni.getWindowInfo()
|
||||||
|
safeAreaInsets = systemInfo.safeArea
|
||||||
|
? {
|
||||||
|
top: systemInfo.safeArea.top,
|
||||||
|
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||||
|
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||||
|
left: systemInfo.safeArea.left,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
systemInfo = uni.getSystemInfoSync()
|
||||||
|
safeAreaInsets = systemInfo.safeAreaInsets
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 页面参数
|
||||||
|
const sessionId = ref('')
|
||||||
|
const agentId = ref('')
|
||||||
|
|
||||||
|
// 智能体信息(简化)
|
||||||
|
const currentAgent = computed(() => {
|
||||||
|
return {
|
||||||
|
id: agentId.value,
|
||||||
|
agentName: '智能助手',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 聊天数据
|
||||||
|
const messageList = ref<ChatMessage[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 音频播放相关
|
||||||
|
const audioContext = ref<UniApp.InnerAudioContext | null>(null)
|
||||||
|
const playingAudioId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 返回上一页
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载聊天记录
|
||||||
|
async function loadChatHistory() {
|
||||||
|
if (!sessionId.value || !agentId.value) {
|
||||||
|
console.error('缺少必要参数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await getChatHistory(agentId.value, sessionId.value)
|
||||||
|
messageList.value = response
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取聊天记录失败:', error)
|
||||||
|
toast.error('获取聊天记录失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析用户消息内容
|
||||||
|
function parseUserMessage(content: string): UserMessageContent | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(content)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取消息显示内容
|
||||||
|
function getMessageContent(message: ChatMessage): string {
|
||||||
|
if (message.chatType === 1) {
|
||||||
|
// 用户消息,需要解析JSON
|
||||||
|
const parsed = parseUserMessage(message.content)
|
||||||
|
return parsed ? parsed.content : message.content
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// AI消息,直接显示
|
||||||
|
return message.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取说话人名称
|
||||||
|
function getSpeakerName(message: ChatMessage): string {
|
||||||
|
if (message.chatType === 1) {
|
||||||
|
const parsed = parseUserMessage(message.content)
|
||||||
|
return parsed ? parsed.speaker : '用户'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return currentAgent.value?.agentName || 'AI助手'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
function formatTime(timeStr: string) {
|
||||||
|
const date = new Date(timeStr)
|
||||||
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放音频
|
||||||
|
async function playAudio(audioId: string) {
|
||||||
|
if (!audioId) {
|
||||||
|
toast.error('音频ID无效')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果正在播放其他音频,先停止
|
||||||
|
if (audioContext.value) {
|
||||||
|
audioContext.value.stop()
|
||||||
|
audioContext.value.destroy()
|
||||||
|
audioContext.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取音频下载ID
|
||||||
|
const downloadId = await getAudioId(audioId)
|
||||||
|
|
||||||
|
// 构造音频播放地址
|
||||||
|
const baseUrl = getEnvBaseUrl()
|
||||||
|
const audioUrl = `${baseUrl}/agent/play/${downloadId}`
|
||||||
|
|
||||||
|
// 创建音频上下文
|
||||||
|
audioContext.value = uni.createInnerAudioContext()
|
||||||
|
audioContext.value.src = audioUrl
|
||||||
|
|
||||||
|
// 设置播放状态
|
||||||
|
playingAudioId.value = audioId
|
||||||
|
|
||||||
|
// 监听播放完成
|
||||||
|
audioContext.value.onEnded(() => {
|
||||||
|
playingAudioId.value = null
|
||||||
|
if (audioContext.value) {
|
||||||
|
audioContext.value.destroy()
|
||||||
|
audioContext.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听播放错误
|
||||||
|
audioContext.value.onError((error) => {
|
||||||
|
console.error('音频播放失败:', error)
|
||||||
|
toast.error('音频播放失败')
|
||||||
|
playingAudioId.value = null
|
||||||
|
if (audioContext.value) {
|
||||||
|
audioContext.value.destroy()
|
||||||
|
audioContext.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 开始播放
|
||||||
|
audioContext.value.play()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('播放音频失败:', error)
|
||||||
|
toast.error('播放音频失败')
|
||||||
|
playingAudioId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options?.sessionId && options?.agentId) {
|
||||||
|
sessionId.value = options.sessionId
|
||||||
|
agentId.value = options.agentId
|
||||||
|
loadChatHistory()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.error('缺少必要参数')
|
||||||
|
toast.error('页面参数错误')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 页面销毁时清理音频资源
|
||||||
|
onUnload(() => {
|
||||||
|
if (audioContext.value) {
|
||||||
|
audioContext.value.stop()
|
||||||
|
audioContext.value.destroy()
|
||||||
|
audioContext.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="h-screen flex flex-col bg-[#f5f7fb]">
|
||||||
|
<!-- 状态栏背景 -->
|
||||||
|
<view class="w-full bg-white" :style="{ height: `${safeAreaInsets?.top}px` }" />
|
||||||
|
|
||||||
|
<!-- 导航栏 -->
|
||||||
|
<wd-navbar title="聊天详情">
|
||||||
|
<template #left>
|
||||||
|
<wd-icon name="arrow-left" size="18" @click="goBack" />
|
||||||
|
</template>
|
||||||
|
</wd-navbar>
|
||||||
|
|
||||||
|
<!-- 聊天消息列表 -->
|
||||||
|
<scroll-view
|
||||||
|
scroll-y
|
||||||
|
:style="{ height: `calc(100vh - ${safeAreaInsets?.top || 0}px - 120rpx)` }"
|
||||||
|
class="box-border flex-1 bg-[#f5f7fb] p-[20rpx]"
|
||||||
|
:scroll-into-view="`message-${messageList.length - 1}`"
|
||||||
|
>
|
||||||
|
<view v-if="loading" class="flex flex-col items-center justify-center gap-[20rpx] p-[100rpx_0]">
|
||||||
|
<wd-loading />
|
||||||
|
<text class="text-[28rpx] text-[#65686f]">
|
||||||
|
加载中...
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="flex flex-col gap-[20rpx]">
|
||||||
|
<view
|
||||||
|
v-for="(message, index) in messageList"
|
||||||
|
:id="`message-${index}`"
|
||||||
|
:key="index"
|
||||||
|
class="w-full flex"
|
||||||
|
:class="{
|
||||||
|
'justify-end': message.chatType === 1,
|
||||||
|
'justify-start': message.chatType === 2,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="max-w-[80%] flex flex-col gap-[8rpx]"
|
||||||
|
:class="{
|
||||||
|
'items-end': message.chatType === 1,
|
||||||
|
'items-start': message.chatType === 2,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- 消息气泡 -->
|
||||||
|
<view
|
||||||
|
class="shadow-message break-words rounded-[20rpx] p-[24rpx] leading-[1.4]"
|
||||||
|
:class="{
|
||||||
|
'bg-[#336cff] text-white': message.chatType === 1,
|
||||||
|
'bg-white text-[#232338] border border-[#eeeeee]': message.chatType === 2,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- 内容区域 - 使用flex布局让图标和文本对齐 -->
|
||||||
|
<view class="flex items-center gap-[12rpx]">
|
||||||
|
<!-- 音频播放图标 -->
|
||||||
|
<view
|
||||||
|
v-if="message.audioId"
|
||||||
|
class="flex-shrink-0 cursor-pointer transition-transform duration-200 active:scale-90"
|
||||||
|
:class="{
|
||||||
|
'text-white animate-pulse-audio': message.chatType === 1 && playingAudioId === message.audioId,
|
||||||
|
'text-[#ffd700]': message.chatType === 1 && playingAudioId === message.audioId && playingAudioId,
|
||||||
|
'text-[#336cff] animate-pulse-audio': message.chatType === 2 && playingAudioId === message.audioId,
|
||||||
|
'text-[#ff6b35]': message.chatType === 2 && playingAudioId === message.audioId && playingAudioId,
|
||||||
|
'text-white': message.chatType === 1 && playingAudioId !== message.audioId,
|
||||||
|
'text-[#336cff]': message.chatType === 2 && playingAudioId !== message.audioId,
|
||||||
|
}"
|
||||||
|
@click="playAudio(message.audioId)"
|
||||||
|
>
|
||||||
|
<wd-icon
|
||||||
|
:name="playingAudioId === message.audioId ? 'pause-circle-filled' : 'play-circle-filled'"
|
||||||
|
size="20"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 消息内容容器 -->
|
||||||
|
<view class="min-w-0 flex-1">
|
||||||
|
<!-- 消息内容 -->
|
||||||
|
<text class="block text-[28rpx]">
|
||||||
|
{{ getMessageContent(message) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 说话人信息 -->
|
||||||
|
<text
|
||||||
|
class="mx-[12rpx] text-[22rpx] text-[#9d9ea3]"
|
||||||
|
:class="{
|
||||||
|
'text-right': message.chatType === 1,
|
||||||
|
'text-left': message.chatType === 2,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ formatTime(message.createdAt) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 自定义阴影和动画效果,无法用UnoCSS表示的样式 */
|
||||||
|
.shadow-message {
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-audio {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-audio {
|
||||||
|
animation: pulse-audio 1.5s infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ChatSession } from '@/api/chat-history/types'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { getChatSessions } from '@/api/chat-history/chat-history'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChatHistory',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 接收props
|
||||||
|
interface Props {
|
||||||
|
agentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
agentId: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取屏幕边界到安全区域距离
|
||||||
|
let safeAreaInsets: any
|
||||||
|
let systemInfo: any
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
systemInfo = uni.getWindowInfo()
|
||||||
|
safeAreaInsets = systemInfo.safeArea
|
||||||
|
? {
|
||||||
|
top: systemInfo.safeArea.top,
|
||||||
|
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||||
|
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||||
|
left: systemInfo.safeArea.left,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
systemInfo = uni.getSystemInfoSync()
|
||||||
|
safeAreaInsets = systemInfo.safeAreaInsets
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 聊天会话数据
|
||||||
|
const sessionList = ref<ChatSession[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 10
|
||||||
|
|
||||||
|
// 使用传入的智能体ID
|
||||||
|
const currentAgentId = computed(() => {
|
||||||
|
return props.agentId
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载聊天会话列表
|
||||||
|
async function loadChatSessions(page = 1, isRefresh = false) {
|
||||||
|
try {
|
||||||
|
console.log('获取聊天会话列表', { page, isRefresh })
|
||||||
|
|
||||||
|
// 检查是否有当前选中的智能体
|
||||||
|
if (!currentAgentId.value) {
|
||||||
|
console.warn('没有选中的智能体')
|
||||||
|
sessionList.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page === 1) {
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
loadingMore.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getChatSessions(currentAgentId.value, {
|
||||||
|
page,
|
||||||
|
limit: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (page === 1) {
|
||||||
|
sessionList.value = response.list || []
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sessionList.value.push(...(response.list || []))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新分页信息
|
||||||
|
hasMore.value = (response.list?.length || 0) === pageSize
|
||||||
|
currentPage.value = page
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取聊天会话列表失败:', error)
|
||||||
|
if (page === 1) {
|
||||||
|
sessionList.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的刷新方法
|
||||||
|
async function refresh() {
|
||||||
|
currentPage.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
await loadChatSessions(1, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的加载更多方法
|
||||||
|
async function loadMore() {
|
||||||
|
if (!hasMore.value || loadingMore.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadChatSessions(currentPage.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
function formatTime(timeStr: string) {
|
||||||
|
if (!timeStr)
|
||||||
|
return '未知时间'
|
||||||
|
|
||||||
|
// 处理时间字符串,确保格式正确
|
||||||
|
const date = new Date(timeStr.replace(' ', 'T')) // 转换为ISO格式
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// 检查日期是否有效
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return timeStr // 如果解析失败,直接返回原字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
|
||||||
|
// 小于1分钟
|
||||||
|
if (diff < 60000)
|
||||||
|
return '刚刚'
|
||||||
|
|
||||||
|
// 小于1小时
|
||||||
|
if (diff < 3600000)
|
||||||
|
return `${Math.floor(diff / 60000)}分钟前`
|
||||||
|
|
||||||
|
// 小于1天(24小时)
|
||||||
|
if (diff < 86400000)
|
||||||
|
return `${Math.floor(diff / 3600000)}小时前`
|
||||||
|
|
||||||
|
// 小于7天
|
||||||
|
if (diff < 604800000) {
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
return `${days}天前`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过7天,显示具体日期
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
|
||||||
|
// 如果是当前年份,不显示年份
|
||||||
|
if (year === currentYear) {
|
||||||
|
return `${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入聊天详情
|
||||||
|
function goToChatDetail(session: ChatSession) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/chat-history/detail?sessionId=${session.sessionId}&agentId=${currentAgentId.value}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 智能体已简化为默认
|
||||||
|
|
||||||
|
loadChatSessions(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
refresh,
|
||||||
|
loadMore,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="chat-history-container" style="background: #f5f7fb; min-height: 100%;">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading && sessionList.length === 0" class="loading-container">
|
||||||
|
<wd-loading color="#336cff" />
|
||||||
|
<text class="loading-text">
|
||||||
|
加载中...
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 会话列表 -->
|
||||||
|
<view v-else-if="sessionList.length > 0" class="session-container">
|
||||||
|
<!-- 聊天会话列表 -->
|
||||||
|
<view class="session-list">
|
||||||
|
<view
|
||||||
|
v-for="session in sessionList"
|
||||||
|
:key="session.sessionId"
|
||||||
|
class="session-item"
|
||||||
|
@click="goToChatDetail(session)"
|
||||||
|
>
|
||||||
|
<view class="session-card">
|
||||||
|
<view class="session-info">
|
||||||
|
<view class="session-header">
|
||||||
|
<text class="session-title">
|
||||||
|
对话记录 {{ session.sessionId.substring(0, 8) }}...
|
||||||
|
</text>
|
||||||
|
<text class="session-time">
|
||||||
|
{{ formatTime(session.createdAt) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="session-meta">
|
||||||
|
<text class="chat-count">
|
||||||
|
共 {{ session.chatCount }} 条对话
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<wd-icon name="arrow-right" custom-class="arrow-icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载更多状态 -->
|
||||||
|
<view v-if="loadingMore" class="loading-more">
|
||||||
|
<wd-loading color="#336cff" size="24" />
|
||||||
|
<text class="loading-more-text">
|
||||||
|
加载中...
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 没有更多数据 -->
|
||||||
|
<view v-else-if="!hasMore && sessionList.length > 0" class="no-more">
|
||||||
|
<text class="no-more-text">
|
||||||
|
没有更多数据了
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else-if="!loading" class="empty-state">
|
||||||
|
<wd-icon name="chat" custom-class="empty-icon" />
|
||||||
|
<text class="empty-text">
|
||||||
|
暂无聊天记录
|
||||||
|
</text>
|
||||||
|
<text class="empty-desc">
|
||||||
|
与智能体的对话记录会显示在这里
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.chat-history-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
.loading-more-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx;
|
||||||
|
|
||||||
|
.no-more-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-section {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
background: #ffffff;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
background: #fbfbfb;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1rpx solid #eeeeee;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.session-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
|
||||||
|
.session-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #232338;
|
||||||
|
max-width: 70%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9d9ea3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
.chat-count {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #65686f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arrow-icon) {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #c7c7cc;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
:deep(.empty-icon) {
|
||||||
|
font-size: 120rpx;
|
||||||
|
color: #d9d9d9;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #666666;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999999;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useToast } from 'wot-design-uni'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
interface WiFiNetwork {
|
||||||
|
ssid: string
|
||||||
|
rssi: number
|
||||||
|
authmode: number
|
||||||
|
channel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
selectedNetwork: WiFiNetwork | null
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Toast 实例
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const generating = ref(false)
|
||||||
|
const playing = ref(false)
|
||||||
|
const audioGenerated = ref(false)
|
||||||
|
const autoLoop = ref(true)
|
||||||
|
const audioFilePath = ref('')
|
||||||
|
const audioContext = ref<any>(null)
|
||||||
|
|
||||||
|
// AFSK调制参数 - 参考HTML文件
|
||||||
|
const MARK = 1800 // 二进制1的频率 (Hz)
|
||||||
|
const SPACE = 1500 // 二进制0的频率 (Hz)
|
||||||
|
const SAMPLE_RATE = 44100 // 采样率
|
||||||
|
const BIT_RATE = 100 // 比特率 (bps)
|
||||||
|
const START_BYTES = [0x01, 0x02] // 起始标记
|
||||||
|
const END_BYTES = [0x03, 0x04] // 结束标记
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const canGenerate = computed(() => {
|
||||||
|
if (!props.selectedNetwork)
|
||||||
|
return false
|
||||||
|
if (props.selectedNetwork.authmode > 0 && !props.password)
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioLengthText = computed(() => {
|
||||||
|
if (!props.selectedNetwork)
|
||||||
|
return '0秒'
|
||||||
|
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
|
||||||
|
const textBytes = stringToBytes(dataStr)
|
||||||
|
const totalBits = (START_BYTES.length + textBytes.length + 1 + END_BYTES.length) * 8
|
||||||
|
const duration = Math.ceil(totalBits / BIT_RATE)
|
||||||
|
return `约${duration}秒`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 字符串转字节数组 - uniapp兼容版本
|
||||||
|
function stringToBytes(str: string): number[] {
|
||||||
|
const bytes: number[] = []
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i)
|
||||||
|
if (code < 0x80) {
|
||||||
|
bytes.push(code)
|
||||||
|
}
|
||||||
|
else if (code < 0x800) {
|
||||||
|
bytes.push(0xC0 | (code >> 6))
|
||||||
|
bytes.push(0x80 | (code & 0x3F))
|
||||||
|
}
|
||||||
|
else if (code < 0xD800 || code >= 0xE000) {
|
||||||
|
bytes.push(0xE0 | (code >> 12))
|
||||||
|
bytes.push(0x80 | ((code >> 6) & 0x3F))
|
||||||
|
bytes.push(0x80 | (code & 0x3F))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 代理对处理
|
||||||
|
i++
|
||||||
|
const hi = code
|
||||||
|
const lo = str.charCodeAt(i)
|
||||||
|
const codePoint = 0x10000 + (((hi & 0x3FF) << 10) | (lo & 0x3FF))
|
||||||
|
bytes.push(0xF0 | (codePoint >> 18))
|
||||||
|
bytes.push(0x80 | ((codePoint >> 12) & 0x3F))
|
||||||
|
bytes.push(0x80 | ((codePoint >> 6) & 0x3F))
|
||||||
|
bytes.push(0x80 | (codePoint & 0x3F))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验和计算 - 参考HTML文件
|
||||||
|
function checksum(data: number[]): number {
|
||||||
|
return data.reduce((sum, b) => (sum + b) & 0xFF, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字节转比特位 - 参考HTML文件
|
||||||
|
function toBits(byte: number): number[] {
|
||||||
|
const bits: number[] = []
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
bits.push((byte >> i) & 1)
|
||||||
|
}
|
||||||
|
return bits
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFSK调制 - 参考HTML文件算法
|
||||||
|
function afskModulate(bits: number[]): Float32Array {
|
||||||
|
const samplesPerBit = SAMPLE_RATE / BIT_RATE
|
||||||
|
const totalSamples = Math.floor(bits.length * samplesPerBit)
|
||||||
|
const buffer = new Float32Array(totalSamples)
|
||||||
|
|
||||||
|
for (let i = 0; i < bits.length; i++) {
|
||||||
|
const freq = bits[i] ? MARK : SPACE
|
||||||
|
for (let j = 0; j < samplesPerBit; j++) {
|
||||||
|
const t = (i * samplesPerBit + j) / SAMPLE_RATE
|
||||||
|
buffer[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浮点转16位PCM - 参考HTML文件
|
||||||
|
function floatTo16BitPCM(floatSamples: Float32Array): Uint8Array {
|
||||||
|
const buffer = new Uint8Array(floatSamples.length * 2)
|
||||||
|
for (let i = 0; i < floatSamples.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, floatSamples[i]))
|
||||||
|
const val = s < 0 ? s * 0x8000 : s * 0x7FFF
|
||||||
|
buffer[i * 2] = val & 0xFF
|
||||||
|
buffer[i * 2 + 1] = (val >> 8) & 0xFF
|
||||||
|
}
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64编码表
|
||||||
|
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||||
|
|
||||||
|
// 兼容的base64编码实现
|
||||||
|
function base64Encode(bytes: Uint8Array): string {
|
||||||
|
let result = ''
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < bytes.length) {
|
||||||
|
const a = bytes[i++]
|
||||||
|
const b = i < bytes.length ? bytes[i++] : 0
|
||||||
|
const c = i < bytes.length ? bytes[i++] : 0
|
||||||
|
|
||||||
|
const bitmap = (a << 16) | (b << 8) | c
|
||||||
|
|
||||||
|
result += base64Chars.charAt((bitmap >> 18) & 63)
|
||||||
|
result += base64Chars.charAt((bitmap >> 12) & 63)
|
||||||
|
result += i - 2 < bytes.length ? base64Chars.charAt((bitmap >> 6) & 63) : '='
|
||||||
|
result += i - 1 < bytes.length ? base64Chars.charAt(bitmap & 63) : '='
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数组转base64编码 - 兼容版本
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
|
||||||
|
// 尝试使用原生btoa,如果不存在则使用自定义实现
|
||||||
|
if (typeof btoa !== 'undefined') {
|
||||||
|
let binary = ''
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return base64Encode(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建WAV文件 - 返回ArrayBuffer而不是Blob
|
||||||
|
function buildWav(pcm: Uint8Array): ArrayBuffer {
|
||||||
|
const wavHeader = new Uint8Array(44)
|
||||||
|
const dataLen = pcm.length
|
||||||
|
const fileLen = 36 + dataLen
|
||||||
|
|
||||||
|
const writeStr = (offset: number, str: string) => {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
wavHeader[offset + i] = str.charCodeAt(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const write32 = (offset: number, value: number) => {
|
||||||
|
wavHeader[offset] = value & 0xFF
|
||||||
|
wavHeader[offset + 1] = (value >> 8) & 0xFF
|
||||||
|
wavHeader[offset + 2] = (value >> 16) & 0xFF
|
||||||
|
wavHeader[offset + 3] = (value >> 24) & 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
const write16 = (offset: number, value: number) => {
|
||||||
|
wavHeader[offset] = value & 0xFF
|
||||||
|
wavHeader[offset + 1] = (value >> 8) & 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
writeStr(0, 'RIFF')
|
||||||
|
write32(4, fileLen)
|
||||||
|
writeStr(8, 'WAVE')
|
||||||
|
writeStr(12, 'fmt ')
|
||||||
|
write32(16, 16)
|
||||||
|
write16(20, 1)
|
||||||
|
write16(22, 1)
|
||||||
|
write32(24, SAMPLE_RATE)
|
||||||
|
write32(28, SAMPLE_RATE * 2)
|
||||||
|
write16(32, 2)
|
||||||
|
write16(34, 16)
|
||||||
|
writeStr(36, 'data')
|
||||||
|
write32(40, dataLen)
|
||||||
|
|
||||||
|
// 合并header和数据
|
||||||
|
const result = new ArrayBuffer(44 + dataLen)
|
||||||
|
const resultView = new Uint8Array(result)
|
||||||
|
resultView.set(wavHeader)
|
||||||
|
resultView.set(pcm, 44)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成并播放声波 - 主要功能函数
|
||||||
|
async function generateAndPlay() {
|
||||||
|
if (!canGenerate.value || !props.selectedNetwork)
|
||||||
|
return
|
||||||
|
|
||||||
|
generating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('生成超声波配网音频...')
|
||||||
|
|
||||||
|
// 准备配网数据 - 参考HTML文件格式
|
||||||
|
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
|
||||||
|
const textBytes = stringToBytes(dataStr)
|
||||||
|
const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES]
|
||||||
|
|
||||||
|
console.log('配网数据:', { ssid: props.selectedNetwork.ssid, password: props.password })
|
||||||
|
console.log('数据字节长度:', textBytes.length)
|
||||||
|
|
||||||
|
// 转换为比特流
|
||||||
|
let bits: number[] = []
|
||||||
|
fullBytes.forEach((b) => {
|
||||||
|
bits = bits.concat(toBits(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('比特流长度:', bits.length)
|
||||||
|
|
||||||
|
// AFSK调制 - 减少采样率降低文件大小
|
||||||
|
const reducedSampleRate = 22050 // 降低采样率
|
||||||
|
const samplesPerBit = reducedSampleRate / BIT_RATE
|
||||||
|
const totalSamples = Math.floor(bits.length * samplesPerBit)
|
||||||
|
const floatBuf = new Float32Array(totalSamples)
|
||||||
|
|
||||||
|
for (let i = 0; i < bits.length; i++) {
|
||||||
|
const freq = bits[i] ? MARK : SPACE
|
||||||
|
for (let j = 0; j < samplesPerBit; j++) {
|
||||||
|
const t = (i * samplesPerBit + j) / reducedSampleRate
|
||||||
|
floatBuf[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t) * 0.5 // 降低音量
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pcmBuf = floatTo16BitPCM(floatBuf)
|
||||||
|
|
||||||
|
// 生成WAV文件 - 使用降低的采样率
|
||||||
|
const wavBuffer = buildWavOptimized(pcmBuf, reducedSampleRate)
|
||||||
|
const base64 = arrayBufferToBase64(wavBuffer)
|
||||||
|
const dataUri = `data:audio/wav;base64,${base64}`
|
||||||
|
|
||||||
|
console.log('base64长度:', base64.length, '约', Math.round(base64.length / 1024), 'KB')
|
||||||
|
|
||||||
|
// 检查数据大小
|
||||||
|
if (base64.length > 1024 * 1024) { // 超过1MB
|
||||||
|
throw new Error('音频文件过大,请缩短SSID或密码长度')
|
||||||
|
}
|
||||||
|
|
||||||
|
audioFilePath.value = dataUri
|
||||||
|
audioGenerated.value = true
|
||||||
|
|
||||||
|
console.log('音频生成成功,比特流长度:', bits.length, '采样点数:', floatBuf.length)
|
||||||
|
|
||||||
|
toast.success('声波生成成功')
|
||||||
|
|
||||||
|
// 延迟播放
|
||||||
|
setTimeout(async () => {
|
||||||
|
await playAudio()
|
||||||
|
}, 800) // 增加延迟时间
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('音频生成失败:', error)
|
||||||
|
toast.error(`声波生成失败: ${error.message || error}`)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化的WAV构建函数
|
||||||
|
function buildWavOptimized(pcm: Uint8Array, sampleRate: number): ArrayBuffer {
|
||||||
|
const wavHeader = new Uint8Array(44)
|
||||||
|
const dataLen = pcm.length
|
||||||
|
const fileLen = 36 + dataLen
|
||||||
|
|
||||||
|
const writeStr = (offset: number, str: string) => {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
wavHeader[offset + i] = str.charCodeAt(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const write32 = (offset: number, value: number) => {
|
||||||
|
wavHeader[offset] = value & 0xFF
|
||||||
|
wavHeader[offset + 1] = (value >> 8) & 0xFF
|
||||||
|
wavHeader[offset + 2] = (value >> 16) & 0xFF
|
||||||
|
wavHeader[offset + 3] = (value >> 24) & 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
const write16 = (offset: number, value: number) => {
|
||||||
|
wavHeader[offset] = value & 0xFF
|
||||||
|
wavHeader[offset + 1] = (value >> 8) & 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
writeStr(0, 'RIFF')
|
||||||
|
write32(4, fileLen)
|
||||||
|
writeStr(8, 'WAVE')
|
||||||
|
writeStr(12, 'fmt ')
|
||||||
|
write32(16, 16)
|
||||||
|
write16(20, 1)
|
||||||
|
write16(22, 1)
|
||||||
|
write32(24, sampleRate) // 使用传入的采样率
|
||||||
|
write32(28, sampleRate * 2)
|
||||||
|
write16(32, 2)
|
||||||
|
write16(34, 16)
|
||||||
|
writeStr(36, 'data')
|
||||||
|
write32(40, dataLen)
|
||||||
|
|
||||||
|
// 合并header和数据
|
||||||
|
const result = new ArrayBuffer(44 + dataLen)
|
||||||
|
const resultView = new Uint8Array(result)
|
||||||
|
resultView.set(wavHeader)
|
||||||
|
resultView.set(pcm, 44)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放音频
|
||||||
|
async function playAudio() {
|
||||||
|
if (!audioFilePath.value) {
|
||||||
|
toast.error('请先生成音频')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 强制清理所有旧的音频实例
|
||||||
|
await cleanupAudio()
|
||||||
|
|
||||||
|
// 等待一下确保清理完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
|
||||||
|
playing.value = true
|
||||||
|
console.log('开始播放超声波配网音频')
|
||||||
|
|
||||||
|
// 创建新的音频上下文
|
||||||
|
const innerAudioContext = uni.createInnerAudioContext()
|
||||||
|
audioContext.value = innerAudioContext
|
||||||
|
|
||||||
|
// 最简化的音频设置
|
||||||
|
innerAudioContext.src = audioFilePath.value
|
||||||
|
innerAudioContext.loop = autoLoop.value
|
||||||
|
innerAudioContext.volume = 0.8
|
||||||
|
innerAudioContext.autoplay = false
|
||||||
|
|
||||||
|
// 简化的事件监听
|
||||||
|
innerAudioContext.onPlay(() => {
|
||||||
|
console.log('超声波音频开始播放')
|
||||||
|
toast.success('开始播放配网声波')
|
||||||
|
})
|
||||||
|
|
||||||
|
innerAudioContext.onEnded(() => {
|
||||||
|
console.log('超声波音频播放结束')
|
||||||
|
if (!autoLoop.value) {
|
||||||
|
playing.value = false
|
||||||
|
cleanupAudio()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
innerAudioContext.onError((error) => {
|
||||||
|
console.error('音频播放失败:', error)
|
||||||
|
playing.value = false
|
||||||
|
|
||||||
|
let errorMsg = '音频播放失败'
|
||||||
|
if (error.errCode === -99) {
|
||||||
|
errorMsg = '音频资源繁忙,请稍后重试'
|
||||||
|
}
|
||||||
|
else if (error.errCode === 10004) {
|
||||||
|
errorMsg = '音频格式不支持,可能是data URI问题'
|
||||||
|
}
|
||||||
|
else if (error.errCode === 10003) {
|
||||||
|
errorMsg = '音频文件错误'
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(errorMsg)
|
||||||
|
|
||||||
|
cleanupAudio()
|
||||||
|
})
|
||||||
|
|
||||||
|
innerAudioContext.onStop(() => {
|
||||||
|
console.log('音频播放停止')
|
||||||
|
playing.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 延迟播放
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioContext.value) {
|
||||||
|
console.log('尝试播放音频,src长度:', audioFilePath.value.length)
|
||||||
|
audioContext.value.play()
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('播放音频异常:', error)
|
||||||
|
playing.value = false
|
||||||
|
await cleanupAudio()
|
||||||
|
toast.error(`播放失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理音频资源
|
||||||
|
async function cleanupAudio() {
|
||||||
|
if (audioContext.value) {
|
||||||
|
try {
|
||||||
|
audioContext.value.pause()
|
||||||
|
audioContext.value.destroy()
|
||||||
|
console.log('清理音频上下文')
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.log('清理音频上下文失败:', e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
audioContext.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止播放
|
||||||
|
async function stopAudio() {
|
||||||
|
playing.value = false
|
||||||
|
await cleanupAudio()
|
||||||
|
|
||||||
|
console.log('停止播放超声波音频')
|
||||||
|
toast.success('已停止播放')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="ultrasonic-config">
|
||||||
|
<!-- 选中的网络信息 -->
|
||||||
|
<view v-if="props.selectedNetwork" class="selected-network">
|
||||||
|
<view class="network-info">
|
||||||
|
<view class="network-name">
|
||||||
|
选中网络: {{ props.selectedNetwork.ssid }}
|
||||||
|
</view>
|
||||||
|
<view class="network-details">
|
||||||
|
<text class="network-signal">
|
||||||
|
信号: {{ props.selectedNetwork.rssi }}dBm
|
||||||
|
</text>
|
||||||
|
<text class="network-security">
|
||||||
|
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="props.password" class="network-password">
|
||||||
|
密码: {{ '*'.repeat(props.password.length) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 超声波配网操作 -->
|
||||||
|
<view class="submit-section">
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="generating"
|
||||||
|
:disabled="!canGenerate"
|
||||||
|
@click="generateAndPlay"
|
||||||
|
>
|
||||||
|
{{ generating ? '生成中...' : '🎵 生成并播放声波' }}
|
||||||
|
</wd-button>
|
||||||
|
|
||||||
|
<wd-button
|
||||||
|
v-if="audioGenerated"
|
||||||
|
type="success"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="playing"
|
||||||
|
@click="playAudio"
|
||||||
|
>
|
||||||
|
{{ playing ? '播放中...' : '🔊 播放声波' }}
|
||||||
|
</wd-button>
|
||||||
|
|
||||||
|
<wd-button
|
||||||
|
v-if="playing"
|
||||||
|
type="warning"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
@click="stopAudio"
|
||||||
|
>
|
||||||
|
⏹️ 停止播放
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 音频控制选项 -->
|
||||||
|
<view v-if="audioGenerated" class="audio-options">
|
||||||
|
<view class="option-item">
|
||||||
|
<wd-checkbox v-model="autoLoop">
|
||||||
|
自动循环播放声波
|
||||||
|
</wd-checkbox>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 音频播放器 -->
|
||||||
|
<view v-if="audioGenerated" class="audio-player">
|
||||||
|
<view class="player-info">
|
||||||
|
<text class="audio-title">
|
||||||
|
配网音频文件
|
||||||
|
</text>
|
||||||
|
<text class="audio-duration">
|
||||||
|
时长: {{ audioLengthText }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 使用说明 -->
|
||||||
|
<view class="help-section">
|
||||||
|
<view class="help-title">
|
||||||
|
超声波配网说明
|
||||||
|
</view>
|
||||||
|
<view class="help-content">
|
||||||
|
<text class="help-item">
|
||||||
|
1. 确保已选择WiFi网络并输入密码
|
||||||
|
</text>
|
||||||
|
<text class="help-item">
|
||||||
|
2. 点击生成并播放声波,系统会将配网信息编码为音频
|
||||||
|
</text>
|
||||||
|
<text class="help-item">
|
||||||
|
3. 将手机靠近xiaozhi设备(距离1-2米)
|
||||||
|
</text>
|
||||||
|
<text class="help-item">
|
||||||
|
4. 音频播放时,xiaozhi会接收并解码配网信息
|
||||||
|
</text>
|
||||||
|
<text class="help-item">
|
||||||
|
5. 配网成功后设备会自动连接WiFi网络
|
||||||
|
</text>
|
||||||
|
<text class="help-tip">
|
||||||
|
使用AFSK调制技术,通过1800Hz和1500Hz频率传输数据
|
||||||
|
</text>
|
||||||
|
<text class="help-tip">
|
||||||
|
请确保手机音量适中,避免环境噪音干扰
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ultrasonic-config {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-network {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-info {
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #f0f6ff;
|
||||||
|
border: 1rpx solid #336cff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #232338;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-signal,
|
||||||
|
.network-security {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #65686f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-password {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #65686f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-section {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-section .wd-button {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-section .wd-button:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-options {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #fbfbfb;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 1rpx solid #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #f0f6ff;
|
||||||
|
border: 1rpx solid #336cff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #232338;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-duration {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #65686f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
background-color: #fbfbfb;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 1rpx solid #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #232338;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-item {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #65686f;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tip {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #336cff;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useToast } from 'wot-design-uni'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
interface WiFiNetwork {
|
||||||
|
ssid: string
|
||||||
|
rssi: number
|
||||||
|
authmode: number
|
||||||
|
channel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
selectedNetwork: WiFiNetwork | null
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Toast 实例
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const configuring = ref(false)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
if (!props.selectedNetwork)
|
||||||
|
return false
|
||||||
|
if (props.selectedNetwork.authmode > 0 && !props.password)
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// ESP32连接检查
|
||||||
|
async function checkESP32Connection() {
|
||||||
|
try {
|
||||||
|
const response = await uni.request({
|
||||||
|
url: 'http://192.168.4.1/scan',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 3000,
|
||||||
|
})
|
||||||
|
return response.statusCode === 200
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.log('ESP32连接检查失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交配网
|
||||||
|
async function submitConfig() {
|
||||||
|
if (!props.selectedNetwork)
|
||||||
|
return
|
||||||
|
|
||||||
|
// 检查ESP32连接
|
||||||
|
const connected = await checkESP32Connection()
|
||||||
|
if (!connected) {
|
||||||
|
toast.error('请先连接xiaozhi热点')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configuring.value = true
|
||||||
|
console.log('开始WiFi配网:', props.selectedNetwork.ssid)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uni.request({
|
||||||
|
url: 'http://192.168.4.1/submit',
|
||||||
|
method: 'POST',
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
ssid: props.selectedNetwork.ssid,
|
||||||
|
password: props.selectedNetwork.authmode > 0 ? props.password : '',
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('WiFi配网响应:', response)
|
||||||
|
|
||||||
|
if (response.statusCode === 200 && (response.data as any)?.success) {
|
||||||
|
toast.success(`配网成功!设备将连接到 ${props.selectedNetwork.ssid},设备会自动重启。请断开xiaozhi热点连接。`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const errorMsg = (response.data as any)?.error || '配网失败'
|
||||||
|
toast.error(errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('WiFi配网失败:', error)
|
||||||
|
toast.error('配网失败,请检查网络连接')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
configuring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="wifi-config">
|
||||||
|
<!-- 选中的网络信息 -->
|
||||||
|
<view v-if="props.selectedNetwork" class="selected-network">
|
||||||
|
<view class="network-info">
|
||||||
|
<view class="network-name">
|
||||||
|
选中网络: {{ props.selectedNetwork.ssid }}
|
||||||
|
</view>
|
||||||
|
<view class="network-details">
|
||||||
|
<text class="network-signal">
|
||||||
|
信号: {{ props.selectedNetwork.rssi }}dBm
|
||||||
|
</text>
|
||||||
|
<text class="network-security">
|
||||||
|
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 配网按钮 -->
|
||||||
|
<view class="submit-section">
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="configuring"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="submitConfig"
|
||||||
|
>
|
||||||
|
{{ configuring ? '配网中...' : '开始WiFi配网' }}
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 使用说明 -->
|
||||||
|
<view class="help-section">
|
||||||
|
<view class="help-title">
|
||||||
|
WiFi配网说明
|
||||||
|
</view>
|
||||||
|
<view class="help-content">
|
||||||
|
<text class="help-item">
|
||||||
|
1. 手机连接xiaozhi热点 (xiaozhi-XXXXXX)
|
||||||
|
</text>
|
||||||
|
<text class="help-item">
|
||||||
|
2. 选择要配网的目标WiFi网络
|
||||||
|
</text>
|
||||||
|
<text class="help-item">
|
||||||
|
3. 输入WiFi密码(如果需要)
|
||||||
|
</text>
|
||||||
|
<text class="help-item">
|
||||||
|
4. 点击开始配网,等待设备连接
|
||||||
|
</text>
|
||||||
|
<text class="help-tip">
|
||||||
|
配网成功后设备会自动重启并连接目标WiFi
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wifi-config {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-network {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-info {
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #f0f6ff;
|
||||||
|
border: 1rpx solid #336cff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #232338;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-signal,
|
||||||
|
.network-security {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #65686f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-section {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
background-color: #fbfbfb;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 1rpx solid #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #232338;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-item {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #65686f;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tip {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #336cff;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineEmits, defineExpose, onMounted, ref } from 'vue'
|
||||||
|
import { useToast } from 'wot-design-uni'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
interface WiFiNetwork {
|
||||||
|
ssid: string
|
||||||
|
rssi: number
|
||||||
|
authmode: number
|
||||||
|
channel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
autoConnect?: boolean // 是否自动检测ESP32连接
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
autoConnect: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'network-selected': [network: WiFiNetwork | null, password: string]
|
||||||
|
'connection-status': [connected: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Toast 实例
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const isConnectedToESP32 = ref(false)
|
||||||
|
const checkingConnection = ref(false)
|
||||||
|
const scanning = ref(false)
|
||||||
|
const wifiNetworks = ref<WiFiNetwork[]>([])
|
||||||
|
const selectedNetwork = ref<WiFiNetwork | null>(null)
|
||||||
|
const password = ref('')
|
||||||
|
const selectorExpanded = ref(false)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const networkDisplayText = computed(() => {
|
||||||
|
if (!selectedNetwork.value)
|
||||||
|
return '请选择WiFi网络'
|
||||||
|
return selectedNetwork.value.ssid
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查xiaozhi连接状态
|
||||||
|
async function checkESP32Connection() {
|
||||||
|
checkingConnection.value = true
|
||||||
|
try {
|
||||||
|
const response = await uni.request({
|
||||||
|
url: 'http://192.168.4.1/scan',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 3000,
|
||||||
|
})
|
||||||
|
isConnectedToESP32.value = response.statusCode === 200
|
||||||
|
emit('connection-status', isConnectedToESP32.value)
|
||||||
|
console.log('xiaozhi连接状态:', isConnectedToESP32.value)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
isConnectedToESP32.value = false
|
||||||
|
emit('connection-status', false)
|
||||||
|
console.log('xiaozhi连接检查失败:', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
checkingConnection.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描WiFi网络
|
||||||
|
async function scanWifi() {
|
||||||
|
if (!isConnectedToESP32.value) {
|
||||||
|
toast.error('请先连接xiaozhi热点')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scanning.value = true
|
||||||
|
console.log('开始扫描WiFi网络')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uni.request({
|
||||||
|
url: 'http://192.168.4.1/scan',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('WiFi扫描响应:', response)
|
||||||
|
|
||||||
|
if (response.statusCode === 200 && response.data) {
|
||||||
|
const data = response.data as any
|
||||||
|
if (data.success && Array.isArray(data.networks)) {
|
||||||
|
wifiNetworks.value = data.networks
|
||||||
|
console.log(`扫描成功,发现 ${data.networks.length} 个网络`)
|
||||||
|
}
|
||||||
|
else if (Array.isArray(response.data)) {
|
||||||
|
// 兼容旧格式
|
||||||
|
wifiNetworks.value = response.data.map((item: any) => ({
|
||||||
|
ssid: item.ssid,
|
||||||
|
rssi: item.rssi,
|
||||||
|
authmode: item.authmode,
|
||||||
|
channel: item.channel || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new TypeError('扫描接口返回格式异常')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`HTTP ${response.statusCode}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('WiFi扫描失败:', error)
|
||||||
|
toast.error('扫描失败,请检查xiaozhi连接')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
scanning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示网络选择器
|
||||||
|
async function showNetworkSelector() {
|
||||||
|
// 实时检测xiaozhi连接状态
|
||||||
|
await checkESP32Connection()
|
||||||
|
|
||||||
|
if (!isConnectedToESP32.value) {
|
||||||
|
toast.error('请先连接xiaozhi热点')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectorExpanded.value = true
|
||||||
|
|
||||||
|
// 如果还没有网络列表,自动扫描
|
||||||
|
if (wifiNetworks.value.length === 0) {
|
||||||
|
scanWifi()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择网络
|
||||||
|
function selectNetwork(network: WiFiNetwork) {
|
||||||
|
selectedNetwork.value = network
|
||||||
|
password.value = ''
|
||||||
|
selectorExpanded.value = false
|
||||||
|
console.log('选择网络:', network.ssid)
|
||||||
|
|
||||||
|
// 通知父组件
|
||||||
|
emit('network-selected', network, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码变化时通知父组件
|
||||||
|
function onPasswordChange() {
|
||||||
|
emit('network-selected', selectedNetwork.value, password.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前选择的网络和密码
|
||||||
|
function getSelectedNetworkInfo() {
|
||||||
|
return {
|
||||||
|
network: selectedNetwork.value,
|
||||||
|
password: password.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置选择
|
||||||
|
function reset() {
|
||||||
|
selectedNetwork.value = null
|
||||||
|
password.value = ''
|
||||||
|
wifiNetworks.value = []
|
||||||
|
selectorExpanded.value = false
|
||||||
|
emit('network-selected', null, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取信号强度描述
|
||||||
|
function getSignalStrength(rssi: number): string {
|
||||||
|
if (rssi >= -50)
|
||||||
|
return '信号强'
|
||||||
|
if (rssi >= -60)
|
||||||
|
return '信号良好'
|
||||||
|
if (rssi >= -70)
|
||||||
|
return '信号一般'
|
||||||
|
return '信号弱'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取信号强度颜色
|
||||||
|
function getSignalColor(rssi: number): string {
|
||||||
|
if (rssi >= -50)
|
||||||
|
return '#52c41a'
|
||||||
|
if (rssi >= -60)
|
||||||
|
return '#73d13d'
|
||||||
|
if (rssi >= -70)
|
||||||
|
return '#faad14'
|
||||||
|
return '#ff4d4f'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
checkESP32Connection,
|
||||||
|
scanWifi,
|
||||||
|
getSelectedNetworkInfo,
|
||||||
|
reset,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.autoConnect) {
|
||||||
|
checkESP32Connection()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="wifi-selector">
|
||||||
|
<!-- Xiaozhi连接状态 -->
|
||||||
|
<view v-if="props.autoConnect" class="connection-status">
|
||||||
|
<view v-if="!isConnectedToESP32" class="status-warning">
|
||||||
|
<view class="status-content">
|
||||||
|
<text class="warning-text">
|
||||||
|
请先连接xiaozhi热点 (xiaozhi-XXXXXX)
|
||||||
|
</text>
|
||||||
|
<wd-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="checkingConnection"
|
||||||
|
@click="checkESP32Connection"
|
||||||
|
>
|
||||||
|
{{ checkingConnection ? '检测中...' : '重新检测' }}
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="status-success">
|
||||||
|
<view class="status-content">
|
||||||
|
<text class="success-text">
|
||||||
|
已连接xiaozhi热点
|
||||||
|
</text>
|
||||||
|
<wd-button
|
||||||
|
size="small"
|
||||||
|
:loading="checkingConnection"
|
||||||
|
@click="checkESP32Connection"
|
||||||
|
>
|
||||||
|
{{ checkingConnection ? '检测中...' : '刷新状态' }}
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- WiFi网络选择器 -->
|
||||||
|
<view class="network-selector">
|
||||||
|
<view class="selector-item" @click="showNetworkSelector">
|
||||||
|
<text class="selector-label">
|
||||||
|
WiFi网络
|
||||||
|
</text>
|
||||||
|
<text class="selector-value">
|
||||||
|
{{ networkDisplayText }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="arrow-icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 展开的网络列表 -->
|
||||||
|
<view v-if="selectorExpanded" class="network-list-overlay">
|
||||||
|
<view class="network-list-container">
|
||||||
|
<view class="list-header">
|
||||||
|
<text class="list-title">
|
||||||
|
选择WiFi网络
|
||||||
|
</text>
|
||||||
|
<view class="list-actions">
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="scanning"
|
||||||
|
@click="scanWifi"
|
||||||
|
>
|
||||||
|
{{ scanning ? '扫描中...' : '刷新扫描' }}
|
||||||
|
</wd-button>
|
||||||
|
<wd-button
|
||||||
|
size="small"
|
||||||
|
@click="selectorExpanded = false"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="network-list">
|
||||||
|
<view v-if="wifiNetworks.length === 0 && !scanning" class="empty-state">
|
||||||
|
<text class="empty-text">
|
||||||
|
暂无WiFi网络
|
||||||
|
</text>
|
||||||
|
<text class="empty-tip">
|
||||||
|
请点击刷新扫描
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="wifi-list">
|
||||||
|
<view
|
||||||
|
v-for="network in wifiNetworks"
|
||||||
|
:key="network.ssid"
|
||||||
|
class="wifi-item"
|
||||||
|
@click="selectNetwork(network)"
|
||||||
|
>
|
||||||
|
<view class="wifi-info">
|
||||||
|
<view class="wifi-name">
|
||||||
|
{{ network.ssid }}
|
||||||
|
</view>
|
||||||
|
<view class="wifi-details">
|
||||||
|
<text class="wifi-signal">
|
||||||
|
信号: {{ network.rssi }}dBm
|
||||||
|
</text>
|
||||||
|
<text class="wifi-channel">
|
||||||
|
频道: {{ network.channel }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="wifi-security">
|
||||||
|
<text class="security-icon">
|
||||||
|
{{ network.authmode === 0 ? '开放' : '加密' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 密码输入 -->
|
||||||
|
<view v-if="selectedNetwork && selectedNetwork.authmode > 0" class="password-section">
|
||||||
|
<view class="password-item">
|
||||||
|
<text class="password-label">
|
||||||
|
网络密码
|
||||||
|
</text>
|
||||||
|
<wd-input
|
||||||
|
v-model="password"
|
||||||
|
placeholder="请输入WiFi密码"
|
||||||
|
show-password
|
||||||
|
clearable
|
||||||
|
@input="onPasswordChange"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wifi-selector {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1rpx solid #ffeaa7;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
padding: 24rpx;
|
||||||
|
background-color: #d4edda;
|
||||||
|
border: 1rpx solid #c3e6cb;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
color: #856404;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: #155724;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-selector {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx;
|
||||||
|
background: #f5f7fb;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
border: 1rpx solid #eeeeee;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-item:active {
|
||||||
|
background: #eef3ff;
|
||||||
|
border-color: #336cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #232338;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-value {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #65686f;
|
||||||
|
margin: 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arrow-icon) {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #9d9ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-list-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-list-container {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 20rpx 20rpx 0 0;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
border-bottom: 1rpx solid #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #232338;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-list {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 80rpx 20rpx;
|
||||||
|
background-color: #fbfbfb;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 1rpx solid #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #65686f;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9d9ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
background-color: #fbfbfb;
|
||||||
|
border: 2rpx solid #eeeeee;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-item:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
background-color: #f0f6ff;
|
||||||
|
border-color: #336cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #232338;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-signal,
|
||||||
|
.wifi-channel {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #65686f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-icon {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #65686f;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-section {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #232338;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import UltrasonicConfig from './components/ultrasonic-config.vue'
|
||||||
|
import WifiConfig from './components/wifi-config.vue'
|
||||||
|
import WifiSelector from './components/wifi-selector.vue'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
interface WiFiNetwork {
|
||||||
|
ssid: string
|
||||||
|
rssi: number
|
||||||
|
authmode: number
|
||||||
|
channel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配网类型
|
||||||
|
const configType = ref<'wifi' | 'ultrasonic'>('wifi')
|
||||||
|
|
||||||
|
// 配网模式选择器状态
|
||||||
|
const configTypeSelectorShow = ref(false)
|
||||||
|
|
||||||
|
// WiFi选择器引用
|
||||||
|
const wifiSelectorRef = ref<InstanceType<typeof WifiSelector>>()
|
||||||
|
|
||||||
|
// 选择的WiFi网络信息
|
||||||
|
const selectedWifiInfo = ref<{
|
||||||
|
network: WiFiNetwork | null
|
||||||
|
password: string
|
||||||
|
}>({
|
||||||
|
network: null,
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 配网模式选项
|
||||||
|
const configTypeOptions = [
|
||||||
|
{
|
||||||
|
name: 'WiFi配网',
|
||||||
|
value: 'wifi' as const,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// name: '超声波配网',
|
||||||
|
// value: 'ultrasonic' as const,
|
||||||
|
// },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 显示配网模式选择器
|
||||||
|
function showConfigTypeSelector() {
|
||||||
|
configTypeSelectorShow.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配网模式选择器确认
|
||||||
|
function onConfigTypeConfirm(item: { name: string, value: 'wifi' | 'ultrasonic' }) {
|
||||||
|
configType.value = item.value
|
||||||
|
configTypeSelectorShow.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配网模式选择器取消
|
||||||
|
function onConfigTypeCancel() {
|
||||||
|
configTypeSelectorShow.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFi网络选择事件
|
||||||
|
function onNetworkSelected(network: WiFiNetwork | null, password: string) {
|
||||||
|
selectedWifiInfo.value = { network, password }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESP32连接状态变化事件
|
||||||
|
function onConnectionStatusChange(connected: boolean) {
|
||||||
|
console.log('ESP32连接状态:', connected)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-[#f5f7fb]">
|
||||||
|
<wd-navbar title="设备配网" safe-area-inset-top />
|
||||||
|
|
||||||
|
<view class="box-border px-[20rpx]">
|
||||||
|
<!-- 配网方式选择 -->
|
||||||
|
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
配网方式
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||||
|
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:border-[#336cff] active:bg-[#eef3ff]" @click="showConfigTypeSelector">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
配网方式
|
||||||
|
</text>
|
||||||
|
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||||
|
{{ configType === 'wifi' ? 'WiFi配网' : '超声波配网' }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- WiFi网络选择 -->
|
||||||
|
<view class="pb-[20rpx]">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
网络配置
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||||
|
<wifi-selector
|
||||||
|
ref="wifiSelectorRef"
|
||||||
|
@network-selected="onNetworkSelected"
|
||||||
|
@connection-status="onConnectionStatusChange"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 配网操作 -->
|
||||||
|
<view v-if="selectedWifiInfo.network" class="flex-1">
|
||||||
|
<!-- WiFi配网组件 -->
|
||||||
|
<wifi-config
|
||||||
|
v-if="configType === 'wifi'"
|
||||||
|
:selected-network="selectedWifiInfo.network"
|
||||||
|
:password="selectedWifiInfo.password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 超声波配网组件 -->
|
||||||
|
<ultrasonic-config
|
||||||
|
v-else-if="configType === 'ultrasonic'"
|
||||||
|
:selected-network="selectedWifiInfo.network"
|
||||||
|
:password="selectedWifiInfo.password"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 配网模式选择器 -->
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="configTypeSelectorShow"
|
||||||
|
:actions="configTypeOptions.map(item => ({ name: item.name, value: item.value }))"
|
||||||
|
@close="onConfigTypeCancel"
|
||||||
|
@select="({ item }) => onConfigTypeConfirm(item)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<route lang="jsonc" type="page">
|
||||||
|
{
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "设备配网",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</route>
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Device, FirmwareType } from '@/api/device'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useMessage } from 'wot-design-uni'
|
||||||
|
import { bindDevice, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
|
||||||
|
import { toast } from '@/utils/toast'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'DeviceManage',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 接收props
|
||||||
|
interface Props {
|
||||||
|
agentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
agentId: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取屏幕边界到安全区域距离
|
||||||
|
let safeAreaInsets: any
|
||||||
|
let systemInfo: any
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
systemInfo = uni.getWindowInfo()
|
||||||
|
safeAreaInsets = systemInfo.safeArea
|
||||||
|
? {
|
||||||
|
top: systemInfo.safeArea.top,
|
||||||
|
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||||
|
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||||
|
left: systemInfo.safeArea.left,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
systemInfo = uni.getSystemInfoSync()
|
||||||
|
safeAreaInsets = systemInfo.safeAreaInsets
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 设备数据
|
||||||
|
const deviceList = ref<Device[]>([])
|
||||||
|
const firmwareTypes = ref<FirmwareType[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 消息组件
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
// 使用传入的智能体ID
|
||||||
|
const currentAgentId = computed(() => {
|
||||||
|
return props.agentId
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
async function loadDeviceList() {
|
||||||
|
try {
|
||||||
|
console.log('获取设备列表')
|
||||||
|
|
||||||
|
// 检查是否有当前选中的智能体
|
||||||
|
if (!currentAgentId.value) {
|
||||||
|
console.warn('没有选中的智能体')
|
||||||
|
deviceList.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
const response = await getBindDevices(currentAgentId.value)
|
||||||
|
deviceList.value = response || []
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取设备列表失败:', error)
|
||||||
|
deviceList.value = []
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的刷新方法
|
||||||
|
async function refresh() {
|
||||||
|
await loadDeviceList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备类型名称
|
||||||
|
function getDeviceTypeName(boardKey: string): string {
|
||||||
|
const firmwareType = firmwareTypes.value.find(type => type.key === boardKey)
|
||||||
|
return firmwareType?.name || boardKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
function formatTime(timeStr: string) {
|
||||||
|
if (!timeStr)
|
||||||
|
return '从未连接'
|
||||||
|
const date = new Date(timeStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
|
||||||
|
if (diff < 60000)
|
||||||
|
return '刚刚'
|
||||||
|
if (diff < 3600000)
|
||||||
|
return `${Math.floor(diff / 60000)}分钟前`
|
||||||
|
if (diff < 86400000)
|
||||||
|
return `${Math.floor(diff / 3600000)}小时前`
|
||||||
|
if (diff < 604800000)
|
||||||
|
return `${Math.floor(diff / 86400000)}天前`
|
||||||
|
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换OTA自动更新
|
||||||
|
async function toggleAutoUpdate(device: Device) {
|
||||||
|
try {
|
||||||
|
const newStatus = device.autoUpdate === 1 ? 0 : 1
|
||||||
|
await updateDeviceAutoUpdate(device.id, newStatus)
|
||||||
|
device.autoUpdate = newStatus
|
||||||
|
toast.success(newStatus === 1 ? 'OTA自动升级已开启' : 'OTA自动升级已关闭')
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error('更新设备OTA状态失败:', error)
|
||||||
|
toast.error('操作失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解绑设备
|
||||||
|
async function handleUnbindDevice(device: Device) {
|
||||||
|
try {
|
||||||
|
await unbindDevice(device.id)
|
||||||
|
await loadDeviceList()
|
||||||
|
toast.success('设备已解绑')
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error('解绑设备失败:', error)
|
||||||
|
toast.error('解绑失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认解绑设备
|
||||||
|
function confirmUnbindDevice(device: Device) {
|
||||||
|
message.confirm({
|
||||||
|
title: '解绑设备',
|
||||||
|
msg: `确定要解绑设备 "${device.macAddress}" 吗?`,
|
||||||
|
confirmButtonText: '确定解绑',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
}).then(() => {
|
||||||
|
handleUnbindDevice(device)
|
||||||
|
}).catch(() => {
|
||||||
|
// 用户取消
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定新设备
|
||||||
|
async function handleBindDevice(code: string) {
|
||||||
|
try {
|
||||||
|
if (!currentAgentId.value) {
|
||||||
|
toast.error('请先选择智能体')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await bindDevice(currentAgentId.value, code.trim())
|
||||||
|
await loadDeviceList()
|
||||||
|
toast.success('设备绑定成功!')
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error('绑定设备失败:', error)
|
||||||
|
const errorMessage = error?.message || '绑定失败,请检查验证码是否正确'
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开绑定设备对话框
|
||||||
|
function openBindDialog() {
|
||||||
|
message
|
||||||
|
.prompt({
|
||||||
|
title: '绑定设备',
|
||||||
|
inputPlaceholder: '请输入设备验证码',
|
||||||
|
inputValue: '',
|
||||||
|
inputPattern: /^\d{6}$/,
|
||||||
|
confirmButtonText: '立即绑定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
.then(async (result: any) => {
|
||||||
|
if (result.value && String(result.value).trim()) {
|
||||||
|
await handleBindDevice(String(result.value).trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 用户取消操作
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备类型列表
|
||||||
|
async function loadFirmwareTypes() {
|
||||||
|
try {
|
||||||
|
const response = await getFirmwareTypes()
|
||||||
|
firmwareTypes.value = response
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取设备类型失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 智能体已简化为默认
|
||||||
|
|
||||||
|
loadFirmwareTypes()
|
||||||
|
loadDeviceList()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
refresh,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="device-container" style="background: #f5f7fb; min-height: 100%;">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading && deviceList.length === 0" class="loading-container">
|
||||||
|
<wd-loading color="#336cff" />
|
||||||
|
<text class="loading-text">
|
||||||
|
加载中...
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 设备列表 -->
|
||||||
|
<view v-else-if="deviceList.length > 0" class="device-list">
|
||||||
|
<!-- 设备卡片列表 -->
|
||||||
|
<view class="box-border flex flex-col gap-[24rpx] p-[20rpx]">
|
||||||
|
<view v-for="device in deviceList" :key="device.id">
|
||||||
|
<wd-swipe-action>
|
||||||
|
<view class="cursor-pointer bg-[#fbfbfb] p-[32rpx] transition-all duration-200 active:bg-[#f8f9fa]">
|
||||||
|
<view class="flex items-start justify-between">
|
||||||
|
<view class="flex-1">
|
||||||
|
<view class="mb-[16rpx] flex items-center justify-between">
|
||||||
|
<text class="max-w-[60%] break-all text-[32rpx] text-[#232338] font-semibold">
|
||||||
|
{{ getDeviceTypeName(device.board) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-[20rpx]">
|
||||||
|
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||||
|
MAC地址:{{ device.macAddress }}
|
||||||
|
</text>
|
||||||
|
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||||
|
固件版本:{{ device.appVersion }}
|
||||||
|
</text>
|
||||||
|
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||||
|
最近对话:{{ formatTime(device.lastConnectedAt) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx]">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
OTA升级
|
||||||
|
</text>
|
||||||
|
<wd-switch
|
||||||
|
:model-value="device.autoUpdate === 1"
|
||||||
|
size="24"
|
||||||
|
@change="toggleAutoUpdate(device)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<view class="h-full flex">
|
||||||
|
<view
|
||||||
|
class="h-full min-w-[120rpx] flex items-center justify-center bg-[#ff4d4f] p-x-[32rpx] text-[28rpx] text-white font-medium"
|
||||||
|
@click.stop="confirmUnbindDevice(device)"
|
||||||
|
>
|
||||||
|
<wd-icon name="delete" />
|
||||||
|
<text>解绑</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</wd-swipe-action>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else-if="!loading" class="empty-container">
|
||||||
|
<view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center">
|
||||||
|
<wd-icon name="phone" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" />
|
||||||
|
<text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium">
|
||||||
|
暂无设备
|
||||||
|
</text>
|
||||||
|
<text class="text-[26rpx] text-[#999999] leading-[1.5]">
|
||||||
|
点击右下角 + 号绑定您的第一个设备
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- FAB 绑定设备按钮 -->
|
||||||
|
<wd-fab type="primary" size="small" icon="add" :draggable="true" :expandable="false" @click="openBindDialog" />
|
||||||
|
|
||||||
|
<!-- MessageBox 组件 -->
|
||||||
|
<wd-message-box />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-swipe-action) {
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1rpx solid #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-icon) {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
<!-- 使用 type="home" 属性设置首页,其他页面不需要设置,默认为page -->
|
||||||
|
<route lang="jsonc" type="home">
|
||||||
|
{
|
||||||
|
"layout": "tabbar",
|
||||||
|
"style": {
|
||||||
|
// 'custom' 表示开启自定义导航栏,默认 'default'
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "首页"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Agent } from '@/api/agent/types'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useMessage } from 'wot-design-uni'
|
||||||
|
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
|
||||||
|
import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent'
|
||||||
|
import { toast } from '@/utils/toast'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'Home',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取屏幕边界到安全区域距离
|
||||||
|
let safeAreaInsets: any
|
||||||
|
let systemInfo: any
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 微信小程序使用新的API
|
||||||
|
systemInfo = uni.getWindowInfo()
|
||||||
|
safeAreaInsets = systemInfo.safeArea
|
||||||
|
? {
|
||||||
|
top: systemInfo.safeArea.top,
|
||||||
|
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||||
|
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||||
|
left: systemInfo.safeArea.left,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
// 其他平台继续使用uni API
|
||||||
|
systemInfo = uni.getSystemInfoSync()
|
||||||
|
safeAreaInsets = systemInfo.safeAreaInsets
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 智能体数据
|
||||||
|
const agentList = ref<Agent[]>([])
|
||||||
|
const pagingRef = ref()
|
||||||
|
useZPaging(pagingRef)
|
||||||
|
// 消息组件
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
// z-paging查询列表数据
|
||||||
|
async function queryList(pageNo: number, pageSize: number) {
|
||||||
|
try {
|
||||||
|
console.log('z-paging获取智能体列表')
|
||||||
|
|
||||||
|
const response = await getAgentList()
|
||||||
|
|
||||||
|
// 更新本地列表
|
||||||
|
agentList.value = response
|
||||||
|
|
||||||
|
// 直接返回全部数据,不需要分页处理
|
||||||
|
pagingRef.value.complete(response)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取智能体列表失败:', error)
|
||||||
|
// 告知z-paging数据加载失败
|
||||||
|
pagingRef.value.complete(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建智能体
|
||||||
|
async function handleCreateAgent(agentName: string) {
|
||||||
|
try {
|
||||||
|
await createAgent({ agentName: agentName.trim() })
|
||||||
|
// 创建成功后刷新列表
|
||||||
|
pagingRef.value.reload()
|
||||||
|
toast.success(`智能体"${agentName}"创建成功!`)
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error('创建智能体失败:', error)
|
||||||
|
const errorMessage = error?.message || '创建失败,请重试'
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除智能体
|
||||||
|
async function handleDeleteAgent(agent: Agent) {
|
||||||
|
try {
|
||||||
|
await deleteAgent(agent.id)
|
||||||
|
// 删除成功后刷新列表
|
||||||
|
pagingRef.value.reload()
|
||||||
|
toast.success(`智能体"${agent.agentName}"已删除`)
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error('删除智能体失败:', error)
|
||||||
|
const errorMessage = error?.message || '删除失败,请重试'
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入编辑页面
|
||||||
|
function goToEditAgent(agent: Agent) {
|
||||||
|
// 传递智能体ID到编辑页面
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/agent/index?agentId=${agent.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击卡片进入编辑
|
||||||
|
function handleCardClick(agent: Agent) {
|
||||||
|
goToEditAgent(agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开创建对话框
|
||||||
|
function openCreateDialog() {
|
||||||
|
message
|
||||||
|
.prompt({
|
||||||
|
title: '创建智能体',
|
||||||
|
msg: '',
|
||||||
|
inputPlaceholder: '例如:客服助手、语音助理、知识问答',
|
||||||
|
inputValue: '',
|
||||||
|
inputPattern: /^[\u4E00-\u9FA5a-z0-9\s]{1,50}$/i,
|
||||||
|
confirmButtonText: '立即创建',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
.then(async (result: any) => {
|
||||||
|
if (result.value && String(result.value).trim()) {
|
||||||
|
await handleCreateAgent(String(result.value).trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 用户取消操作
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
function formatTime(timeStr: string) {
|
||||||
|
const date = new Date(timeStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
|
||||||
|
if (diff < 60000)
|
||||||
|
return '刚刚'
|
||||||
|
if (diff < 3600000)
|
||||||
|
return `${Math.floor(diff / 60000)}分钟前`
|
||||||
|
if (diff < 86400000)
|
||||||
|
return `${Math.floor(diff / 3600000)}小时前`
|
||||||
|
return `${Math.floor(diff / 86400000)}天前`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面显示时刷新列表
|
||||||
|
onShow(() => {
|
||||||
|
console.log('首页 onShow,刷新智能体列表')
|
||||||
|
if (pagingRef.value) {
|
||||||
|
pagingRef.value.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<z-paging
|
||||||
|
ref="pagingRef" v-model="agentList" :refresher-enabled="true" :auto-show-back-to-top="true"
|
||||||
|
:loading-more-enabled="false" :show-loading-more="false" :hide-empty-view="false" empty-view-text="暂无智能体"
|
||||||
|
empty-view-img="" :refresher-threshold="80" :back-to-top-style="{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
}" @query="queryList"
|
||||||
|
>
|
||||||
|
<!-- 固定在顶部的横幅区域 -->
|
||||||
|
<template #top>
|
||||||
|
<view class="banner-section" :style="{ paddingTop: `${safeAreaInsets?.top + 100}rpx` }">
|
||||||
|
<view class="banner-content">
|
||||||
|
<view class="welcome-info">
|
||||||
|
<text class="greeting">
|
||||||
|
你好,小智
|
||||||
|
</text>
|
||||||
|
<text class="subtitle">
|
||||||
|
让我们度过 <text class="highlight">
|
||||||
|
美好的一天!
|
||||||
|
</text>
|
||||||
|
</text>
|
||||||
|
<text class="english-subtitle">
|
||||||
|
Hello, Let's have a wonderful day!
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="wave-decoration">
|
||||||
|
<!-- 添加波浪装饰 -->
|
||||||
|
<view class="wave" />
|
||||||
|
<view class="wave wave-2" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 内容区域开始标识 -->
|
||||||
|
<view class="content-section-header" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 智能体卡片列表 -->
|
||||||
|
<view class="agent-list">
|
||||||
|
<view v-for="agent in agentList" :key="agent.id" class="agent-item">
|
||||||
|
<wd-swipe-action>
|
||||||
|
<view class="simple-card" @click="handleCardClick(agent)">
|
||||||
|
<view class="card-content">
|
||||||
|
<view class="card-main">
|
||||||
|
<view class="agent-title">
|
||||||
|
<text class="agent-name">
|
||||||
|
{{ agent.agentName }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="model-info">
|
||||||
|
<text class="model-text">
|
||||||
|
语言模型: {{ agent.llmModelName }}
|
||||||
|
</text>
|
||||||
|
<text class="model-text">
|
||||||
|
音色模型: {{ agent.ttsModelName }} ({{ agent.ttsVoiceName }})
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="stats-row">
|
||||||
|
<view class="stat-chip">
|
||||||
|
<wd-icon name="phone" custom-class="chip-icon" />
|
||||||
|
<text class="chip-text">
|
||||||
|
设备管理({{ agent.deviceCount }})
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="agent.lastConnectedAt" class="stat-chip">
|
||||||
|
<wd-icon name="time" custom-class="chip-icon" />
|
||||||
|
<text class="chip-text">
|
||||||
|
最近对话:{{ formatTime(agent.lastConnectedAt) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<wd-icon name="arrow-right" custom-class="arrow-icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<view class="swipe-actions">
|
||||||
|
<view class="action-btn delete-btn" @click.stop="handleDeleteAgent(agent)">
|
||||||
|
<wd-icon name="delete" />
|
||||||
|
<text>删除</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</wd-swipe-action>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 自定义空状态 -->
|
||||||
|
<template #empty>
|
||||||
|
<view class="empty-state">
|
||||||
|
<wd-icon name="robot" custom-class="empty-icon" />
|
||||||
|
<text class="empty-text">
|
||||||
|
暂无智能体
|
||||||
|
</text>
|
||||||
|
<text class="empty-desc">
|
||||||
|
点击右下角 + 号创建您的第一个智能体
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- FAB 新增按钮 -->
|
||||||
|
<wd-fab type="primary" icon="add" :draggable="true" :expandable="false" @click="openCreateDialog" />
|
||||||
|
|
||||||
|
<!-- MessageBox 组件 -->
|
||||||
|
<wd-message-box />
|
||||||
|
</z-paging>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.banner-section {
|
||||||
|
background: linear-gradient(145deg, #9ebbfc, #6baaff, #9ebbfc, #f5f8fd);
|
||||||
|
position: relative;
|
||||||
|
padding: 40rpx 40rpx 80rpx 40rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.banner-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: -50rpx;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 32rpx;
|
||||||
|
|
||||||
|
.filter-icon,
|
||||||
|
.setting-icon {
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-info {
|
||||||
|
.greeting {
|
||||||
|
display: block;
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
color: #ffd700;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.english-subtitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -100rpx;
|
||||||
|
width: 400rpx;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0.1;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.wave {
|
||||||
|
position: absolute;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
|
||||||
|
&.wave-2 {
|
||||||
|
top: 20%;
|
||||||
|
right: 20%;
|
||||||
|
animation-delay: -3s;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(-30rpx) rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容区域开始标识,创建白色背景过渡
|
||||||
|
.content-section-header {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
|
margin-top: -32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// z-paging内容区域样式
|
||||||
|
:deep(.z-paging-content) {
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 0 0 40rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-item {
|
||||||
|
:deep(.wd-swipe-action) {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1rpx solid #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-card {
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 24rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
|
||||||
|
.agent-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-info {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
|
||||||
|
.model-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.stat-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6rpx 12rpx;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
border: 1rpx solid #eaeaea;
|
||||||
|
|
||||||
|
:deep(.chip-icon) {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #666666;
|
||||||
|
margin-right: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arrow-icon) {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #c7c7cc;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-actions {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 120rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.edit-btn {
|
||||||
|
background: #1890ff;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #096dd9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.delete-btn {
|
||||||
|
background: #ff4d4f;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #d9363e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-icon) {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
:deep(.empty-icon) {
|
||||||
|
font-size: 120rpx;
|
||||||
|
color: #d9d9d9;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #666666;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999999;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
padding: 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1rpx solid #eeeeee;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
<route lang="jsonc" type="page">
|
||||||
|
{
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "登陆"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { LoginData } from '@/api/auth'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { login } from '@/api/auth'
|
||||||
|
import { useConfigStore } from '@/store'
|
||||||
|
import { getEnvBaseUrl } from '@/utils'
|
||||||
|
import { toast } from '@/utils/toast'
|
||||||
|
|
||||||
|
// 获取屏幕边界到安全区域距离
|
||||||
|
let safeAreaInsets
|
||||||
|
let systemInfo
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 微信小程序使用新的API
|
||||||
|
systemInfo = uni.getWindowInfo()
|
||||||
|
safeAreaInsets = systemInfo.safeArea
|
||||||
|
? {
|
||||||
|
top: systemInfo.safeArea.top,
|
||||||
|
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||||
|
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||||
|
left: systemInfo.safeArea.left,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
// 其他平台继续使用uni API
|
||||||
|
systemInfo = uni.getSystemInfoSync()
|
||||||
|
safeAreaInsets = systemInfo.safeAreaInsets
|
||||||
|
// #endif
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<LoginData>({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
captcha: '',
|
||||||
|
captchaId: '',
|
||||||
|
areaCode: '+86',
|
||||||
|
mobile: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证码图片
|
||||||
|
const captchaImage = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 登录方式:'username' | 'mobile'
|
||||||
|
const loginType = ref<'username' | 'mobile'>('username')
|
||||||
|
|
||||||
|
// 获取配置store
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
// 区号选择相关
|
||||||
|
const showAreaCodeSheet = ref(false)
|
||||||
|
const selectedAreaCode = ref('+86')
|
||||||
|
const selectedAreaName = ref('中国大陆')
|
||||||
|
|
||||||
|
// 计算属性:是否启用手机号登录
|
||||||
|
const enableMobileLogin = computed(() => {
|
||||||
|
return configStore.config.enableMobileRegister
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算属性:区号列表
|
||||||
|
const areaCodeList = computed(() => {
|
||||||
|
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换登录方式
|
||||||
|
function toggleLoginType() {
|
||||||
|
loginType.value = loginType.value === 'username' ? 'mobile' : 'username'
|
||||||
|
// 清空输入框
|
||||||
|
formData.value.username = ''
|
||||||
|
formData.value.mobile = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开区号选择弹窗
|
||||||
|
function openAreaCodeSheet() {
|
||||||
|
showAreaCodeSheet.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择区号
|
||||||
|
function selectAreaCode(item: { name: string, key: string }) {
|
||||||
|
selectedAreaCode.value = item.key
|
||||||
|
selectedAreaName.value = item.name
|
||||||
|
formData.value.areaCode = item.key
|
||||||
|
showAreaCodeSheet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭区号选择弹窗
|
||||||
|
function closeAreaCodeSheet() {
|
||||||
|
showAreaCodeSheet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到注册页面
|
||||||
|
function goToRegister() {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages/register/index',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成UUID
|
||||||
|
function generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = Math.random() * 16 | 0
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8)
|
||||||
|
return v.toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipReLaunch = false // 全局或组件作用域
|
||||||
|
|
||||||
|
//跳转至服务端设置页面
|
||||||
|
function goToServerSetting() {
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/settings/index',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取验证码
|
||||||
|
async function refreshCaptcha() {
|
||||||
|
const uuid = generateUUID()
|
||||||
|
formData.value.captchaId = uuid
|
||||||
|
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
async function handleLogin() {
|
||||||
|
// 表单验证
|
||||||
|
if (loginType.value === 'username') {
|
||||||
|
if (!formData.value.username) {
|
||||||
|
toast.warning('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!formData.value.mobile) {
|
||||||
|
toast.warning('请输入手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 手机号格式验证
|
||||||
|
const phoneRegex = /^1[3-9]\d{9}$/
|
||||||
|
if (!phoneRegex.test(formData.value.mobile)) {
|
||||||
|
toast.warning('请输入正确的手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!formData.value.password) {
|
||||||
|
toast.warning('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.captcha) {
|
||||||
|
toast.warning('请输入验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
// 构建登录数据
|
||||||
|
const loginData = { ...formData.value }
|
||||||
|
|
||||||
|
// 如果是手机号登录,将区号+手机号拼接到username字段
|
||||||
|
if (loginType.value === 'mobile') {
|
||||||
|
loginData.username = `${selectedAreaCode.value}${formData.value.mobile}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await login(loginData)
|
||||||
|
// 存储token
|
||||||
|
uni.setStorageSync('token', response.token)
|
||||||
|
uni.setStorageSync('expire', response.expire)
|
||||||
|
|
||||||
|
toast.success('登录成功')
|
||||||
|
|
||||||
|
// 跳转到主页
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.reLaunch({
|
||||||
|
url: '/pages/index/index',
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
// 登录失败重新获取验证码
|
||||||
|
refreshCaptcha()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时获取验证码
|
||||||
|
onLoad(() => {
|
||||||
|
refreshCaptcha()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件挂载时确保配置已加载
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!configStore.config.name) {
|
||||||
|
try {
|
||||||
|
await configStore.fetchPublicConfig()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取配置失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="app-container box-border h-screen w-full" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
|
||||||
|
<view class="header">
|
||||||
|
<view class="logo-section">
|
||||||
|
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
|
||||||
|
<text class="welcome-text">
|
||||||
|
欢迎回来
|
||||||
|
</text>
|
||||||
|
<text class="subtitle">
|
||||||
|
请登录您的账户
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 右上角服务端设置按钮 -->
|
||||||
|
<view
|
||||||
|
class="server-btn"
|
||||||
|
:style="{ top: `${safeAreaInsets?.top + 10}px` }"
|
||||||
|
@click="goToServerSetting"
|
||||||
|
>
|
||||||
|
<wd-icon name="setting" custom-class="server-icon" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-container">
|
||||||
|
<view class="form">
|
||||||
|
<!-- 手机号登录 -->
|
||||||
|
<template v-if="loginType === 'mobile'">
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper mobile-wrapper">
|
||||||
|
<view class="area-code-selector" @click="openAreaCodeSheet">
|
||||||
|
<text class="area-code-text">
|
||||||
|
{{ selectedAreaCode }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-down" custom-class="area-code-arrow" />
|
||||||
|
</view>
|
||||||
|
<view class="mobile-input-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.mobile"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入手机号码"
|
||||||
|
type="number"
|
||||||
|
:maxlength="11"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 用户名登录 -->
|
||||||
|
<template v-else>
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.username"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.password"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入密码"
|
||||||
|
clearable
|
||||||
|
show-password
|
||||||
|
:maxlength="20"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper captcha-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.captcha"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
:maxlength="6"
|
||||||
|
/>
|
||||||
|
<view class="captcha-image" @click="refreshCaptcha">
|
||||||
|
<image :src="captchaImage" class="captcha-img" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="forgot-password">
|
||||||
|
<text class="forgot-text">
|
||||||
|
忘记密码?
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="login-btn"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="register-hint">
|
||||||
|
<text class="hint-text">
|
||||||
|
还没有账户?
|
||||||
|
</text>
|
||||||
|
<text class="register-link" @click="goToRegister">
|
||||||
|
立即注册
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 登录方式切换 -->
|
||||||
|
<view v-if="enableMobileLogin" class="login-type-switch">
|
||||||
|
<view class="switch-tabs">
|
||||||
|
<view
|
||||||
|
class="switch-tab"
|
||||||
|
:class="{ active: loginType === 'username' }"
|
||||||
|
@click="toggleLoginType"
|
||||||
|
>
|
||||||
|
<wd-icon name="user" />
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="switch-tab"
|
||||||
|
:class="{ active: loginType === 'mobile' }"
|
||||||
|
@click="toggleLoginType"
|
||||||
|
>
|
||||||
|
<wd-icon name="phone" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 区号选择弹窗 -->
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="showAreaCodeSheet"
|
||||||
|
title="选择国家/地区"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
@close="closeAreaCodeSheet"
|
||||||
|
>
|
||||||
|
<view class="area-code-sheet">
|
||||||
|
<scroll-view scroll-y class="area-code-list">
|
||||||
|
<view
|
||||||
|
v-for="item in areaCodeList"
|
||||||
|
:key="item.key"
|
||||||
|
class="area-code-item"
|
||||||
|
:class="{ selected: selectedAreaCode === item.key }"
|
||||||
|
@click="selectAreaCode(item)"
|
||||||
|
>
|
||||||
|
<view class="area-info">
|
||||||
|
<text class="area-name">
|
||||||
|
{{ item.name }}
|
||||||
|
</text>
|
||||||
|
<text class="area-code">
|
||||||
|
{{ item.key }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<wd-icon
|
||||||
|
v-if="selectedAreaCode === item.key"
|
||||||
|
name="check"
|
||||||
|
custom-class="check-icon"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="sheet-footer">
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
custom-class="confirm-btn"
|
||||||
|
@click="closeAreaCodeSheet"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</wd-action-sheet>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.app-container {
|
||||||
|
background: linear-gradient(145deg, #f5f8fd, #6baaff, #9ebbfc, #f5f8fd);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px) rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px) rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 280rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 15% 0 40rpx 0;
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
display: block;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: block;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
padding: 0 40rpx 40rpx 40rpx;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 40rpx 30rpx 30rpx 30rpx;
|
||||||
|
backdrop-filter: blur(10rpx);
|
||||||
|
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.2);
|
||||||
|
max-height: calc(100vh - 350rpx);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx 16rpx;
|
||||||
|
border: 2rpx solid #e9ecef;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.captcha-wrapper {
|
||||||
|
.captcha-image {
|
||||||
|
margin-left: 20rpx;
|
||||||
|
width: 120rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #e9ecef;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
|
||||||
|
.captcha-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-loading {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mobile-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
|
||||||
|
.area-code-selector {
|
||||||
|
flex: 0 0 160rpx;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx 16rpx;
|
||||||
|
border: 2rpx solid #e9ecef;
|
||||||
|
height: 45rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-code-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.area-code-arrow) {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999999;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx 16rpx;
|
||||||
|
border: 2rpx solid #e9ecef;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.styled-input) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #333333;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #999999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
|
||||||
|
.forgot-text {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 26rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background-color: var(--wot-button-primary-bg-color, var(--wot-color-theme, #4d80f0));
|
||||||
|
text-align: center;
|
||||||
|
line-height: 80rpx;
|
||||||
|
&:active {
|
||||||
|
transform: translateY(2rpx);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-hint {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
color: #666666;
|
||||||
|
font-size: 26rpx;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
// text-decoration: underline;
|
||||||
|
// &:hover {
|
||||||
|
// text-decoration: underline;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-type-switch {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.switch-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 60rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.switch-tab {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-icon) {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-hint {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 区号选择弹窗样式
|
||||||
|
.area-code-sheet {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.sheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 40rpx 40rpx 20rpx 40rpx;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.close-icon) {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #999999;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10rpx;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-code-list {
|
||||||
|
max-height: 60vh;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
|
||||||
|
.area-code-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f8f9fa;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: rgba(102, 126, 234, 0.05);
|
||||||
|
|
||||||
|
.area-name {
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-code {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
.area-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-code {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.check-icon) {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-footer {
|
||||||
|
padding: 30rpx 40rpx 40rpx 40rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
:deep(.confirm-btn) {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.server-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 20rpx; // 距离右边距
|
||||||
|
top: 40rpx; // 顶部稍微下移,不贴状态栏
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255, 255, 255, 0.15); // 半透明背景,更好看
|
||||||
|
border-radius: 24rpx; // 圆形按钮
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2); // 阴影
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #FFFFFF; // 白色图标
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25); // 悬停效果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
<route lang="jsonc" type="page">
|
||||||
|
{
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "注册"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { register, sendSmsCode } from '@/api/auth'
|
||||||
|
import { useConfigStore } from '@/store'
|
||||||
|
import { getEnvBaseUrl } from '@/utils'
|
||||||
|
import { toast } from '@/utils/toast'
|
||||||
|
|
||||||
|
// 获取屏幕边界到安全区域距离
|
||||||
|
let safeAreaInsets
|
||||||
|
let systemInfo
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 微信小程序使用新的API
|
||||||
|
systemInfo = uni.getWindowInfo()
|
||||||
|
safeAreaInsets = systemInfo.safeArea
|
||||||
|
? {
|
||||||
|
top: systemInfo.safeArea.top,
|
||||||
|
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||||
|
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||||
|
left: systemInfo.safeArea.left,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
// 其他平台继续使用uni API
|
||||||
|
systemInfo = uni.getSystemInfoSync()
|
||||||
|
safeAreaInsets = systemInfo.safeAreaInsets
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 注册表单数据
|
||||||
|
interface RegisterData {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
captcha: string
|
||||||
|
captchaId: string
|
||||||
|
areaCode: string
|
||||||
|
mobile: string
|
||||||
|
mobileCaptcha: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = ref<RegisterData>({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
captcha: '',
|
||||||
|
captchaId: '',
|
||||||
|
areaCode: '+86',
|
||||||
|
mobile: '',
|
||||||
|
mobileCaptcha: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证码图片
|
||||||
|
const captchaImage = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const smsLoading = ref(false)
|
||||||
|
const smsCountdown = ref(0)
|
||||||
|
|
||||||
|
// 注册方式:'username' | 'mobile'
|
||||||
|
const registerType = ref<'username' | 'mobile'>('username')
|
||||||
|
|
||||||
|
// 获取配置store
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
// 区号选择相关
|
||||||
|
const showAreaCodeSheet = ref(false)
|
||||||
|
const selectedAreaCode = ref('+86')
|
||||||
|
const selectedAreaName = ref('中国大陆')
|
||||||
|
|
||||||
|
// 计算属性:是否启用手机号注册
|
||||||
|
const enableMobileRegister = computed(() => {
|
||||||
|
return configStore.config.enableMobileRegister
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算属性:区号列表
|
||||||
|
const areaCodeList = computed(() => {
|
||||||
|
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换注册方式
|
||||||
|
function toggleRegisterType() {
|
||||||
|
registerType.value = registerType.value === 'username' ? 'mobile' : 'username'
|
||||||
|
// 清空输入框
|
||||||
|
formData.value.username = ''
|
||||||
|
formData.value.mobile = ''
|
||||||
|
formData.value.mobileCaptcha = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开区号选择弹窗
|
||||||
|
function openAreaCodeSheet() {
|
||||||
|
showAreaCodeSheet.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择区号
|
||||||
|
function selectAreaCode(item: { name: string, key: string }) {
|
||||||
|
selectedAreaCode.value = item.key
|
||||||
|
selectedAreaName.value = item.name
|
||||||
|
formData.value.areaCode = item.key
|
||||||
|
showAreaCodeSheet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭区号选择弹窗
|
||||||
|
function closeAreaCodeSheet() {
|
||||||
|
showAreaCodeSheet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成UUID
|
||||||
|
function generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||||
|
return v.toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取验证码
|
||||||
|
async function refreshCaptcha() {
|
||||||
|
const uuid = generateUUID()
|
||||||
|
formData.value.captchaId = uuid
|
||||||
|
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送短信验证码
|
||||||
|
async function sendSmsVerification() {
|
||||||
|
if (!formData.value.mobile) {
|
||||||
|
toast.warning('请输入手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.captcha) {
|
||||||
|
toast.warning('请输入图形验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手机号格式验证
|
||||||
|
const phoneRegex = /^1[3-9]\d{9}$/
|
||||||
|
if (!phoneRegex.test(formData.value.mobile)) {
|
||||||
|
toast.warning('请输入正确的手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
smsLoading.value = true
|
||||||
|
await sendSmsCode({
|
||||||
|
phone: `${selectedAreaCode.value}${formData.value.mobile}`,
|
||||||
|
captcha: formData.value.captcha,
|
||||||
|
captchaId: formData.value.captchaId,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.success('验证码发送成功')
|
||||||
|
|
||||||
|
// 开始倒计时
|
||||||
|
smsCountdown.value = 60
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
smsCountdown.value--
|
||||||
|
if (smsCountdown.value <= 0) {
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
// 发送失败重新获取图形验证码
|
||||||
|
refreshCaptcha()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
smsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
async function handleRegister() {
|
||||||
|
// 表单验证
|
||||||
|
if (registerType.value === 'username') {
|
||||||
|
if (!formData.value.username) {
|
||||||
|
toast.warning('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!formData.value.mobile) {
|
||||||
|
toast.warning('请输入手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 手机号格式验证
|
||||||
|
const phoneRegex = /^1[3-9]\d{9}$/
|
||||||
|
if (!phoneRegex.test(formData.value.mobile)) {
|
||||||
|
toast.warning('请输入正确的手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.mobileCaptcha) {
|
||||||
|
toast.warning('请输入短信验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.value.password) {
|
||||||
|
toast.warning('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.value.confirmPassword) {
|
||||||
|
toast.warning('请确认密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.value.password !== formData.value.confirmPassword) {
|
||||||
|
toast.warning('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.value.captcha) {
|
||||||
|
toast.warning('请输入验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
// 构建注册数据
|
||||||
|
const registerData = {
|
||||||
|
username: registerType.value === 'mobile' ? `${selectedAreaCode.value}${formData.value.mobile}` : formData.value.username,
|
||||||
|
password: formData.value.password,
|
||||||
|
confirmPassword: formData.value.confirmPassword,
|
||||||
|
captcha: formData.value.captcha,
|
||||||
|
captchaId: formData.value.captchaId,
|
||||||
|
areaCode: formData.value.areaCode,
|
||||||
|
mobile: formData.value.mobile,
|
||||||
|
mobileCaptcha: formData.value.mobileCaptcha,
|
||||||
|
}
|
||||||
|
|
||||||
|
await register(registerData)
|
||||||
|
toast.success('注册成功')
|
||||||
|
|
||||||
|
// 跳转到登录页
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
// 注册失败重新获取验证码
|
||||||
|
refreshCaptcha()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回登录
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时获取验证码
|
||||||
|
onLoad(() => {
|
||||||
|
refreshCaptcha()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件挂载时确保配置已加载
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!configStore.config.name) {
|
||||||
|
try {
|
||||||
|
await configStore.fetchPublicConfig()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取配置失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="app-container box-border h-screen w-full" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
|
||||||
|
<view class="header">
|
||||||
|
<view class="back-button" @click="goBack">
|
||||||
|
<wd-icon name="arrow-left" custom-class="back-icon" />
|
||||||
|
</view>
|
||||||
|
<view class="logo-section">
|
||||||
|
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
|
||||||
|
<text class="welcome-text">
|
||||||
|
欢迎注册
|
||||||
|
</text>
|
||||||
|
<text class="subtitle">
|
||||||
|
创建您的新账户
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-container">
|
||||||
|
<view class="form">
|
||||||
|
<!-- 手机号注册 -->
|
||||||
|
<template v-if="registerType === 'mobile'">
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper mobile-wrapper">
|
||||||
|
<view class="area-code-selector" @click="openAreaCodeSheet">
|
||||||
|
<text class="area-code-text">
|
||||||
|
{{ selectedAreaCode }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-down" custom-class="area-code-arrow" />
|
||||||
|
</view>
|
||||||
|
<view class="mobile-input-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.mobile"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入手机号码"
|
||||||
|
type="number"
|
||||||
|
:maxlength="11"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 用户名注册 -->
|
||||||
|
<template v-else>
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.username"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.password"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入密码"
|
||||||
|
show-password
|
||||||
|
:maxlength="20"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.confirmPassword"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请确认密码"
|
||||||
|
show-password
|
||||||
|
:maxlength="20"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="input-wrapper captcha-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.captcha"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
:maxlength="6"
|
||||||
|
/>
|
||||||
|
<view class="captcha-image" @click="refreshCaptcha">
|
||||||
|
<image :src="captchaImage" class="captcha-img" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 手机验证码输入框 -->
|
||||||
|
<view v-if="registerType === 'mobile'" class="input-group">
|
||||||
|
<view class="input-wrapper sms-wrapper">
|
||||||
|
<wd-input
|
||||||
|
v-model="formData.mobileCaptcha"
|
||||||
|
custom-class="styled-input"
|
||||||
|
no-border
|
||||||
|
placeholder="请输入短信验证码"
|
||||||
|
type="number"
|
||||||
|
:maxlength="6"
|
||||||
|
/>
|
||||||
|
<wd-button
|
||||||
|
:loading="smsLoading"
|
||||||
|
:disabled="smsCountdown > 0"
|
||||||
|
custom-class="sms-btn"
|
||||||
|
@click="sendSmsVerification"
|
||||||
|
>
|
||||||
|
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="register-btn"
|
||||||
|
type="primary"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleRegister"
|
||||||
|
>
|
||||||
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="login-hint">
|
||||||
|
<text class="hint-text">
|
||||||
|
已有账户?
|
||||||
|
</text>
|
||||||
|
<text class="login-link" @click="goBack">
|
||||||
|
立即登录
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 注册方式切换 -->
|
||||||
|
<view v-if="enableMobileRegister" class="register-type-switch">
|
||||||
|
<view class="switch-tabs">
|
||||||
|
<view
|
||||||
|
class="switch-tab"
|
||||||
|
:class="{ active: registerType === 'username' }"
|
||||||
|
@click="toggleRegisterType"
|
||||||
|
>
|
||||||
|
<wd-icon name="user" />
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="switch-tab"
|
||||||
|
:class="{ active: registerType === 'mobile' }"
|
||||||
|
@click="toggleRegisterType"
|
||||||
|
>
|
||||||
|
<wd-icon name="phone" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 区号选择弹窗 -->
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="showAreaCodeSheet"
|
||||||
|
title="选择国家/地区"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
@close="closeAreaCodeSheet"
|
||||||
|
>
|
||||||
|
<view class="area-code-sheet">
|
||||||
|
<scroll-view scroll-y class="area-code-list">
|
||||||
|
<view
|
||||||
|
v-for="item in areaCodeList"
|
||||||
|
:key="item.key"
|
||||||
|
class="area-code-item"
|
||||||
|
:class="{ selected: selectedAreaCode === item.key }"
|
||||||
|
@click="selectAreaCode(item)"
|
||||||
|
>
|
||||||
|
<view class="area-info">
|
||||||
|
<text class="area-name">
|
||||||
|
{{ item.name }}
|
||||||
|
</text>
|
||||||
|
<text class="area-code">
|
||||||
|
{{ item.key }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<wd-icon
|
||||||
|
v-if="selectedAreaCode === item.key"
|
||||||
|
name="check"
|
||||||
|
custom-class="check-icon"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="sheet-footer">
|
||||||
|
<wd-button
|
||||||
|
type="primary"
|
||||||
|
custom-class="confirm-btn"
|
||||||
|
@click="closeAreaCodeSheet"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</wd-action-sheet>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.app-container {
|
||||||
|
background: linear-gradient(145deg, #f5f8fd, #6baaff, #9ebbfc, #f5f8fd);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px) rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px) rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 280rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 15% 0 40rpx 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
position: absolute;
|
||||||
|
left: 40rpx;
|
||||||
|
top: 60rpx;
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.back-icon) {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
display: block;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: block;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
padding: 0 40rpx 40rpx 40rpx;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 40rpx 30rpx 30rpx 30rpx;
|
||||||
|
backdrop-filter: blur(10rpx);
|
||||||
|
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.2);
|
||||||
|
max-height: calc(100vh - 350rpx);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx 16rpx;
|
||||||
|
border: 2rpx solid #e9ecef;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.captcha-wrapper {
|
||||||
|
.captcha-image {
|
||||||
|
margin-left: 20rpx;
|
||||||
|
width: 120rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #e9ecef;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
|
||||||
|
.captcha-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sms-wrapper {
|
||||||
|
:deep(.sms-btn) {
|
||||||
|
margin-left: 20rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 140rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mobile-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
|
||||||
|
.area-code-selector {
|
||||||
|
flex: 0 0 160rpx;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx 16rpx;
|
||||||
|
border: 2rpx solid #e9ecef;
|
||||||
|
height: 45rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-code-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.area-code-arrow) {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999999;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx 16rpx;
|
||||||
|
border: 2rpx solid #e9ecef;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.styled-input) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #333333;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #999999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.register-btn) {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background-color: var(--wot-button-primary-bg-color, var(--wot-color-theme, #4d80f0));
|
||||||
|
text-align: center;
|
||||||
|
line-height: 80rpx;
|
||||||
|
&:active {
|
||||||
|
transform: translateY(2rpx);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hint {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
color: #666666;
|
||||||
|
font-size: 26rpx;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-type-switch {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.switch-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 60rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.switch-tab {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-icon) {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-hint {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 区号选择弹窗样式
|
||||||
|
.area-code-sheet {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.area-code-list {
|
||||||
|
max-height: 60vh;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
|
||||||
|
.area-code-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f8f9fa;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: rgba(102, 126, 234, 0.05);
|
||||||
|
|
||||||
|
.area-name {
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-code {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
.area-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-code {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.check-icon) {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-footer {
|
||||||
|
padding: 30rpx 40rpx 40rpx 40rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
:deep(.confirm-btn) {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<route lang="jsonc" type="page">{
|
||||||
|
"layout": "default",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "设置",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}</route>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, setServerBaseUrlOverride } from '@/utils'
|
||||||
|
import { isMp } from '@/utils/platform'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useToast } from 'wot-design-uni'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SettingsPage',
|
||||||
|
})
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// 缓存信息
|
||||||
|
const cacheInfo = reactive({
|
||||||
|
storageSize: '0MB',
|
||||||
|
imageCache: '0MB',
|
||||||
|
dataCache: '0MB',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 服务端地址设置
|
||||||
|
const baseUrlInput = ref('')
|
||||||
|
const urlError = ref('')
|
||||||
|
|
||||||
|
// 系统信息(保留)
|
||||||
|
const systemInfo = computed(() => {
|
||||||
|
const info = uni.getSystemInfoSync()
|
||||||
|
return `${info.platform} ${info.system}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 读取本地覆盖地址
|
||||||
|
function loadServerBaseUrl() {
|
||||||
|
const override = getServerBaseUrlOverride()
|
||||||
|
baseUrlInput.value = override || getEnvBaseUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存信息
|
||||||
|
function getCacheInfo() {
|
||||||
|
try {
|
||||||
|
const info = uni.getStorageInfoSync()
|
||||||
|
const totalSize = (info.currentSize || 0) / 1024 // KB to MB
|
||||||
|
cacheInfo.storageSize = `${totalSize.toFixed(2)}MB`
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取缓存信息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证URL格式
|
||||||
|
function validateUrl() {
|
||||||
|
urlError.value = ''
|
||||||
|
|
||||||
|
if (!baseUrlInput.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) {
|
||||||
|
urlError.value = '请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试服务端地址
|
||||||
|
async function testServerBaseUrl() {
|
||||||
|
// 先清除错误信息
|
||||||
|
urlError.value = ''
|
||||||
|
|
||||||
|
if (!baseUrlInput.value || !/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uni.request({
|
||||||
|
url: `${baseUrlInput.value}/api/ping`,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.statusCode === 200) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
toast.error('无效地址,请检查服务端是否启动或网络连接是否正常')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('测试服务端地址失败:', error)
|
||||||
|
toast.error('无效地址,请检查服务端是否启动或网络连接是否正常')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存服务端地址
|
||||||
|
async function saveServerBaseUrl() {
|
||||||
|
if (!baseUrlInput.value || !/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) {
|
||||||
|
toast.warning('请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试地址有效性
|
||||||
|
const isServerValid = await testServerBaseUrl()
|
||||||
|
if (!isServerValid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setServerBaseUrlOverride(baseUrlInput.value)
|
||||||
|
|
||||||
|
// 切换请求地址后清空所有缓存
|
||||||
|
clearAllCacheAfterUrlChange()
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '重启应用',
|
||||||
|
content: '服务端地址已保存并清空缓存,是否立即重启生效?',
|
||||||
|
confirmText: '立即重启',
|
||||||
|
cancelText: '稍后',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
restartApp()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.success('已保存,可稍后手动重启应用')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置为 env 默认
|
||||||
|
function resetServerBaseUrl() {
|
||||||
|
clearServerBaseUrlOverride()
|
||||||
|
baseUrlInput.value = getEnvBaseUrl()
|
||||||
|
|
||||||
|
// 切换请求地址后清空所有缓存
|
||||||
|
clearAllCacheAfterUrlChange()
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '重启应用',
|
||||||
|
content: '已重置为默认地址并清空缓存,是否立即重启生效?',
|
||||||
|
confirmText: '立即重启',
|
||||||
|
cancelText: '稍后',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
restartApp()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.success('已重置,可稍后手动重启应用')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重启应用(App 原生重启;其他端回到首页)
|
||||||
|
function restartApp() {
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
plus.runtime.restart()
|
||||||
|
// #endif
|
||||||
|
// #ifndef APP-PLUS
|
||||||
|
uni.reLaunch({ url: '/pages/index/index' })
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换地址后自动清空所有缓存
|
||||||
|
function clearAllCacheAfterUrlChange() {
|
||||||
|
try {
|
||||||
|
// 备份运行时覆盖地址,确保清理后恢复
|
||||||
|
const preservedOverride = getServerBaseUrlOverride()
|
||||||
|
|
||||||
|
// 完全清空所有缓存,包括token
|
||||||
|
uni.clearStorageSync()
|
||||||
|
|
||||||
|
// 清空localStorage(H5环境)
|
||||||
|
// #ifdef H5
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.clear()
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 恢复运行时覆盖地址(如有),需要在清理完成后再写入
|
||||||
|
if (preservedOverride) {
|
||||||
|
setServerBaseUrlOverride(preservedOverride)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新获取缓存信息
|
||||||
|
getCacheInfo()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('清除缓存失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
async function clearCache() {
|
||||||
|
try {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认清除',
|
||||||
|
content: '确定要清除所有缓存吗?这将删除所有数据包括登录状态,需要重新登录。',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
clearAllCacheAfterUrlChange()
|
||||||
|
toast.success('缓存清除成功,即将跳转到登录页')
|
||||||
|
|
||||||
|
// 延迟跳转到登录页
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('清除缓存失败:', error)
|
||||||
|
toast.error('清除缓存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关于我们
|
||||||
|
function showAbout() {
|
||||||
|
uni.showModal({
|
||||||
|
title: `关于${import.meta.env.VITE_APP_TITLE}`,
|
||||||
|
content: `${import.meta.env.VITE_APP_TITLE}\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server`,
|
||||||
|
title: `关于小智智控台`,
|
||||||
|
content: `小智智控台\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server 0.7.6`,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '确定',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 仅在非小程序环境加载服务端地址设置
|
||||||
|
if (!isMp) {
|
||||||
|
loadServerBaseUrl()
|
||||||
|
}
|
||||||
|
getCacheInfo()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-[#f5f7fb]">
|
||||||
|
<wd-navbar title="设置" placeholder safe-area-inset-top fixed />
|
||||||
|
|
||||||
|
<view class="p-[24rpx]">
|
||||||
|
<!-- 网络设置 - 仅在非小程序环境显示 -->
|
||||||
|
<view v-if="!isMp" class="mb-[32rpx]">
|
||||||
|
<view class="mb-[24rpx] flex items-center">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
网络设置
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx] overflow-hidden"
|
||||||
|
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
||||||
|
<view class="mb-[24rpx]">
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-semibold">
|
||||||
|
服务端接口地址
|
||||||
|
</text>
|
||||||
|
<text class="mt-[8rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||||
|
修改后将自动清空缓存并重启应用
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-[24rpx]">
|
||||||
|
<view class="w-full rounded-[16rpx] border border-[#eeeeee] bg-[#f5f7fb] overflow-hidden">
|
||||||
|
<wd-input
|
||||||
|
v-model="baseUrlInput"
|
||||||
|
type="text"
|
||||||
|
clearable
|
||||||
|
:maxlength="200"
|
||||||
|
placeholder="输入服务端地址,如 https://example.com/xiaozhi"
|
||||||
|
custom-class="!border-none !bg-transparent h-[88rpx] px-[24rpx] items-center"
|
||||||
|
input-class="text-[28rpx] text-[#232338]"
|
||||||
|
@input="validateUrl"
|
||||||
|
@blur="validateUrl"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text v-if="urlError" class="mt-[8rpx] block text-[24rpx] text-[#ff4d4f]">
|
||||||
|
{{ urlError }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex gap-[16rpx]">
|
||||||
|
<wd-button type="primary"
|
||||||
|
custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-[#336cff] border-none shadow-[0_4rpx_16rpx_rgba(51,108,255,0.3)] active:shadow-[0_2rpx_8rpx_rgba(51,108,255,0.4)] active:scale-98"
|
||||||
|
@click="saveServerBaseUrl">
|
||||||
|
保存设置
|
||||||
|
</wd-button>
|
||||||
|
<wd-button type="default"
|
||||||
|
custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-white border-[#eeeeee] text-[#65686f] active:bg-[#f5f7fb]"
|
||||||
|
@click="resetServerBaseUrl">
|
||||||
|
恢复默认
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 缓存管理 -->
|
||||||
|
<view class="mb-[32rpx]">
|
||||||
|
<view class="mb-[24rpx] flex items-center">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
缓存管理
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
|
||||||
|
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
||||||
|
<view class="space-y-[16rpx]">
|
||||||
|
<!-- 缓存信息展示,参考插件样式 -->
|
||||||
|
<view
|
||||||
|
class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]">
|
||||||
|
<view>
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
总缓存大小
|
||||||
|
</text>
|
||||||
|
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||||
|
应用数据总大小
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text class="text-[28rpx] text-[#65686f] font-semibold">
|
||||||
|
{{ cacheInfo.storageSize }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 清除缓存按钮,参考插件编辑按钮样式 -->
|
||||||
|
<view
|
||||||
|
class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx]">
|
||||||
|
<view>
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
缓存清理
|
||||||
|
</text>
|
||||||
|
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||||
|
清空所有缓存数据
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="cursor-pointer rounded-[24rpx] bg-[rgba(255,107,107,0.1)] px-[28rpx] py-[16rpx] text-[24rpx] text-[#ff6b6b] font-semibold transition-all duration-300 active:scale-95 active:bg-[#ff6b6b] active:text-white"
|
||||||
|
@click="clearCache">
|
||||||
|
清除缓存
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 应用信息 -->
|
||||||
|
<view class="mb-[32rpx]">
|
||||||
|
<view class="mb-[24rpx] flex items-center">
|
||||||
|
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||||
|
应用信息
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
|
||||||
|
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
||||||
|
<view
|
||||||
|
class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]"
|
||||||
|
@click="showAbout">
|
||||||
|
<view>
|
||||||
|
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||||
|
关于我们
|
||||||
|
</text>
|
||||||
|
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||||
|
应用版本与团队信息
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<wd-icon name="arrow-right" custom-class="text-[32rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部安全距离 -->
|
||||||
|
<!-- 底部安全距离 -->
|
||||||
|
<view style="height: env(safe-area-inset-bottom);" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// 保持与 edit.vue 一致的风格,样式主要通过类名控制</style>
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ChatHistory, CreateSpeakerData, VoicePrint } from '@/api/voiceprint'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useMessage } from 'wot-design-uni'
|
||||||
|
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||||
|
import { createVoicePrint, deleteVoicePrint, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'VoicePrintManage',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 接收props
|
||||||
|
interface Props {
|
||||||
|
agentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
agentId: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取屏幕边界到安全区域距离
|
||||||
|
let safeAreaInsets: any
|
||||||
|
let systemInfo: any
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
systemInfo = uni.getWindowInfo()
|
||||||
|
safeAreaInsets = systemInfo.safeArea
|
||||||
|
? {
|
||||||
|
top: systemInfo.safeArea.top,
|
||||||
|
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||||
|
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||||
|
left: systemInfo.safeArea.left,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
systemInfo = uni.getSystemInfoSync()
|
||||||
|
safeAreaInsets = systemInfo.safeAreaInsets
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// 页面数据
|
||||||
|
const voicePrintList = ref<VoicePrint[]>([])
|
||||||
|
const chatHistoryList = ref<ChatHistory[]>([])
|
||||||
|
const chatHistoryActions = ref<any[]>([])
|
||||||
|
const swipeStates = ref<Record<string, 'left' | 'close' | 'right'>>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 使用传入的智能体ID
|
||||||
|
const currentAgentId = computed(() => {
|
||||||
|
return props.agentId
|
||||||
|
})
|
||||||
|
|
||||||
|
// 智能体选择相关功能已移除
|
||||||
|
|
||||||
|
// 弹窗相关
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const showEditDialog = ref(false)
|
||||||
|
const showChatHistoryDialog = ref(false)
|
||||||
|
const addForm = ref<CreateSpeakerData>({
|
||||||
|
agentId: '',
|
||||||
|
audioId: '',
|
||||||
|
sourceName: '',
|
||||||
|
introduce: '',
|
||||||
|
})
|
||||||
|
const editForm = ref<VoicePrint>({
|
||||||
|
id: '',
|
||||||
|
audioId: '',
|
||||||
|
sourceName: '',
|
||||||
|
introduce: '',
|
||||||
|
createDate: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取声纹列表
|
||||||
|
async function loadVoicePrintList() {
|
||||||
|
try {
|
||||||
|
console.log('获取声纹列表')
|
||||||
|
|
||||||
|
// 检查是否有当前选中的智能体
|
||||||
|
if (!currentAgentId.value) {
|
||||||
|
console.warn('没有选中的智能体')
|
||||||
|
voicePrintList.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
const data = await getVoicePrintList(currentAgentId.value)
|
||||||
|
|
||||||
|
// 初始化滑动状态
|
||||||
|
const list = data || []
|
||||||
|
list.forEach((item) => {
|
||||||
|
if (!swipeStates.value[item.id]) {
|
||||||
|
swipeStates.value[item.id] = 'close'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
voicePrintList.value = list
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取声纹列表失败:', error)
|
||||||
|
voicePrintList.value = []
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的刷新方法
|
||||||
|
async function refresh() {
|
||||||
|
await loadVoicePrintList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取语音对话记录
|
||||||
|
async function loadChatHistory() {
|
||||||
|
try {
|
||||||
|
if (!currentAgentId.value) {
|
||||||
|
toast.error('请先选择智能体')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getChatHistory(currentAgentId.value)
|
||||||
|
chatHistoryList.value = data || []
|
||||||
|
// 转换为ActionSheet格式
|
||||||
|
chatHistoryActions.value = chatHistoryList.value.map((item, index) => ({
|
||||||
|
name: item.content,
|
||||||
|
audioId: item.audioId,
|
||||||
|
index,
|
||||||
|
}))
|
||||||
|
showChatHistoryDialog.value = true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取对话记录失败:', error)
|
||||||
|
toast.error('获取对话记录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开添加弹窗
|
||||||
|
function openAddDialog() {
|
||||||
|
if (!currentAgentId.value) {
|
||||||
|
toast.error('请先选择智能体')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addForm.value = {
|
||||||
|
agentId: currentAgentId.value,
|
||||||
|
audioId: '',
|
||||||
|
sourceName: '',
|
||||||
|
introduce: '',
|
||||||
|
}
|
||||||
|
showAddDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开编辑弹窗
|
||||||
|
function openEditDialog(item: VoicePrint) {
|
||||||
|
editForm.value = { ...item }
|
||||||
|
showEditDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取选中音频的显示内容
|
||||||
|
function getSelectedAudioContent(audioId: string) {
|
||||||
|
if (!audioId)
|
||||||
|
return '点击选择声纹向量'
|
||||||
|
const chatItem = chatHistoryList.value.find(item => item.audioId === audioId)
|
||||||
|
return chatItem ? chatItem.content : `已选择: ${audioId.substring(0, 8)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择声纹向量
|
||||||
|
function selectAudioId({ item }: { item: any }) {
|
||||||
|
if (showAddDialog.value) {
|
||||||
|
addForm.value.audioId = item.audioId
|
||||||
|
}
|
||||||
|
else if (showEditDialog.value) {
|
||||||
|
editForm.value.audioId = item.audioId
|
||||||
|
}
|
||||||
|
showChatHistoryDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交添加说话人
|
||||||
|
async function submitAdd() {
|
||||||
|
if (!addForm.value.sourceName.trim()) {
|
||||||
|
toast.error('请输入姓名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!addForm.value.audioId) {
|
||||||
|
toast.error('请选择声纹向量')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createVoicePrint(addForm.value)
|
||||||
|
toast.success('添加成功')
|
||||||
|
showAddDialog.value = false
|
||||||
|
await loadVoicePrintList()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('添加说话人失败:', error)
|
||||||
|
toast.error('添加说话人失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交编辑说话人
|
||||||
|
async function submitEdit() {
|
||||||
|
if (!editForm.value.sourceName.trim()) {
|
||||||
|
toast.error('请输入姓名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editForm.value.audioId) {
|
||||||
|
toast.error('请选择声纹向量')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateVoicePrint({
|
||||||
|
id: editForm.value.id,
|
||||||
|
audioId: editForm.value.audioId,
|
||||||
|
sourceName: editForm.value.sourceName,
|
||||||
|
introduce: editForm.value.introduce,
|
||||||
|
createDate: editForm.value.createDate,
|
||||||
|
})
|
||||||
|
toast.success('编辑成功')
|
||||||
|
showEditDialog.value = false
|
||||||
|
await loadVoicePrintList()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('编辑说话人失败:', error)
|
||||||
|
toast.error('编辑说话人失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理编辑操作
|
||||||
|
function handleEdit(item: VoicePrint) {
|
||||||
|
openEditDialog(item)
|
||||||
|
swipeStates.value[item.id] = 'close'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除声纹
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
message.confirm({
|
||||||
|
msg: '确定要删除这个说话人吗?',
|
||||||
|
title: '确认删除',
|
||||||
|
}).then(async () => {
|
||||||
|
await deleteVoicePrint(id)
|
||||||
|
toast.success('删除成功')
|
||||||
|
await loadVoicePrintList()
|
||||||
|
}).catch(() => {
|
||||||
|
console.log('点击了取消按钮')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 智能体已简化为默认
|
||||||
|
|
||||||
|
loadVoicePrintList()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
refresh,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="voiceprint-container" style="background: #f5f7fb; min-height: 100%;">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading && voicePrintList.length === 0" class="loading-container">
|
||||||
|
<wd-loading color="#336cff" />
|
||||||
|
<text class="loading-text">
|
||||||
|
加载中...
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 声纹列表 -->
|
||||||
|
<view v-else-if="voicePrintList.length > 0" class="voiceprint-list">
|
||||||
|
<!-- 声纹卡片列表 -->
|
||||||
|
<view class="box-border flex flex-col gap-[24rpx] p-[20rpx]">
|
||||||
|
<view v-for="item in voicePrintList" :key="item.id">
|
||||||
|
<wd-swipe-action
|
||||||
|
:model-value="swipeStates[item.id] || 'close'"
|
||||||
|
@update:model-value="swipeStates[item.id] = $event"
|
||||||
|
>
|
||||||
|
<view class="bg-[#fbfbfb] p-[32rpx]" @click="handleEdit(item)">
|
||||||
|
<view>
|
||||||
|
<text class="mb-[12rpx] block text-[32rpx] text-[#232338] font-semibold">
|
||||||
|
{{ item.sourceName }}
|
||||||
|
</text>
|
||||||
|
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||||
|
{{ item.introduce || '暂无描述' }}
|
||||||
|
</text>
|
||||||
|
<text class="block text-[24rpx] text-[#9d9ea3]">
|
||||||
|
{{ item.createDate }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<view class="h-full flex">
|
||||||
|
<view
|
||||||
|
class="h-full min-w-[120rpx] flex items-center justify-center bg-[#ff4d4f] p-x-[32rpx] text-[28rpx] text-white font-medium"
|
||||||
|
@click="handleDelete(item.id)"
|
||||||
|
>
|
||||||
|
<wd-icon name="delete" />
|
||||||
|
删除
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</wd-swipe-action>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else-if="!loading" class="empty-container">
|
||||||
|
<view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center">
|
||||||
|
<wd-icon name="voice" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" />
|
||||||
|
<text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium">
|
||||||
|
暂无声纹数据
|
||||||
|
</text>
|
||||||
|
<text class="text-[26rpx] text-[#999999] leading-[1.5]">
|
||||||
|
点击右下角 + 号添加您的第一个说话人
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 浮动操作按钮 -->
|
||||||
|
<wd-fab type="primary" size="small" :draggable="true" :expandable="false" @click="openAddDialog">
|
||||||
|
<wd-icon name="add" />
|
||||||
|
</wd-fab>
|
||||||
|
|
||||||
|
<!-- MessageBox 组件 -->
|
||||||
|
<wd-message-box />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 添加说话人弹窗 -->
|
||||||
|
<wd-popup
|
||||||
|
v-model="showAddDialog" position="center" custom-style="width: 90%; max-width: 400px; border-radius: 16px;"
|
||||||
|
safe-area-inset-bottom
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]">
|
||||||
|
<text class="w-full text-center text-[32rpx] text-[#232338] font-semibold">
|
||||||
|
添加说话人
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="p-[32rpx]">
|
||||||
|
<!-- 声纹向量选择 -->
|
||||||
|
<view class="mb-[32rpx]">
|
||||||
|
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
* 声纹向量
|
||||||
|
</text>
|
||||||
|
<view
|
||||||
|
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
|
||||||
|
@click="loadChatHistory"
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
|
||||||
|
:class="{ 'text-[#9d9ea3]': !addForm.audioId }"
|
||||||
|
>
|
||||||
|
{{ getSelectedAudioContent(addForm.audioId) }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-down" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 姓名 -->
|
||||||
|
<view class="mb-[32rpx]">
|
||||||
|
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
* 姓名
|
||||||
|
</text>
|
||||||
|
<input
|
||||||
|
v-model="addForm.sourceName"
|
||||||
|
class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
type="text" placeholder="请输入姓名"
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<view>
|
||||||
|
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
* 描述
|
||||||
|
</text>
|
||||||
|
<textarea
|
||||||
|
v-model="addForm.introduce" :maxlength="100" placeholder="请输入描述"
|
||||||
|
class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
/>
|
||||||
|
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
|
||||||
|
{{ (addForm.introduce || '').length }}/100
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
|
||||||
|
<wd-button type="info" custom-class="flex-1" @click="showAddDialog = false">
|
||||||
|
取消
|
||||||
|
</wd-button>
|
||||||
|
<wd-button type="primary" custom-class="flex-1" @click="submitAdd">
|
||||||
|
保存
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</wd-popup>
|
||||||
|
|
||||||
|
<!-- 编辑说话人弹窗 -->
|
||||||
|
<wd-popup
|
||||||
|
v-model="showEditDialog" position="center" custom-style="width: 90%; max-width: 400px; border-radius: 16px;"
|
||||||
|
safe-area-inset-bottom
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]">
|
||||||
|
<text class="w-full text-center text-[32rpx] text-[#232338] font-semibold">
|
||||||
|
编辑说话人
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="p-[32rpx]">
|
||||||
|
<!-- 声纹向量选择 -->
|
||||||
|
<view class="mb-[32rpx]">
|
||||||
|
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
* 声纹向量
|
||||||
|
</text>
|
||||||
|
<view
|
||||||
|
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
|
||||||
|
@click="loadChatHistory"
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
|
||||||
|
:class="{ 'text-[#9d9ea3]': !editForm.audioId }"
|
||||||
|
>
|
||||||
|
{{ getSelectedAudioContent(editForm.audioId) }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-down" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 姓名 -->
|
||||||
|
<view class="mb-[32rpx]">
|
||||||
|
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
* 姓名
|
||||||
|
</text>
|
||||||
|
<input
|
||||||
|
v-model="editForm.sourceName"
|
||||||
|
class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
type="text" placeholder="请输入姓名"
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<view>
|
||||||
|
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||||
|
* 描述
|
||||||
|
</text>
|
||||||
|
<textarea
|
||||||
|
v-model="editForm.introduce" :maxlength="100" placeholder="请输入描述"
|
||||||
|
class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||||
|
/>
|
||||||
|
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
|
||||||
|
{{ (editForm.introduce || '').length }}/100
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
|
||||||
|
<wd-button type="info" custom-class="flex-1" @click="showEditDialog = false">
|
||||||
|
取消
|
||||||
|
</wd-button>
|
||||||
|
<wd-button type="primary" custom-class="flex-1" @click="submitEdit">
|
||||||
|
保存
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</wd-popup>
|
||||||
|
|
||||||
|
<!-- 语音对话记录选择动作面板 -->
|
||||||
|
<wd-action-sheet
|
||||||
|
v-model="showChatHistoryDialog" :actions="chatHistoryActions" title="选择声纹向量"
|
||||||
|
@select="selectAudioId"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.voiceprint-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-swipe-action) {
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1rpx solid #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.flex-1) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||