Compare commits

...
203 Commits
Author SHA1 Message Date
欣南科技andGitHub bffed8bb8a Merge pull request #2141 from xinnan-tech/fix-tool-input-bg
update:修复mcp input样式污染其他input的bug
2025-08-31 00:00:30 +08:00
hrz ff3c0ab7ca update:修复mcp input样式污染其他input的bug 2025-08-30 23:58:11 +08:00
欣南科技andGitHub 6d0addf005 Merge pull request #2140 from xinnan-tech/rollback
update:流式优化暂不稳定,先回滚到早期代码
2025-08-30 23:03:18 +08:00
hrz 1520874432 update:升级版本号 2025-08-30 23:02:50 +08:00
hrz eaade698fb update:流式优化暂不稳定,先回滚到早期代码 2025-08-30 22:59:21 +08:00
欣南科技andGitHub b4f4995ff9 Merge pull request #2139 from xinnan-tech/py_wakeup_audio
Py wakeup audio
2025-08-30 18:15:40 +08:00
hrzandGitHub 76a7ac04c9 Merge branch 'main' into py_wakeup_audio 2025-08-30 18:15:09 +08:00
欣南科技andGitHub 3e1765a26d Merge pull request #2138 from xinnan-tech/update-doc
update:更换Demo视频连接
2025-08-29 13:48:42 +08:00
hrz 95b80f6bb1 update:更换Demo视频连接 2025-08-29 13:47:40 +08:00
hrzandGitHub caf5f3f04c Merge pull request #2134 from xinnan-tech/py_test_tts
update:AliAppLLM流式接口改造
2025-08-28 19:19:39 +08:00
Sakura-RanChen 41b8fac3aa update: 增加唤醒时声纹处理 ,1秒内发送至大模型 【需优化唤醒锁机制,中途会遭受打断(偶发),考虑忽略检测】 2025-08-28 17:58:23 +08:00
3030332422 f6f240bc4b update:AliAppLLM流式接口改造 2025-08-28 16:32:00 +08:00
欣南科技andGitHub a79d8d61e3 Merge pull request #2128 from xinnan-tech/ha-timeout
update:优化ha插件超时方法
2025-08-28 00:40:19 +08:00
hrz a5e748f775 update:优化ha插件超时方法 2025-08-28 00:39:43 +08:00
hrzandGitHub 0fc4edc431 Merge pull request #2114 from xinnan-tech/paddlespeech
update:更新paddlespeechtts供应器
2025-08-27 23:22:04 +08:00
gitjianyuandGitHub 3378d2cd8f Merge pull request #2102 from Minamiyama/refactor/extract-streaming-context
refactor(audio): 将流式音频处理逻辑提取为独立类
2025-08-27 10:33:07 +08:00
hrzandGitHub e004a85f53 Merge pull request #2115 from xinnan-tech/py_fix_mcp
fix:修复“服务端mcp 未添加到 function 列表中”问题
2025-08-27 10:16:59 +08:00
Sakura-RanChen 91cd843cfe update: 恢复唤醒播放机制,待优化和改造(目前只有linkerai拥有正常保存唤醒音频to_tts) 2025-08-27 10:04:27 +08:00
FAN-yeB 880b669877 还原配置文件 2025-08-26 11:49:08 +08:00
FAN-yeB e53ba28c65 更新paddlespeechtts供应器 2025-08-26 11:47:52 +08:00
3030332422 bdb6d3c093 fix:修复“服务端mcp 未添加到 function 列表中” 2025-08-26 11:45:52 +08:00
FAN-yeB 9af77d0f47 更新paddlespeechtts供应器 2025-08-26 11:24:58 +08:00
FAN-yeB 15291bfcf2 更新paddlespeechtts供应器 2025-08-26 11:02:57 +08:00
FAN-yeB ae228f3b86 update:更新paddlespeechtts供应器 2025-08-26 10:33:38 +08:00
欣南科技andGitHub b8803f92e7 Merge pull request #2111 from xinnan-tech/duplicate-model
Duplicate model
2025-08-25 22:28:36 +08:00
hrzandGitHub 1515784700 Merge pull request #2104 from Minamiyama/feat/model-management/duplicate-model
feat(模型管理): 添加模型副本创建功能
2025-08-25 22:23:34 +08:00
hrzandGitHub 06e2909fda Merge pull request #2110 from myifeng/main
移除未使用的变量
2025-08-25 22:15:06 +08:00
hrzandGitHub 9012deeae7 Update connection.py 2025-08-25 22:14:41 +08:00
Chingfeng Li 31657e1dc0 移除未使用的变量 2025-08-25 18:26:55 +08:00
欣南科技andGitHub 5a08a41cc8 Merge pull request #2105 from xinnan-tech/update_doc
update:增加移动端贡献者信息
2025-08-25 09:22:20 +08:00
hrz 3e48d7e331 update:增加移动端贡献者信息 2025-08-25 09:21:48 +08:00
Minamiyama 5993470d3f feat(模型管理): 添加模型副本创建功能
在模型编辑对话框中增加副本创建功能,当选择创建副本时自动在原模型名称和代码后添加'_副本'后缀,并通过新增API接口保存为新模型
2025-08-24 23:40:54 +08:00
Minamiyama 18acec1a81 refactor(audio): 将流式音频处理逻辑提取为独立类
将原本内联在test_page.html中的流式音频处理逻辑重构为独立的StreamingContext类,提高代码可维护性和复用性
2025-08-24 11:31:30 +08:00
欣南科技andGitHub 701936326d Merge pull request #2100 from xinnan-tech/py_fix_audio
update:显示天气接口错误信息
2025-08-23 23:25:52 +08:00
hrz 5b7d613a35 update:显示天气接口错误信息 2025-08-23 23:25:19 +08:00
hrzandGitHub 0ce55ff7a4 Merge pull request #2099 from Minamiyama/refactor/audio-context-singleton
refactor(音频处理): 提取音频上下文创建逻辑到独立函数
2025-08-23 22:25:11 +08:00
欣南科技andGitHub 595edb6f80 Merge pull request #2094 from xinnan-tech/py_fix_audio
fix: 优化流控音频播放 wechat聊天模式错误STT消息发送
2025-08-23 22:23:59 +08:00
Minamiyama d4b75c5d99 refactor(音频处理): 提取音频上下文创建逻辑到独立函数
将重复的音频上下文创建逻辑提取到独立的getAudioContextInstance函数中,减少代码重复并统一音频上下文配置
2025-08-23 07:47:40 +08:00
Sakura-RanChen 1988bced60 fix: 优化流控音频播放 wechat聊天模式错误STT消息发送 2025-08-22 17:10:36 +08:00
hrzandGitHub 60f8dbf418 Merge pull request #2086 from xinnan-tech/ASRPerformanceTester
update:更新流式ASR首词等待时间测试工具
2025-08-21 11:58:31 +08:00
FAN-yeB a708382cfd update:更新流式ASR首词等待时间测试工具 2025-08-21 11:54:06 +08:00
hrzandGitHub 1d95e1420a Merge pull request #2080 from VanillaNahida/main
fix: 修复脚本获取不到ws和视觉分析接口地址的bug,更新README
2025-08-20 22:50:18 +08:00
hrzandGitHub e1bcff82e2 Merge pull request #2084 from xinnan-tech/py_fix_audio
Py fix audio
2025-08-20 16:28:54 +08:00
hrzandGitHub 216f1b701f Merge pull request #2082 from xinnan-tech/fix_mobile
fix:修复mcp地址显示慢,增加服务端地址校验
2025-08-20 16:27:06 +08:00
Sakura-RanChen 0ea18d87a3 fix: asr识别缺字现象,去除无用的变量 2025-08-20 16:22:32 +08:00
hrzandGitHub 6a79d57a93 Update docker-setup.sh 2025-08-20 14:42:46 +08:00
hrzandGitHub 31f596f96c Update docker-setup.sh
全模块部署,OTA地址是在8002端口
2025-08-20 14:41:22 +08:00
hrzandGitHub 7f99895116 Update Deployment_all.md 2025-08-20 14:38:51 +08:00
rainv123 789d756ce1 fix:修复mcp地址显示慢,增加服务端地址校验 2025-08-20 14:36:42 +08:00
hrzandGitHub 570fb29d3c Merge pull request #2081 from xinnan-tech/performance_tester
update:更新流式TTS首词耗时测试工具
2025-08-20 14:36:36 +08:00
FAN-yeB d7dc636d3f update:更新流式TTS测试工具 2025-08-20 14:30:47 +08:00
香草味的纳西妲喵andGitHub 67d3a8f94f Update Deployment_all.md 2025-08-20 14:07:46 +08:00
VanillaNahida 7d7b8ddfb1 fix: 修复脚本获取不到ws和视觉分析接口地址的bug
docs: 更新README说明
2025-08-20 14:00:53 +08:00
Sakura-RanChen 74826c1c59 fix 2025-08-20 09:30:43 +08:00
hrzandGitHub d138e1dcef Merge pull request #2073 from xinnan-tech/web_fix_DeviceManagement
fix:修复设备管理页面全选功能失效问题
2025-08-19 17:24:43 +08:00
3030332422 372dc8ecb0 fix:修复设备管理页面全选功能失效问题 2025-08-19 14:45:14 +08:00
欣南科技andGitHub e8bfe9850e Merge pull request #2071 from xinnan-tech/fix-ota
fix: #2067 #2070 OTA和流控的音频bug
2025-08-19 00:20:46 +08:00
hrz ad50fbe3c9 fix: #2067 #2070 OTA和流控的音频bug 2025-08-19 00:19:41 +08:00
hrzandGitHub 61636dd085 Merge pull request #2068 from rainv123/fix-mobile-server
update:修复切换服务端地址无法生效,登陆界面右上角添加可跳转至设置页的按钮
2025-08-18 16:38:39 +08:00
rainv123andGitHub 5ea669be5a Update 删除无用代码行 2025-08-18 15:56:31 +08:00
rainv123 5260b62287 update:修复切换服务端地址无法生效,登陆界面右上角添加可跳转至设置页的按钮 2025-08-18 14:54:54 +08:00
欣南科技andGitHub 984a84edc1 Merge pull request #2061 from xinnan-tech/llm-tester
update:纠正教程错误描述
2025-08-17 14:15:31 +08:00
hrz 32548878e4 update:纠正教程错误描述 2025-08-17 14:14:25 +08:00
欣南科技andGitHub 093db56c98 Merge pull request #2058 from xinnan-tech/llm-tester
Llm tester
2025-08-17 11:13:47 +08:00
hrz a17f8518d6 update:优化推荐配置 2025-08-17 11:13:04 +08:00
hrz e9e9c5355d update:增加性能测试报告链接 2025-08-17 10:44:13 +08:00
hrz 61ee21d627 update:给LLM性能测试工具增加系统提示词,更符合本项目的实际效果 2025-08-17 09:35:06 +08:00
欣南科技andGitHub a86707f1ff Merge pull request #2056 from xinnan-tech/build-apk
update:回滚取消自动编译apk
2025-08-16 19:58:06 +08:00
hrz be42d80061 update:回滚取消自动编译apk 2025-08-16 19:57:34 +08:00
欣南科技andGitHub 16da6f9696 Merge pull request #2054 from xinnan-tech/dev-ops
update:优化自动部署脚本
2025-08-16 16:36:26 +08:00
hrz 427b0aa197 update:优化自动部署脚本 2025-08-16 16:35:26 +08:00
欣南科技andGitHub a2432d45cc Merge pull request #2052 from xinnan-tech/hot-fix
Hot fix
2025-08-16 15:07:53 +08:00
hrz f62b5d4f22 update:优化常见问题链接 2025-08-16 15:07:13 +08:00
hrz 001225c27b update:优化常见问题 2025-08-16 15:03:02 +08:00
欣南科技andGitHub 3e68f188fa Merge pull request #2051 from xinnan-tech/hot-fix
update:引导测试页面启动
2025-08-16 14:37:04 +08:00
hrz 2d75eb76f1 update:引导测试页面启动 2025-08-16 14:36:23 +08:00
欣南科技andGitHub bcc3ebb6bb Merge pull request #2050 from xinnan-tech/hot-fix
update:增加移动版智控台的说明
2025-08-16 11:39:04 +08:00
hrz 52c4e65686 update:增加移动版智控台的说明 2025-08-16 11:37:23 +08:00
欣南科技andGitHub 44a5afb8a2 Merge pull request #2049 from xinnan-tech/hot-fix
update:优化常见问题,梳理链接教程
2025-08-16 10:34:26 +08:00
hrz 3057d35111 update:优化常见问题,梳理链接教程 2025-08-16 10:33:28 +08:00
欣南科技andGitHub bdf99bc554 Merge pull request #2048 from xinnan-tech/hot-fix
update:优化测试工具目录结构
2025-08-16 10:10:17 +08:00
hrz e70415690c update:优化测试工具目录结构 2025-08-16 10:09:36 +08:00
hrzandGitHub 9b5e4eb9b7 Merge pull request #2039 from xinnan-tech/py_voiceprint_add_health
update:优化声纹识别,添加服务器健康检查机制避免运行时超时等待
2025-08-15 18:27:44 +08:00
hrzandGitHub e2324d3c37 Merge pull request #2044 from xinnan-tech/py_test_audio
Py test audio
2025-08-15 17:59:21 +08:00
hrzandGitHub c395c31e46 Merge pull request #2046 from xinnan-tech/xiaozhi-sercer-testPage
修复测试页面 打断对话多次可能导致无法发出声音的bug
2025-08-15 17:58:48 +08:00
JianYu Zheng 94662e17ba 优化:设置播放最短是0.3秒 2025-08-15 17:33:02 +08:00
3030332422 0bfa5cf723 update:feat: 为声纹识别添加缓存机制 2025-08-15 16:58:56 +08:00
Sakura-RanChen d2f29f335e update: 音频流式优化 2025-08-15 16:33:46 +08:00
JianYu Zheng 4423cb85c2 优化:优化播放逻辑去除多个方法共享属性导致的逻辑错误 2025-08-15 11:26:14 +08:00
CGDandGitHub 46fb997b52 Merge pull request #2035 from xinnan-tech/performance_text
update:更新各组件性能测试功能
2025-08-14 17:58:37 +08:00
3030332422 dcf8c134a2 update:优化声纹识别,添加服务器健康检查机制避免运行时超时等待 2025-08-14 17:57:46 +08:00
JianYu Zheng 7c0df908e9 优化:添加阻塞队列,优化缓存禁止 2025-08-14 14:44:52 +08:00
FAN-yeB 7bb76d7ced 更新 performance_test_tool.py 2025-08-14 10:39:53 +08:00
FAN-yeB 1cfb8de3a0 up:修改性能测试工具读取配置的目录 2025-08-14 10:31:31 +08:00
FAN-yeB 768d2b82d6 update:更新性能测试工具 2025-08-14 10:01:43 +08:00
rainv123andGitHub 7a4431d80c Merge pull request #2031 from xinnan-tech/update-paddlespeech
Update: paddlespeech配置
2025-08-13 16:02:44 +08:00
FAN-yeB 7e63e1d607 update:更新paddlespeech.py 2025-08-13 16:00:33 +08:00
FAN-yeB bda6c1e3ed update:paddlespeech配置 2025-08-13 15:59:48 +08:00
JianYu Zheng a9d116df2f 优化:拆分test_page.html一部分代码 2025-08-13 14:25:04 +08:00
欣南科技andGitHub e7c6c32d40 Merge pull request #2025 from xinnan-tech/openrz-patch-1
Update build-apk.yml
2025-08-12 13:55:04 +08:00
hrzandGitHub cdfe24fcdb Update build-apk.yml 2025-08-12 13:54:47 +08:00
欣南科技andGitHub a9199171df Merge pull request #2024 from xinnan-tech/openrz-patch-1
update:修改版本号
2025-08-12 13:41:17 +08:00
hrz 76b9542507 update:修改版本号 2025-08-12 13:40:40 +08:00
欣南科技andGitHub d30b6a8dcf Merge pull request #2023 from xinnan-tech/openrz-patch-1
Update build-apk.yml
2025-08-12 13:35:45 +08:00
hrzandGitHub 76b5ad1c99 Update build-apk.yml 2025-08-12 13:35:26 +08:00
欣南科技andGitHub 7840e83ada Merge pull request #2022 from xinnan-tech/build-apk
update:优化编译
2025-08-12 13:33:17 +08:00
hrz c4ac9da3e5 update:优化编译 2025-08-12 13:32:27 +08:00
Sakura-RanChenandGitHub 311e5d5cfd Merge pull request #1988 from myifeng/async-handler
调整OPUS函数式处理,不再以数组方式收集再遍历
2025-08-12 10:14:43 +08:00
JianYu Zheng a1f6795d44 优化:删除test_page.html无用的代码 2025-08-12 10:06:41 +08:00
欣南科技andGitHub f8a30ce261 Merge pull request #2021 from xinnan-tech/build-apk
update:测试自动编译apk
2025-08-12 08:52:41 +08:00
hrz ccfd15bda0 update:测试自动编译apk 2025-08-12 08:50:31 +08:00
hrzandGitHub a9610e6ee7 Merge pull request #2016 from xinnan-tech/py_huoshanTTS_AddConfig
update: huoshanTTS add config
2025-08-11 21:46:18 +08:00
hrz 733b36b869 update:补充参数说明 2025-08-11 21:45:32 +08:00
欣南科技andGitHub 70c3f9f929 Merge pull request #2020 from xinnan-tech/hot-fix
fix: #2014  Invalid bound statement (not found)
2025-08-11 21:27:07 +08:00
hrz 210bda15b8 update:更新版本号 2025-08-11 21:24:43 +08:00
hrz 93be1b3443 fix: #2014 Invalid bound statement (not found) 2025-08-11 21:23:00 +08:00
欣南科技andGitHub 533a001aeb Merge pull request #2018 from xinnan-tech/feature/mobile-app
Feature/mobile app
2025-08-11 20:49:04 +08:00
hrz 0b57d78732 update:增加h5的启动方法 2025-08-11 20:47:39 +08:00
hrzandGitHub dce9d1a1e2 Merge pull request #2015 from huangjunsen0406/feature/mobile-app
fix: 修复移动端app json文件丢失
2025-08-11 20:39:35 +08:00
JianYu Zheng 1cff39cec4 优化:拆分test_page.html,把一部分opus和document操作的代码提取出来,减少单个文件大小 2025-08-11 18:04:01 +08:00
JianYu Zheng c7392dcf5a 优化:拆分test_page.html,把一部分document操作的代码提取出来,减少单个文件大小,且把logger.js和xiaoZhiConnect.js也提取出来 2025-08-11 17:44:01 +08:00
Sakura-RanChen 3eb39ebef2 update: huoshanTTS add config 2025-08-11 17:38:17 +08:00
Junsen 041d8d5643 feat: 更新README文档,删除不再使用的请求配置文件,添加TypeScript配置文件,优化设置页面逻辑 2025-08-11 17:26:52 +08:00
JianYu Zheng 1c19046442 优化:拆分test_page.html,把一部分小智连接的代码提取出来,减少单个文件大小 2025-08-11 17:14:00 +08:00
Junsen 3d696aaa64 chore: 更新.gitignore以允许跟踪manager-mobile中的env和json文件;修改LICENSE版权信息;添加环境配置文件;新增manifest.json和pages.json以支持移动应用结构 2025-08-11 16:39:53 +08:00
JianYu Zheng 76516336af 优化:拆分test_page.html,把日志函数提取出来,减少单个文件大小 2025-08-11 16:20:46 +08:00
Junsen fc18677409 chore(gitignore): 允许跟踪 package.json 文件 2025-08-11 16:09:13 +08:00
Junsen 029d3730a8 chore(gitignore): allow tracking of package.json; add manager-mobile/package.json 2025-08-11 16:06:54 +08:00
hrz 8509e62114 update:调整文件夹 2025-08-11 15:48:28 +08:00
hrzandGitHub 6e8f9ca22e Merge pull request #2005 from huangjunsen0406/feature/mobile-app
feat: add complete mobile app project initial commit
2025-08-11 15:42:08 +08:00
Junsen dbc1a515d2 feat: 添加自定义标签组件,更新聊天记录和设备管理页面,优化请求配置 2025-08-11 15:14:23 +08:00
hrzandJunsen 70221e7f62 fix:保存记忆时出错 2025-08-11 15:14:23 +08:00
Sakura-RanChenandJunsen 28f921c38d update: 文档示例补充 2025-08-11 15:14:23 +08:00
hrzandJunsen 30a773601f 升级版本 2025-08-11 15:14:23 +08:00
Sakura-RanChenandJunsen 6cddd5c704 update: 补充安装文档 2025-08-11 15:14:23 +08:00
JianYu Zheng 709bc12b1c 优化:拆分test_page.html,css提取出来,减少单个文件大小 2025-08-11 14:24:06 +08:00
欣南科技andGitHub 171892a894 Merge pull request #2012 from xinnan-tech/py_fix_memory
fix:保存记忆时出错
2025-08-11 14:16:12 +08:00
hrz 535ab315d9 fix:保存记忆时出错 2025-08-11 14:15:34 +08:00
hrzandGitHub de28247b7e Merge pull request #2009 from xinnan-tech/py_IndexStreamTTS
update: 文档示例补充
2025-08-10 17:23:46 +08:00
Sakura-RanChen 6186eb753a update: 文档示例补充 2025-08-09 14:59:02 +08:00
欣南科技andGitHub a18bc85a9b Merge pull request #2008 from xinnan-tech/openrz-patch-1
升级版本
2025-08-09 11:44:03 +08:00
hrzandGitHub 806e4c398d 升级版本 2025-08-09 11:43:33 +08:00
hrzandGitHub a4f1abfa42 Merge pull request #2007 from xinnan-tech/py_IndexStreamTTS
update: 补充安装文档
2025-08-08 22:44:35 +08:00
Sakura-RanChen f2efd9718e update: 补充安装文档 2025-08-08 18:09:56 +08:00
Junsen 031c56e30b feat: 将配置中的名称更改为动态获取的环境变量VITE_APP_TITLE 2025-08-08 17:35:35 +08:00
Junsen 994715d9eb style: 优化移动端页面样式,调整组件布局,增强用户体验 2025-08-08 17:35:35 +08:00
Junsen 90f7af7afa feat: 初始化移动端项目 2025-08-08 17:35:35 +08:00
欣南科技andGitHub 2f3c466833 Merge pull request #2004 from xinnan-tech/py_IndexStreamTTS
Py index stream tts
2025-08-08 17:33:14 +08:00
hrz d742bca205 update:添加indextts 流式语音合成 2025-08-08 17:32:41 +08:00
Sakura-RanChen 8e51d52155 update: 增加单模块IndexTTS 2025-08-08 16:55:17 +08:00
hrzandGitHub a1c4f72470 Merge pull request #2002 from xinnan-tech/manager-api-springdoc
修复api接口文档页面,接口文档和点击按钮的说明不一致,串标题的bug
2025-08-08 14:46:30 +08:00
欣南科技andGitHub 2c11bba25e Merge pull request #2003 from xinnan-tech/update-doc
update:添加新场景
2025-08-08 13:08:26 +08:00
hrz 42a5b4fae7 update:添加新场景 2025-08-08 13:07:21 +08:00
JianYu Zheng a61aedc906 修复api接口文档页面,接口文档和点击按钮的说明不一致,串标题的bug
--pom.xml
1.升级了springdoc 2.8.8, knife4j4.6 与 doc2.7.7版本存在兼容问题,按照官方建议升级到2.8.8 解决了此bug
2.升级了 commons-lang3 3.18.0,springdoc 2.8.8 依赖commons-lang低版本存在CVE-2025-48924漏洞,升级版本解决
2025-08-08 11:55:08 +08:00
FAN-yeBandGitHub 7b72d517ab Merge pull request #1997 from xinnan-tech/rainv123-patch-1
feat:paddlespeech tts服务实现
2025-08-08 11:08:22 +08:00
FAN-yeB 1c43295fbc Merge branch 'rainv123-patch-1' of https://github.com/xinnan-tech/xiaozhi-esp32-server into rainv123-patch-1 2025-08-08 11:05:39 +08:00
FAN-yeB 747c432ea3 update:paddlespeech tts服务配置教程文档 2025-08-08 11:00:31 +08:00
rainv123andGitHub 235bdba42a paddlespeech-deploy.md
PaddleSpeechTTS服务集成xiaozhi服务
2025-08-08 10:59:07 +08:00
hrzandGitHub b3f603732e Merge pull request #1915 from YourKnight1/main
调整PaddleSpeechTTS流式传输
2025-08-07 23:42:16 +08:00
rainv123andGitHub 6091eae7fc paddlespeech-deploy.md
本地部署xiaozhi调用paddlespeechTTS服务
2025-08-07 17:07:52 +08:00
rainv123andGitHub ef36ad1ec1 paddlespeech-deploy.md 2025-08-07 16:50:10 +08:00
rainv123andGitHub f832611d41 Create paddlespeech-deploy.md
PaddleSpeechTTS流式传输集成到xiaozhi服务
2025-08-07 16:19:06 +08:00
Chingfeng Li 4d50eeb830 修正self错误 2025-08-06 14:53:59 +08:00
Chingfeng Li abf1fa0ef4 调整子类 2025-08-06 11:34:57 +08:00
Chingfeng Li 903c0e51ee 修正剩余文本 2025-08-06 10:13:42 +08:00
Chingfeng Li ce6a711c8e 调整流控参数优化 2025-08-06 10:09:28 +08:00
Chingfeng Li 095fe72db0 待完善流控方式 2025-08-05 18:35:37 +08:00
Chingfeng Li 1b6cd01eaf 增加流式处理方法 2025-08-05 14:53:52 +08:00
Chingfeng Li ac1e19f621 调整流式处理opus 2025-08-05 14:10:55 +08:00
Chingfeng Li faf2890695 增加流式处理 2025-08-04 17:04:46 +08:00
Chingfeng Li 75f0773247 增加流式处理Opus 2025-08-04 14:00:41 +08:00
Chingfeng Li 8b81b918cc 移除未使用的total_duration返回值 2025-08-04 13:49:12 +08:00
欣南科技andGitHub 5fe91a80fa Merge pull request #1976 from xinnan-tech/docker-install
Docker install
2025-08-03 16:20:49 +08:00
hrz 6f3dee74be update:优化自动docker全模块懒人脚本说明 2025-08-03 16:09:32 +08:00
hrzandGitHub 9391e8cc1a Merge pull request #1949 from VanillaNahida/main
refact: 重写了Docker全量部署的脚本的实现,支持一键安装一键配置一键更新镜像
2025-08-02 08:56:19 +08:00
hrzandGitHub 8aa5537029 Merge pull request #1963 from myifeng/fix-tts-message
移除无用的TTS消息
2025-08-02 08:55:12 +08:00
Chingfeng Li f40c3ec0f6 恢复sentence_start判断 2025-07-31 16:59:52 +08:00
hrzandGitHub 408143e55e Merge pull request #1962 from zebbingo/main
fix: bug state.type -> state['type']
2025-07-31 16:30:13 +08:00
Chingfeng Li 71eef4693d 仅在首句发送TTS消息 2025-07-31 14:15:39 +08:00
Han WuandGitHub 0b7814882d Merge pull request #1 from zebbingo/codex/find-and-fix-important-codebase-bug
Fix bug in hass_set_state plugin
2025-07-31 13:50:14 +08:00
Han Wu 99cf26ee9e Fix Home Assistant state handler 2025-07-31 13:38:46 +08:00
hrzandGitHub 775f754ff3 Merge pull request #1953 from yaotutu/feature/sherpa-paraformer-support
feat: 添加 Sherpa-ONNX Paraformer 模型支持
2025-07-31 13:35:03 +08:00
hrzandGitHub b6ee2dee68 Delete docs/sherpa-paraformer-guide.md 2025-07-31 13:34:17 +08:00
Chingfeng Li ef25e82544 移除无用的tts消息sentence_end 2025-07-31 13:21:29 +08:00
hrzandGitHub 14b7631dd6 Merge pull request #1958 from pursue-wind/fix_qwen_functioncall_err
fix: 修复qwen模型返回toolcall的arguments为None时导致后续请求400
2025-07-30 20:31:36 +08:00
hrzandGitHub ef0099b3c9 Update connection.py 2025-07-30 20:31:17 +08:00
VanillaNahida 38d60affce Docker安装镜像改为国内镜像源 2025-07-30 18:55:54 +08:00
chan c8a2c9bbd4 fix: 修复qwen模型返回toolcall的arguments为None时导致后续请求400 2025-07-30 18:53:48 +08:00
hrzandGitHub 9c2084b62e Merge pull request #1955 from xinnan-tech/py_fix_time
fix: 时间被固定
2025-07-30 17:33:01 +08:00
yaotutu 9edd083411 feat: 添加 Sherpa-ONNX Paraformer 模型支持
- 在 sherpa_onnx_local.py 中添加 model_type 参数,支持 paraformer 和 sense_voice 两种模型类型
- 在 config.yaml 中添加 SherpaParaformerASR 配置示例
- 添加详细的 Paraformer 使用文档 (docs/sherpa-paraformer-guide.md)
- 保持向后兼容,默认使用 sense_voice 模型

这个改动允许用户在低性能设备(如 RK3566)上使用更轻量的 Paraformer 模型,
相比 SenseVoice (894MB),Paraformer-small (78MB) 可以提供 4-6 倍的识别速度提升。
2025-07-30 10:41:27 +08:00
VanillaNahida 80e8ecc4f4 refact: 重写了Docker全量部署的脚本的实现,支持一键安装一键配置一键更新镜像 2025-07-30 03:00:37 +08:00
Sakura-RanChen e5d3048fb2 fix: 时间被固定 2025-07-29 09:55:32 +08:00
hrzandGitHub 67c4622ca7 Merge pull request #1935 from myifeng/aliyun-internal
如果配置的是ECS内网地址,则使用ws协议,默认是wss协议
2025-07-29 08:59:34 +08:00
欣南科技andGitHub 121f1c4698 Merge pull request #1936 from xinnan-tech/hot-fix
update:优化最后活动时间
2025-07-28 22:40:21 +08:00
hrz ff9fb9eb1b update:优化最后活动时间 2025-07-28 22:38:48 +08:00
Chingfeng Li b07a8796ff 阿里云ECS内网访问协议设置 2025-07-28 17:06:53 +08:00
hrzandGitHub 8ad5ff457e Merge pull request #1932 from xingxinonline/fix/manager-api-voice-print
fix: 增加时间排序,修复增加声纹注册获取对话记录不是最新对话的问题
2025-07-28 15:14:04 +08:00
gitjianyuandGitHub 99f6209c57 Update AgentChatHistoryServiceImpl.java
添加降序排序,
1.改用id的原因:数据形式,id越大的创建时间就越晚,所以使用id的结果和创建时间降序排列结果一样
 2.id作为降序排列的优势,性能高,有主键索引,不用在排序的时候重新进行排除扫描比较
2025-07-28 14:12:42 +08:00
gitjianyuandGitHub 1b3a55b105 Update VoicePrintDialog.vue
删除监听智能体id的,从原先的打开声纹页面获取一次对话,改为每次打开增加修改页面,从新获取一次新的对话信息
2025-07-28 13:58:06 +08:00
醒心onlineandGitHub 8b59a94324 Merge branch 'xinnan-tech:main' into fix/manager-api-voice-print 2025-07-28 12:01:41 +08:00
xingxinonline b17f20eece feat: 在对话框可见性变化时获取最近的50条记录 2025-07-28 11:51:45 +08:00
xingxinonline d9062a0bb0 fix: 增加时间排序,修复增加声纹注册获取对话记录不是最新对话的问题
https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/1922
2025-07-28 11:20:39 +08:00
root 04746961f2 调整PaddleSpeechTTS流式传输 2025-07-25 10:09:04 +08:00
222 changed files with 31354 additions and 2019 deletions
+8
View File
@@ -175,3 +175,11 @@ uploadfile
*.json
.vscode
.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
+16 -11
View File
@@ -38,9 +38,9 @@
</p>
<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>
<img src="./docs/images/hnlg.jpg" alt="华南理工大学" width="50%">
</p>
@@ -57,9 +57,9 @@ By Professor Siyuan Liu Research and Development Group ( South China University
<table>
<tr>
<td>
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
<picture>
<img alt="小智esp32连接自己的后台模型" src="docs/images/demo1.png" />
<img alt="小智医疗救护车场景" src="docs/images/demo1.png" />
</picture>
</a>
</td>
@@ -94,7 +94,7 @@ By Professor Siyuan Liu Research and Development Group ( South China University
</tr>
<tr>
<td>
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
<picture>
<img alt="自定义音色" src="docs/images/demo6.png" />
</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接入点、声纹识别、视觉感知、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
智控台(h5版): https://2662r3426b.vicp.fun/h5/index.html
服务测试工具: https://2662r3426b.vicp.fun/test/
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
@@ -214,21 +216,22 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
| 模块名称 | 入门全免费设置 | 流式配置 |
|:---:|:---:|:---:|
| ASR(语音识别) | FunASR(本地) | 👍FunASRServer 或 👍DoubaoStreamASR |
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍DoubaoLLM(火山doubao-1-5-pro-32k-250115) |
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) |
| 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),可按报告中的测试方法在您的环境中实际测试。
#### 🔧 测试工具
本项目提供以下测试工具,帮助您验证系统和选择合适的模型:
| 工具名称 | 位置 | 使用方法 | 功能说明 |
|:---:|:---|:---:|:---:|
| 音频交互测试工具 | main》xiaozhi-server》test》test_page.html | 使用谷歌浏览器直接打开 | 测试音频播放和接收功能,验证Python端音频处理是否正常 |
| 模型响应测试工具1 | main》xiaozhi-server》performance_tester.py | 执行 `python performance_tester.py` | 测试ASR(语音识别)、LLM(大模型)、TTS(语音合成)三个核心模块的响应速度 |
| 模型响应测试工具2 | main》xiaozhi-server》performance_tester_vllm.py | 执行 `python performance_tester_vllm.py` | 测试VLLM(视觉模型)的响应速度 |
| 模型响应测试工具 | main》xiaozhi-server》performance_tester.py | 执行 `python performance_tester.py` | 测试ASR(语音识别)、LLM(大模型)、VLLM(视觉模型)、TTS(语音合成)三个核心模块的响应速度 |
> 💡 提示:测试模型速度时,只会测试配置了密钥的模型。
@@ -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),欢迎加入!
@@ -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_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_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_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">
+6 -3
View File
@@ -38,9 +38,9 @@ Supports MCP endpoints and voiceprint recognition
</p>
<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>
<img src="./docs/images/hnlg.jpg" alt="South China University of Technology" width="50%">
</p>
@@ -93,7 +93,7 @@ Want to see the usage effects? Click the videos below 🎥
</tr>
<tr>
<td>
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
<picture>
<img alt="Custom voice timbre" src="docs/images/demo6.png" />
</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 (H5): https://2662r3426b.vicp.fun/h5/index.html
Service Test Tool: https://2662r3426b.vicp.fun/test/
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_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_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_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">
+405 -97
View File
@@ -1,105 +1,413 @@
#!/bin/sh
# 脚本作者@VanillaNahida
# 本文件是用于一键自动下载本项目所需文件,自动创建好目录
# 所需条件(否则无法使用):
# 1、请确保你的环境可以正常访问 GitHub 否则无法下载脚本
#
# 检测操作系统类型
case "$(uname -s)" in
Linux*) OS=Linux;;
Darwin*) OS=Mac;;
CYGWIN*) OS=Windows;;
MINGW*) OS=Windows;;
MSYS*) OS=Windows;;
*) OS=UNKNOWN;;
# 暂且只支持X86版本的Ubuntu系统,其他系统未测试
# 定义中断处理函数
handle_interrupt() {
echo ""
echo "安装已被用户中断(Ctrl+C或Esc)"
echo "如需重新安装,请再次运行脚本"
exit 1
}
# 设置信号捕获,处理Ctrl+C
trap handle_interrupt SIGINT
# 处理Esc键
# 保存终端设置
old_stty_settings=$(stty -g)
# 设置终端立即响应,不回显
stty -icanon -echo min 1 time 0
# 后台进程检测Esc键
(while true; do
read -r key
if [[ $key == $'\e' ]]; then
# 检测到Esc键,触发中断处理
kill -SIGINT $$
break
fi
done) &
# 脚本结束时恢复终端设置
trap 'stty "$old_stty_settings"' EXIT
# 打印彩色字符画
echo -e "\e[1;32m" # 设置颜色为亮绿色
cat << "EOF"
脚本作者:@Bilibili 香草味的纳西妲喵
__ __ _ _ _ _ _ _ _ _
\ \ / / (_)| || | | \ | | | | (_) | |
\ \ / /__ _ _ __ _ | || | __ _ | \| | __ _ | |__ _ __| | __ _
\ \/ // _` || '_ \ | || || | / _` | | . ` | / _` || '_ \ | | / _` | / _` |
\ /| (_| || | | || || || || (_| | | |\ || (_| || | | || || (_| || (_| |
\/ \__,_||_| |_||_||_||_| \__,_| |_| \_| \__,_||_| |_||_| \__,_| \__,_|
EOF
echo -e "\e[0m" # 重置颜色
echo -e "\e[1;36m 小智服务端全量部署一键安装脚本 Ver 0.2 2025年8月20日更新 \e[0m\n"
sleep 1
# 检查并安装whiptail
check_whiptail() {
if ! command -v whiptail &> /dev/null; then
echo "正在安装whiptail..."
apt update
apt install -y whiptail
fi
}
check_whiptail
# 创建确认对话框
whiptail --title "安装确认" --yesno "即将安装小智服务端,是否继续?" \
--yes-button "继续" --no-button "退出" 10 50
# 根据用户选择执行操作
case $? in
0)
;;
1)
exit 1
;;
esac
# 设置颜色(Windows CMD 不支持,但不影响使用)
if [ "$OS" = "Windows" ]; then
GREEN=""
RED=""
NC=""
else
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
fi
echo "${GREEN}开始安装小智服务端...${NC}"
# 创建必要的目录
echo "创建目录结构..."
mkdir -p xiaozhi-server/data xiaozhi-server/models/SenseVoiceSmall
cd xiaozhi-server || exit
# 根据操作系统选择下载命令
if [ "$OS" = "Windows" ]; then
DOWNLOAD_CMD="curl -L -o"
if ! command -v curl >/dev/null 2>&1; then
DOWNLOAD_CMD="powershell -Command Invoke-WebRequest -Uri"
DOWNLOAD_CMD_SUFFIX="-OutFile"
fi
else
if command -v curl >/dev/null 2>&1; then
DOWNLOAD_CMD="curl -L -o"
elif command -v wget >/dev/null 2>&1; then
DOWNLOAD_CMD="wget -O"
else
echo "${RED}错误: 需要安装 curl 或 wget${NC}"
exit 1
fi
fi
# 下载语音识别模型
echo "下载语音识别模型..."
if [ "$DOWNLOAD_CMD" = "powershell -Command Invoke-WebRequest -Uri" ]; then
$DOWNLOAD_CMD "https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt" $DOWNLOAD_CMD_SUFFIX "models/SenseVoiceSmall/model.pt"
else
$DOWNLOAD_CMD "models/SenseVoiceSmall/model.pt" "https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt"
fi
if [ $? -ne 0 ]; then
echo "${RED}模型下载失败。请手动从以下地址下载:${NC}"
echo "1. https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt"
echo "2. 百度网盘: https://pan.baidu.com/share/init?surl=QlgM58FHhYv1tFnUT_A8Sg (提取码: qvna)"
echo "下载后请将文件放置在 models/SenseVoiceSmall/model.pt"
fi
# 下载配置文件
echo "下载配置文件..."
if [ "$DOWNLOAD_CMD" = "powershell -Command Invoke-WebRequest -Uri" ]; then
$DOWNLOAD_CMD "https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/main/xiaozhi-server/docker-compose.yml" $DOWNLOAD_CMD_SUFFIX "docker-compose.yml"
$DOWNLOAD_CMD "https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/main/xiaozhi-server/config.yaml" $DOWNLOAD_CMD_SUFFIX "data/.config.yaml"
else
$DOWNLOAD_CMD "docker-compose.yml" "https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/main/xiaozhi-server/docker-compose.yml"
$DOWNLOAD_CMD "data/.config.yaml" "https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/main/xiaozhi-server/config.yaml"
fi
# 检查文件是否存在
echo "检查文件完整性..."
FILES_TO_CHECK="docker-compose.yml data/.config.yaml models/SenseVoiceSmall/model.pt"
ALL_FILES_EXIST=true
for FILE in $FILES_TO_CHECK; do
if [ ! -f "$FILE" ]; then
echo "${RED}错误: $FILE 不存在${NC}"
ALL_FILES_EXIST=false
fi
done
if [ "$ALL_FILES_EXIST" = false ]; then
echo "${RED}某些文件下载失败,请检查上述错误信息并手动下载缺失的文件。${NC}"
# 检查root权限
if [ $EUID -ne 0 ]; then
whiptail --title "权限错误" --msgbox "请使用root权限运行本脚本" 10 50
exit 1
fi
echo "${GREEN}文件下载完成!${NC}"
echo "请编辑 data/.config.yaml 文件配置你的API密钥。"
echo "配置完成后,运行以下命令启动服务:"
echo "${GREEN}docker-compose up -d${NC}"
echo "查看日志请运行:"
echo "${GREEN}docker logs -f xiaozhi-esp32-server${NC}"
# 检查系统版本
if [ -f /etc/os-release ]; then
. /etc/os-release
if [ "$ID" != "debian" ] && [ "$ID" != "ubuntu" ]; then
whiptail --title "系统错误" --msgbox "该脚本只支持Debian/Ubuntu系统执行" 10 60
exit 1
fi
else
whiptail --title "系统错误" --msgbox "无法确定系统版本,该脚本只支持Debian/Ubuntu系统执行" 10 60
exit 1
fi
# 提示用户编辑配置文件
echo "\n${RED}重要提示:${NC}"
echo "1. 请确保编辑 data/.config.yaml 文件,配置必要的API密钥"
echo "2. 特别是 ChatGLM 和 mem0ai 的密钥必须配置"
echo "3. 配置完成后再启动 docker 服务"
# 下载配置文件函数
check_and_download() {
local filepath=$1
local url=$2
if [ ! -f "$filepath" ]; then
if ! curl -fL --progress-bar "$url" -o "$filepath"; then
whiptail --title "错误" --msgbox "${filepath}文件下载失败" 10 50
exit 1
fi
else
echo "${filepath}文件已存在,跳过下载"
fi
}
# 检查是否已安装
check_installed() {
# 检查目录是否存在且非空
if [ -d "/opt/xiaozhi-server/" ] && [ "$(ls -A /opt/xiaozhi-server/)" ]; then
DIR_CHECK=1
else
DIR_CHECK=0
fi
# 检查容器是否存在
if docker inspect xiaozhi-esp32-server > /dev/null 2>&1; then
CONTAINER_CHECK=1
else
CONTAINER_CHECK=0
fi
# 两次检查都通过
if [ $DIR_CHECK -eq 1 ] && [ $CONTAINER_CHECK -eq 1 ]; then
return 0 # 已安装
else
return 1 # 未安装
fi
}
# 更新相关
if check_installed; then
if whiptail --title "已安装检测" --yesno "检测到小智服务端已安装,是否进行升级?" 10 60; then
# 用户选择升级,执行清理操作
echo "开始升级操作..."
# 停止并移除所有docker-compose服务
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml down
# 停止并删除特定容器(考虑容器可能不存在的情况)
containers=(
"xiaozhi-esp32-server"
"xiaozhi-esp32-server-web"
"xiaozhi-esp32-server-db"
"xiaozhi-esp32-server-redis"
)
for container in "${containers[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then
docker stop "$container" >/dev/null 2>&1 && \
docker rm "$container" >/dev/null 2>&1 && \
echo "成功移除容器: $container"
else
echo "容器不存在,跳过: $container"
fi
done
# 删除特定镜像(考虑镜像可能不存在的情况)
images=(
"ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest"
"ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest"
)
for image in "${images[@]}"; do
if docker images --format '{{.Repository}}:{{.Tag}}' | grep -q "^${image}$"; then
docker rmi "$image" >/dev/null 2>&1 && \
echo "成功删除镜像: $image"
else
echo "镜像不存在,跳过: $image"
fi
done
echo "所有清理操作完成"
# 备份原有配置文件
mkdir -p /opt/xiaozhi-server/backup/
if [ -f /opt/xiaozhi-server/data/.config.yaml ]; then
cp /opt/xiaozhi-server/data/.config.yaml /opt/xiaozhi-server/backup/.config.yaml
echo "已备份原有配置文件到 /opt/xiaozhi-server/backup/.config.yaml"
fi
# 下载最新版配置文件
check_and_download "/opt/xiaozhi-server/docker-compose_all.yml" "https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/docker-compose_all.yml"
check_and_download "/opt/xiaozhi-server/data/.config.yaml" "https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/config_from_api.yaml"
# 启动Docker服务
echo "开始启动最新版本服务..."
# 升级完成后标记,跳过后续下载步骤
UPGRADE_COMPLETED=1
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d
else
whiptail --title "跳过升级" --msgbox "已取消升级,将继续使用当前版本。" 10 50
# 跳过升级,继续执行后续安装流程
fi
fi
# 检查curl安装
if ! command -v curl &> /dev/null; then
echo "------------------------------------------------------------"
echo "未检测到curl,正在安装..."
apt update
apt install -y curl
else
echo "------------------------------------------------------------"
echo "curl已安装,跳过安装步骤"
fi
# 检查Docker安装
if ! command -v docker &> /dev/null; then
echo "------------------------------------------------------------"
echo "未检测到Docker,正在安装..."
# 使用国内镜像源替代官方源
DISTRO=$(lsb_release -cs)
MIRROR_URL="https://mirrors.aliyun.com/docker-ce/linux/ubuntu"
GPG_URL="https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg"
# 安装基础依赖
apt update
apt install -y apt-transport-https ca-certificates curl software-properties-common gnupg
# 创建密钥目录并添加国内镜像源密钥
mkdir -p /etc/apt/keyrings
curl -fsSL "$GPG_URL" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
# 添加国内镜像源
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $MIRROR_URL $DISTRO stable" \
> /etc/apt/sources.list.d/docker.list
# 添加备用官方源密钥(避免国内源密钥验证失败)
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 7EA0A9C3F273FCD8 2>/dev/null || \
echo "警告:部分密钥添加失败,继续尝试安装..."
# 安装Docker
apt update
apt install -y docker-ce docker-ce-cli containerd.io
# 启动服务
systemctl start docker
systemctl enable docker
# 检查是否安装成功
if docker --version; then
echo "------------------------------------------------------------"
echo "Docker安装完成!"
else
whiptail --title "错误" --msgbox "Docker安装失败,请检查日志。" 10 50
exit 1
fi
else
echo "Docker已安装,跳过安装步骤"
fi
# Docker镜像源配置
MIRROR_OPTIONS=(
"1" "轩辕镜像 (推荐)"
"2" "腾讯云镜像源"
"3" "中科大镜像源"
"4" "网易163镜像源"
"5" "华为云镜像源"
"6" "阿里云镜像源"
"7" "自定义镜像源"
"8" "跳过配置"
)
MIRROR_CHOICE=$(whiptail --title "选择Docker镜像源" --menu "请选择要使用的Docker镜像源" 20 60 10 \
"${MIRROR_OPTIONS[@]}" 3>&1 1>&2 2>&3) || {
echo "用户取消选择,退出脚本"
exit 1
}
case $MIRROR_CHOICE in
1) MIRROR_URL="https://docker.xuanyuan.me" ;;
2) MIRROR_URL="https://mirror.ccs.tencentyun.com" ;;
3) MIRROR_URL="https://docker.mirrors.ustc.edu.cn" ;;
4) MIRROR_URL="https://hub-mirror.c.163.com" ;;
5) MIRROR_URL="https://05f073ad3c0010ea0f4bc00b7105ec20.mirror.swr.myhuaweicloud.com" ;;
6) MIRROR_URL="https://registry.aliyuncs.com" ;;
7) MIRROR_URL=$(whiptail --title "自定义镜像源" --inputbox "请输入完整的镜像源URL:" 10 60 3>&1 1>&2 2>&3) ;;
8) MIRROR_URL="" ;;
esac
if [ -n "$MIRROR_URL" ]; then
mkdir -p /etc/docker
if [ -f /etc/docker/daemon.json ]; then
cp /etc/docker/daemon.json /etc/docker/daemon.json.bak
fi
cat > /etc/docker/daemon.json <<EOF
{
"dns": ["8.8.8.8", "114.114.114.114"],
"registry-mirrors": ["$MIRROR_URL"]
}
EOF
whiptail --title "配置成功" --msgbox "已成功添加镜像源: $MIRROR_URL\n请按Enter键重启Docker服务并继续..." 12 60
echo "------------------------------------------------------------"
echo "开始重启Docker服务..."
systemctl restart docker.service
fi
# 创建安装目录
echo "------------------------------------------------------------"
echo "开始创建安装目录..."
# 检查并创建数据目录
if [ ! -d /opt/xiaozhi-server/data ]; then
mkdir -p /opt/xiaozhi-server/data
echo "已创建数据目录: /opt/xiaozhi-server/data"
else
echo "目录xiaozhi-server/data已存在,跳过创建"
fi
# 检查并创建模型目录
if [ ! -d /opt/xiaozhi-server/models/SenseVoiceSmall ]; then
mkdir -p /opt/xiaozhi-server/models/SenseVoiceSmall
echo "已创建模型目录: /opt/xiaozhi-server/models/SenseVoiceSmall"
else
echo "目录xiaozhi-server/models/SenseVoiceSmall已存在,跳过创建"
fi
echo "------------------------------------------------------------"
echo "开始下载语音识别模型"
# 下载模型文件
MODEL_PATH="/opt/xiaozhi-server/models/SenseVoiceSmall/model.pt"
if [ ! -f "$MODEL_PATH" ]; then
(
for i in {1..20}; do
echo $((i*5))
sleep 0.5
done
) | whiptail --title "下载中" --gauge "开始下载语音识别模型..." 10 60 0
curl -fL --progress-bar https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt -o "$MODEL_PATH" || {
whiptail --title "错误" --msgbox "model.pt文件下载失败" 10 50
exit 1
}
else
echo "model.pt文件已存在,跳过下载"
fi
# 如果不是升级完成,才执行下载
if [ -z "$UPGRADE_COMPLETED" ]; then
check_and_download "/opt/xiaozhi-server/docker-compose_all.yml" "https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/docker-compose_all.yml"
check_and_download "/opt/xiaozhi-server/data/.config.yaml" "https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/config_from_api.yaml"
fi
# 启动Docker服务
(
echo "------------------------------------------------------------"
echo "正在拉取Docker镜像..."
echo "这可能需要几分钟时间,请耐心等待"
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d
if [ $? -ne 0 ]; then
whiptail --title "错误" --msgbox "Docker服务启动失败,请尝试更换镜像源后重新执行本脚本" 10 60
exit 1
fi
echo "------------------------------------------------------------"
echo "正在检查服务启动状态..."
TIMEOUT=300
START_TIME=$(date +%s)
while true; do
CURRENT_TIME=$(date +%s)
if [ $((CURRENT_TIME - START_TIME)) -gt $TIMEOUT ]; then
whiptail --title "错误" --msgbox "服务启动超时,未在指定时间内找到预期日志内容" 10 60
exit 1
fi
if docker logs xiaozhi-esp32-server-web 2>&1 | grep -q "Started AdminApplication in"; then
break
fi
sleep 1
done
echo "服务端启动成功!正在完成配置..."
echo "正在启动服务..."
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d
echo "服务启动完成!"
)
# 密钥配置
# 获取服务器公网地址
PUBLIC_IP=$(hostname -I | awk '{print $1}')
whiptail --title "配置服务器密钥" --msgbox "请使用浏览器,访问下方链接,打开智控台并注册账号: \n\n内网地址:http://127.0.0.1:8002/\n公网地址:http://$PUBLIC_IP:8002/ (若是云服务器请在服务器安全组放行端口 8000 8001 8002)。\n\n注册的第一个用户即是超级管理员,以后注册的用户都是普通用户。普通用户只能绑定设备和配置智能体; 超级管理员可以进行模型管理、用户管理、参数配置等功能。\n\n注册好后请按Enter键继续" 18 70
SECRET_KEY=$(whiptail --title "配置服务器密钥" --inputbox "请使用超级管理员账号登录智控台\n内网地址:http://127.0.0.1:8002/\n公网地址:http://$PUBLIC_IP:8002/\n在顶部菜单 参数字典 → 参数管理 找到参数编码: server.secret (服务器密钥) \n复制该参数值并输入到下面输入框\n\n请输入密钥(留空则跳过配置):" 15 60 3>&1 1>&2 2>&3)
if [ -n "$SECRET_KEY" ]; then
python3 -c "
import sys, yaml;
config_path = '/opt/xiaozhi-server/data/.config.yaml';
with open(config_path, 'r') as f:
config = yaml.safe_load(f) or {};
config['manager-api'] = {'url': 'http://xiaozhi-esp32-server-web:8002/xiaozhi', 'secret': '$SECRET_KEY'};
with open(config_path, 'w') as f:
yaml.dump(config, f);
"
docker restart xiaozhi-esp32-server
fi
# 获取并显示地址信息
LOCAL_IP=$(hostname -I | awk '{print $1}')
# 修复日志文件获取不到ws的问题,改为硬编码
whiptail --title "安装完成!" --msgbox "\
服务端相关地址如下:\n\
管理后台访问地址: http://$LOCAL_IP:8002\n\
OTA 地址: http://$LOCAL_IP:8002/xiaozhi/ota/\n\
视觉分析接口地址: http://$LOCAL_IP:8003/mcp/vision/explain\n\
WebSocket 地址: ws://$LOCAL_IP:8000/xiaozhi/v1/\n\
\n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70
+34 -57
View File
@@ -8,47 +8,13 @@ docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统
如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html)
如果你已经安装好docker,你可以[1.1使用懒人脚本](#11-懒人脚本)自动帮你下载所需的文件和配置文件,你可以使用docker[1.2手动部署](#12-手动部署)
安装好docker后,进继续
### 1.1 懒人脚本
### 1.1 手动部署
你可以使用以下命令一键下载并执行部署脚本:
请确保你的环境可以正常访问 GitHub 否则无法下载脚本。
```bash
curl -L -o docker-setup.sh https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/docker-setup.sh
```
#### 1.1.1 创建目录
如果您的电脑是windows系统,请使用使用 Git Bash、WSL、PowerShell 或 CMD 运行以下命令:
```bash
# Git Bash 或 WSL
sh docker-setup.sh
# PowerShell 或 CMD
.\docker-setup.sh
```
如果您的电脑是linux 或者 macos 系统,请使用终端运行以下命令:
```bash
chmod +x docker-setup.sh
./docker-setup.sh
```
脚本会自动完成以下操作:
> 1. 创建必要的目录结构
> 2. 下载语音识别模型
> 3. 下载配置文件
> 4. 检查文件完整性
>
> 执行完成后,请按照提示配置 API 密钥。
当你一切顺利完成以上操作后,继续操作[配置项目文件](#2-配置项目文件)
### 1.2 手动部署
如果懒人脚本无法正常运行,请按本章节1.2进行手动部署。
#### 1.2.1 创建目录
安装完后,你需要为这个项目找一个安放配置文件的目录,例如我们可以新建一个文件夹叫`xiaozhi-server`
安装完docker后,你需要为这个项目找一个安放配置文件的目录,例如我们可以新建一个文件夹叫`xiaozhi-server`
创建好目录后,你需要在`xiaozhi-server`下面创建`data`文件夹和`models`文件夹,`models`下面还要再创建`SenseVoiceSmall`文件夹。
@@ -61,18 +27,18 @@ xiaozhi-server
├─ SenseVoiceSmall
```
#### 1.2.2 下载语音识别模型文件
#### 1.1.2 下载语音识别模型文件
你需要下载语音识别的模型文件,因为本项目的默认语音识别用的是本地离线语音识别方案。可通过这个方式下载
[跳转到下载语音识别模型文件](#模型文件)
下载完后,回到本教程。
#### 1.2.3 下载配置文件
#### 1.1.3 下载配置文件
你需要下载两个配置文件:`docker-compose.yaml``config.yaml`。需要从项目仓库下载这两个文件。
##### 1.2.3.1 下载 docker-compose.yaml
##### 1.1.3.1 下载 docker-compose.yaml
用浏览器打开[这个链接](../main/xiaozhi-server/docker-compose.yml)。
@@ -81,7 +47,7 @@ xiaozhi-server
下载完后,回到本教程继续往下。
##### 1.2.3.2 创建 config.yaml
##### 1.1.3.2 创建 config.yaml
用浏览器打开[这个链接](../main/xiaozhi-server/config.yaml)。
@@ -260,7 +226,7 @@ LLM:
文件放在`models/SenseVoiceSmall`
目录下。下面两个下载路线任选一个。
- 线路一:阿里魔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
- 线路一:阿里魔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
- 线路二:百度网盘下载[SenseVoiceSmall](https://pan.baidu.com/share/init?surl=QlgM58FHhYv1tFnUT_A8Sg&pwd=qvna) 提取码:
`qvna`
@@ -290,19 +256,30 @@ LLM:
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 常见问题
以下是一些常见问题,供参考:
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
[3、TTS 经常失败,经常超时](./FAQ.md)
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
[5、如何提高小智对话响应速度?](./FAQ.md)
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
1、[为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)<br/>
2、[为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)<br/>
3、[TTS 经常失败,经常超时](./FAQ.md)<br/>
4、[使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)<br/>
5、[如何提高小智对话响应速度?](./FAQ.md)<br/>
6、[我说话很慢,停顿时小智老是抢话](./FAQ.md)<br/>
## 部署相关教程
1、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
2、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
## 拓展相关教程
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/>
## 语音克隆、本地语音部署相关教程
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/>
+60 -23
View File
@@ -7,7 +7,34 @@ docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统
如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html)
#### 1.1 创建目录
docker 安装全模块有两种方式,你可以[使用懒人脚本](./Deployment_all.md#11-懒人脚本)(作者[@VanillaNahida](https://github.com/VanillaNahida)
脚本会自动帮你下载所需的文件和配置文件,你也可以使用[手动部署](./Deployment_all.md#12-手动部署)从零搭建。
### 1.1 懒人脚本
部署简便,可以参考[视频教程](https://www.bilibili.com/video/BV17bbvzHExd/) ,文字版教程如下:
> [!NOTE]
> 暂且只支持Ubuntu服务器一键部署,其他系统未尝试,可能会有一些奇怪的bug
使用SSH工具连接到服务器,以root权限执行如下脚本
```bash
sudo bash -c "$(wget -qO- https://ghfast.top/https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/main/docker-setup.sh)"
```
脚本会自动完成以下操作:
> 1. 安装Docker
> 2. 配置镜像源
> 3. 下载/拉取镜像
> 4. 下载语音识别模型文件
> 5. 引导配置服务端
>
执行完成后简单配置后,再参照[4. 运行程序](#4. 运行程序)和[5.重启xiaozhi-esp32-server](#5.重启xiaozhi-esp32-server)里提到的最重要的3件事情,完成3这三项配置后即可使用。
### 1.2 手动部署
#### 1.2.1 创建目录
安装完后,你需要为这个项目找一个安放配置文件的目录,例如我们可以新建一个文件夹叫`xiaozhi-server`
@@ -22,22 +49,22 @@ xiaozhi-server
├─ SenseVoiceSmall
```
#### 1.2 下载语音识别模型文件
#### 1.2.2 下载语音识别模型文件
本项目语音识别模型,默认使用`SenseVoiceSmall`模型,进行语音转文字。因为模型较大,需要独立下载,下载后把`model.pt`
文件放在`models/SenseVoiceSmall`
目录下。下面两个下载路线任选一个。
- 线路一:阿里魔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
- 线路一:阿里魔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
- 线路二:百度网盘下载[SenseVoiceSmall](https://pan.baidu.com/share/init?surl=QlgM58FHhYv1tFnUT_A8Sg&pwd=qvna) 提取码:
`qvna`
#### 1.3 下载配置文件
#### 1.2.3 下载配置文件
你需要下载两个配置文件:`docker-compose_all.yaml``config_from_api.yaml`。需要从项目仓库下载这两个文件。
##### 1.3.1 下载 docker-compose_all.yaml
##### 1.2.3.1 下载 docker-compose_all.yaml
用浏览器打开[这个链接](../main/xiaozhi-server/docker-compose_all.yml)。
@@ -48,7 +75,7 @@ xiaozhi-server
下载完后,回到本教程继续往下。
##### 1.3.2 下载 config_from_api.yaml
##### 1.2.3.2 下载 config_from_api.yaml
用浏览器打开[这个链接](../main/xiaozhi-server/config_from_api.yaml)。
@@ -179,12 +206,12 @@ docker logs -f xiaozhi-esp32-server
OTA接口:
```
http://你电脑局域网的ip:8002/xiaozhi/ota/
http://你宿主机局域网的ip:8002/xiaozhi/ota/
```
Websocket接口:
```
ws://你电脑局域网的ip:8000/xiaozhi/v1/
ws://你宿主机的ip:8000/xiaozhi/v1/
```
### 第三件重要的事情
@@ -358,7 +385,7 @@ pip install -r requirements.txt
文件放在`models/SenseVoiceSmall`
目录下。下面两个下载路线任选一个。
- 线路一:阿里魔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
- 线路一:阿里魔下载[SenseVoiceSmall](https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt)
- 线路二:百度网盘下载[SenseVoiceSmall](https://pan.baidu.com/share/init?surl=QlgM58FHhYv1tFnUT_A8Sg&pwd=qvna) 提取码:
`qvna`
@@ -431,19 +458,29 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 常见问题
以下是一些常见问题,供参考:
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
[3、TTS 经常失败,经常超时](./FAQ.md)
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
[5、如何提高小智对话响应速度?](./FAQ.md)
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
1、[为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)<br/>
2、[为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)<br/>
3、[TTS 经常失败,经常超时](./FAQ.md)<br/>
4、[使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)<br/>
5、[如何提高小智对话响应速度?](./FAQ.md)<br/>
6、[我说话很慢,停顿时小智老是抢话](./FAQ.md)<br/>
## 部署相关教程
1、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
2、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
## 拓展相关教程
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/>
## 语音克隆、本地语音部署相关教程
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 -71
View File
@@ -34,59 +34,18 @@ conda install conda-forge::ffmpeg
### 5、如何提高小智对话响应速度? ⚡
本项目默认配置为低成本方案,建议初学者先使用默认免费模型,解决"跑得动"的问题,再优化"跑得快"。
如需提升响应速度,可尝试更换各组件。以下为各组件的响应速度测试数据(仅供参考,不构成承诺):
如需提升响应速度,可尝试更换各组件。`0.5.2`版本起,项目支持流式配置,相比早期版本,响应速度提升约`2.5秒`,显著改善用户体验。
| 影响因素 | 因素值 |
|:-----:|:----------------:|
| 测试地点 | 广东省广州市海珠区 |
| 测试时间 | 2025年2月19日 12:52 |
| 宽带运营商 | 中国联通 |
| 模块名称 | 入门全免费设置 | 流式配置 |
|:---:|:---:|:---:|
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) |
| 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(本地短期记忆) |
测试方法:
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`
如果您关心各组件的耗时,请查阅[小智各组件性能测试报告](https://github.com/xinnan-tech/xiaozhi-performance-research),可按报告中的测试方法在您的环境中实际测试。
### 6、我说话很慢,停顿时小智老是抢话 🗣️
@@ -100,30 +59,34 @@ VAD:
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、如何开启视觉模型实现拍照识物 📷
参考教程[视觉模型使用指南](./mcp-vision-integration.md)
### 10、如何开启MCP接入点 🔧
1、先参考教程[MCP 接入点部署使用指南](./mcp-endpoint-enable.md)
2、再参考教程[MCP 接入点使用指南](./mcp-endpoint-integration.md)
### 12、如何开启声纹识别 🔊
参考教程[声纹识别启用指南](./voiceprint-integration.md)
### 10、性能测试教程
1、[各组件速度测试指南](./performance_tester.md)<br/>
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
### 13、更多问题,可联系我们反馈 💬
可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。
也可以发邮件我们取得联系:huangrongzhuang@xin-nan.com
可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。
+10 -4
View File
@@ -105,6 +105,8 @@ else
fi
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev &
tail tail -f nohup.out
```
保存好后执行赋权命令
@@ -134,8 +136,12 @@ else
echo "已杀掉进程 $PID"
fi
cd main/xiaozhi-server
# 初始化conda环境
source ~/.bashrc
conda activate xiaozhi-esp32-server
pip install -r requirements.txt
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
# 更新并启动Java程序
./update_8001.sh
@@ -158,9 +162,11 @@ cd /home/system/xiaozhi
./update_8002.sh
# 更新并启动python程序
./update_8000.sh
# 查看Java日志
# 后期想查看java日志,执行以下命令
tail -f nohup.out
# 查看Python日志
# 后期想查看python日志,执行以下命令
tail -f /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/tmp/server.log
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

+165
View File
@@ -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)
+99
View File
@@ -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默认24000http默认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
```
+27
View File
@@ -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
```
+72
View File
@@ -8,6 +8,7 @@
* [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.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-数据流与交互机制)
5. [核心功能概要](#5-核心功能概要)
6. [部署与配置概述](#6-部署与配置概述)
@@ -58,6 +59,14 @@
* (潜在功能)监控系统运行状态、查看日志、进行故障排查等。
*`manager-api` 提供的所有后端管理功能进行全面的交互。
5. **`manager-mobile` (智控台移动版 - uni-app实现):**
这是一个基于uni-app v3 + Vue 3 + Vite的跨端移动管理端,支持AppAndroid & 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设备进行播放。所有与语音直接相关的实时交互均在此链路完成。
@@ -71,6 +80,7 @@ xiaozhi-esp32-server
├─ xiaozhi-server 8000 端口 Python语言开发 负责与esp32通信
├─ manager-web 8001 端口 Node.js+Vue开发 负责提供控制台的web界面
├─ 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的跨端移动管理端,支持AppAndroid & 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. 数据流与交互机制
`xiaozhi-esp32-server` 系统通过各组件间定义清晰的数据流和交互协议来协同工作。主要的通信方式依赖于针对实时交互优化的WebSocket协议和适用于客户端-服务器请求的RESTful API。
+66 -1
View File
@@ -8,6 +8,7 @@
* [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.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)
5. [Key Features Summary](#5-key-features-summary)
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
├─ 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-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).
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.
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.
---
+16
View File
@@ -24,6 +24,8 @@
<hutool.version>5.8.24</hutool.version>
<jsoup.version>1.19.1</jsoup.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>
<captcha.version>1.6.2</captcha.version>
<guava.version>33.0.0-jre</guava.version>
@@ -75,6 +77,7 @@
<version>${captcha.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
@@ -190,11 +193,24 @@
<artifactId>jsoup</artifactId>
<version>${jsoup.version}</version>
</dependency>
<!--knife4j -->
<dependency>
<groupId>com.github.xingfudeshi</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</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>
<groupId>org.projectlombok</groupId>
<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
@@ -103,7 +103,11 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
wrapper.select(AgentChatHistoryEntity::getContent, AgentChatHistoryEntity::getAudioId)
.eq(AgentChatHistoryEntity::getAgentId, agentId)
.eq(AgentChatHistoryEntity::getChatType, AgentChatHistoryType.USER.getValue())
.isNotNull(AgentChatHistoryEntity::getAudioId);
.isNotNull(AgentChatHistoryEntity::getAudioId)
// 添加此行,确保查询结果按照创建时间降序排列
// 使用id的原因:数据形式,id越大的创建时间就越晚,所以使用id的结果和创建时间降序排列结果一样
// id作为降序排列的优势,性能高,有主键索引,不用在排序的时候重新进行排除扫描比较
.orderByDesc(AgentChatHistoryEntity::getId);
// 构建分页查询,查询前50页数据
Page<AgentChatHistoryEntity> pageParam = new Page<>(0, 50);
@@ -341,6 +341,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
* @return T 匹配 : F 不匹配
*/
private boolean validateLLMIntentParams(String llmModelId, String intentModelId) {
if (StringUtils.isBlank(llmModelId)) {
return true;
}
ModelConfigEntity llmModelData = modelConfigService.selectById(llmModelId);
String type = llmModelData.getConfigJson().get("type").toString();
// 如果查询大语言模型是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:
encoding: utf8
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">
<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 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>
<update id="logout">
@@ -5,7 +5,7 @@
<!-- 根据参数编码,查询value -->
<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>
<!-- 获取参数编码列表 -->
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
}
+13
View File
@@ -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 # 关闭末尾空格修剪
+44
View File
@@ -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
+8
View File
@@ -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
+21
View File
@@ -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.
+170
View File
@@ -0,0 +1,170 @@
## 智控台移动版(manager-mobile
基于 uni-app v3 + Vue 3 + Vite 的跨端移动管理端,支持 AppAndroid & 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_APPIDuni-app 应用 appidApp
- VITE_WX_APPID:微信小程序 appidmp-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](../../docs/images/manager-mobile/生成appid.png)
- 复制生成的 AppID 到环境变量 `VITE_UNI_APPID`
#### 2. 本地运行步骤
![本地运行](../../docs/images/manager-mobile/本地运行.png)
**App 本地调试:**
1. 用 HBuilderX 导入 `manager-mobile` 目录
2. 重新识别项目
3. 连接手机或使用模拟器进行真机调试
**项目识别问题解决:**
![重新识别项目](../../docs/images/manager-mobile/重新识别项目.png)
如果 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](../../docs/images/manager-mobile/打包发行步骤1.png)
1. 确保已配置正确的 `VITE_UNI_APPID`
2. 运行 `pnpm build:app`,产物在 `dist/build/app`
3. 用 HBuilderX 导入项目目录
4. 在 HBuilderX 中点击"发行" → "原生App-云打包"
**步骤二:配置打包参数**
![打包发行步骤2](../../docs/images/manager-mobile/打包发行步骤2.png)
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
+19
View File
@@ -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'
+6
View File
@@ -0,0 +1,6 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = true
+6
View File
@@ -0,0 +1,6 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = true
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false
+4
View File
@@ -0,0 +1,4 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false
+43
View File
@@ -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,
},
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+26
View File
@@ -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>
+155
View File
@@ -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',
})
+161
View File
@@ -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"
}
}
+23
View File
@@ -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;
}
+14866
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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}`)
})
+39
View File
@@ -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>
+185
View File
@@ -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,
},
})
}
+107
View File
@@ -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
}
+127
View File
@@ -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>
+34
View File
@@ -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 }
}
+160
View File
@@ -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,
})
}
+36
View File
@@ -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>
+19
View File
@@ -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,
}
}
+124
View File
@@ -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>
+158
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More