Compare commits

...
177 Commits
Author SHA1 Message Date
hrzandGitHub 80be8b2eaa Hot fix (#1007)
* update:优化代码

* fix:补回server.websocket配置

* update:修正版本号
2025-04-26 09:12:23 +08:00
hrzandGitHub 7d585615a9 update:细化版本对比 (#1004) 2025-04-25 23:58:43 +08:00
hrzandGitHub 3f3375dcac Auto ota update (#1001)
* update:修改ai_ota表编码

* update:更新教程

* add:增加OTA地址的配置

* update:将设备默认设置成自动升级

* update:设备管理可以设置某个设备是否自动升级

* update:判断客户端是否启动了自动升级

* update:校验OTA地址
2025-04-25 18:23:43 +08:00
hrzandGitHub f555eae653 Merge pull request #999 from xinnan-tech/auto-ota-update
Auto ota update
2025-04-25 17:56:47 +08:00
CGDandGitHub e805a6e037 Merge pull request #981 from xinnan-tech/aboutHandle-modify
打断时增加tts和音频任务清理
2025-04-25 09:58:15 +08:00
欣南科技andGitHub 3ed82ed7bb Merge pull request #982 from xinnan-tech/auto-ota-update
update:设置同类型OTA固件,只保留一个
2025-04-25 09:51:00 +08:00
hrz d638e512c9 update:设置同类型OTA固件,只保留一个 2025-04-25 09:48:25 +08:00
Sakura-RanChen 204b966935 打断时增加tts和音频任务清理 2025-04-25 09:34:22 +08:00
欣南科技andGitHub 8318ba5e55 Merge pull request #978 from xinnan-tech/auto-ota-update
update:OTA自动升级
2025-04-24 18:37:37 +08:00
hrz d39c595828 update:优化获取新固件方法 2025-04-24 18:36:04 +08:00
hrz 04154162c6 update:OTA自动升级 2025-04-24 18:27:38 +08:00
hrz 47f8e0f04c update:检验LLM密钥 2025-04-24 15:32:28 +08:00
hrz 7e4ada5f4b update:检验LLM密钥 2025-04-24 15:22:28 +08:00
hrz b0b4f87848 update:天气插件优化 2025-04-24 14:58:35 +08:00
欣南科技andGitHub d9085b82bf Merge pull request #967 from xinnan-tech/model-config-ui
update:优化模型配置左侧栏显示
2025-04-24 12:17:01 +08:00
hrz 8e5770ff59 修复tts名字过长 2025-04-24 12:13:42 +08:00
Sakura-RanChenandGitHub beb420c69d Merge pull request #968 from xinnan-tech/web-vue-ota-modify
Web vue ota modify
2025-04-24 11:54:08 +08:00
Junsen HuangandGitHub 1d8b898924 fix: index.js
修复router没有设置base
2025-04-24 11:46:29 +08:00
Junsen HuangandGitHub b5b4c5ced7 fix: vue.config.js
修复publicPath空串问题、默认是"/"
2025-04-24 11:45:24 +08:00
CGD b4e9d77895 update:修改了表格样式,增加了滑动条等,使其与其他页面基本对齐一致 2025-04-24 11:21:09 +08:00
hrzandGitHub 21a08b3c7a Merge pull request #933 from laride/feat/volces-ai-gateway
添加火山引擎边缘大模型网关 LLM/TTS
2025-04-24 11:19:28 +08:00
hrzandGitHub 6977d6bf73 Merge branch 'model-config-ui' into feat/volces-ai-gateway 2025-04-24 11:19:19 +08:00
hrzandGitHub 39f81dd0c0 Merge pull request #944 from laride/feat/add-public-path-env
添加前端部署路径环境变量
2025-04-24 11:16:20 +08:00
hrz b3191f5e0b update:优化tts异常捕获 2025-04-24 10:58:42 +08:00
hrzandGitHub 78f2f21142 Merge pull request #934 from taiping520/main
fix: 修复tts失败不重试问题
2025-04-24 10:43:32 +08:00
hrz 497fc1d1ae update:优化模型配置左侧栏显示 2025-04-24 10:41:14 +08:00
taiping520andGitHub d83e1c6fb2 Merge branch 'xinnan-tech:main' into main 2025-04-24 09:50:24 +08:00
CGD c5218d485a update:添加"加载中"样式,修改分页器样式 2025-04-24 09:33:46 +08:00
Sakura-RanChenandGitHub 19cbd3b8da Merge pull request #965 from xinnan-tech/py_test
Py test
2025-04-24 09:20:16 +08:00
CGD c0c5630bfb update:修改"获取天气"的逻辑顺序 2025-04-24 09:12:56 +08:00
CGD 969b5fcfbb update:"默认提示词"取消用户位置 2025-04-24 09:11:58 +08:00
欣南科技andGitHub 0739fa2195 Merge pull request #964 from xinnan-tech/update-fix
fix:取消docker版上传ota固件时默认的文件大小限制
2025-04-23 23:24:21 +08:00
hrz 3c62adab3c fix:取消docker版上传ota固件时默认的文件大小限制 2025-04-23 23:23:25 +08:00
欣南科技andGitHub edc601c35e Merge pull request #962 from xinnan-tech/update-fix
update:让alibl、等非原生llm使用functional时不卡顿
2025-04-23 21:30:40 +08:00
hrz 951370eee7 update:让alibl、等非原生llm使用function时不卡顿 2025-04-23 21:29:42 +08:00
hrzandGitHub fa81f4a150 Merge pull request #959 from TinsFox/main
update: 修改智控台登录地址为8002
2025-04-23 21:10:21 +08:00
TinsFox e6a90c40fb update: 修改智控台登录地址为8002 2025-04-23 20:25:04 +08:00
欣南科技andGitHub 2e0a1c00fa Merge pull request #955 from xinnan-tech/update-readme
update:增加HomeAssistant教程
2025-04-23 18:18:45 +08:00
hrz 109754fac9 update:增加HomeAssistant教程 2025-04-23 18:17:56 +08:00
taiping520andGitHub c3fbff3d7a Merge branch 'xinnan-tech:main' into main 2025-04-23 16:44:27 +08:00
欣南科技andGitHub 6dd967fe24 udpate:更新编译文档 (#948) 2025-04-23 11:49:24 +08:00
hrz f0bfdba944 udpate:更新编译文档 2025-04-23 11:48:20 +08:00
Liu Ruichao 7f76b5f4ee add PUBLIC_PATH env 2025-04-23 10:49:50 +08:00
taiping520andGitHub 35f4d8f15a Merge branch 'xinnan-tech:main' into main 2025-04-23 09:45:05 +08:00
hrzandGitHub 783f17e9e8 update:补充修改 OTA 固件信息接口 (#940)
* update:修复server.websocket配置占用bug

* update:补充修改 OTA 固件信息接口
2025-04-23 00:24:19 +08:00
hrzandGitHub 9e305c2840 update:修复server.websocket配置占用bug (#939) 2025-04-23 00:04:21 +08:00
78fa5d3110 add:ota管理与固件上传与下载 (#931)
* 新增ota管理与固件上传于下载

* feat: 支持通过OTA更新websocket地址

* update:合并最新代码

* update:优化OTA增删改查

* update:一个临时下载链接最多只能下载3次,防止盗链和流量攻击

* update:OTA固件增删改查

---------

Co-authored-by: kevin1sMe <linjiang1205@gmail.com>
Co-authored-by: hrz <1710360675@qq.com>
2025-04-22 23:36:23 +08:00
Liu Ruichao d57482d966 feat: add Volces AI Gateway LLM/TTS 2025-04-22 17:04:29 +08:00
sczx-win10 3d7c2aff4b Merge remote-tracking branch 'origin/main' 2025-04-22 16:58:29 +08:00
sczx-win10 3757c5dde7 fix: 修复tts失败不重试问题 2025-04-22 16:44:45 +08:00
Sakura-RanChenandGitHub 13c0a966ec Merge pull request #928 from xinnan-tech/web-vue-device-fixed
update:设备管理页面样式调整
2025-04-22 11:34:52 +08:00
CGD d987619946 update:设备管理页面样式调整 2025-04-22 11:33:17 +08:00
Sakura-RanChenandGitHub 8fb9498fe6 Merge pull request #927 from xinnan-tech/web-vue-height-fixed
Web vue height fixed
2025-04-22 10:18:19 +08:00
CGD b8f64a6693 update:更换设备管理页面的表格“选择”样式 2025-04-22 10:05:06 +08:00
CGD e31bb047bf update:将参数管理页面和用户管理页面的高度调整为固定 2025-04-22 09:27:14 +08:00
Sakura-RanChenandGitHub 6897b813a6 Merge pull request #919 from xinnan-tech/web-vue-loading
Web vue loading
2025-04-22 09:02:24 +08:00
hrzandGitHub 83f86406b5 update:更新固件下载源码路径 (#923) 2025-04-21 21:08:15 +08:00
dde237ff0d update:增加dashscope依赖 (#920)
* update:增加腾讯TTS、腾讯ASR供应器

* update:增加dashscope依赖

---------

Co-authored-by: 欣南科技 <huangrongzhuang@xin-nan.com>
2025-04-21 17:55:27 +08:00
hrzandGitHub 6a4887e252 Merge pull request #916 from laride/fix/empty-chunk
处理 OpenAI 格式响应中 `choices` 为空的 chunk
2025-04-21 17:41:04 +08:00
d945b94910 update:增加腾讯TTS、腾讯ASR供应器 (#918)
Co-authored-by: 欣南科技 <huangrongzhuang@xin-nan.com>
2025-04-21 17:35:10 +08:00
CGD bc7dcf791d update:获取"设备管理"数据时添加loading状态,修改“添加智能体”的错误字眼 2025-04-21 16:24:53 +08:00
CGD 66c71fe3b7 update:音色管理页面加载音色配置时添加loading状态 2025-04-21 15:23:27 +08:00
larideandGitHub 16a7aeec3b fix: handle empty choices in some chunk of OpenAI streaming response 2025-04-21 14:24:47 +08:00
Sakura-RanChenandGitHub 94605345e5 Merge pull request #914 from xinnan-tech/web-table-loading
Web table loading
2025-04-21 11:40:47 +08:00
CGD ec9fa32c81 update:添加多个组件"保存"时的loading状态 2025-04-21 11:36:10 +08:00
CGDandGitHub 264aa15281 Merge pull request #913 from xinnan-tech/web-role-exit
增加“角色配置”页面返回按钮,智能体管理骨架屏加载显示
2025-04-21 11:26:39 +08:00
Ran_Chen 180a6ff824 增加“角色配置”页面返回按钮,智能体管理骨架屏加载显示 2025-04-21 10:24:04 +08:00
CGD 5904ee1571 update:添加“模型配置”、“用户管理”、“参数管理”页面“获取列表”时的加载状态 2025-04-21 10:02:50 +08:00
hrzandGitHub bc99341f85 Merge pull request #909 from xinnan-tech/ota-activate
update:输入验证码后设备端快速刷新结果
2025-04-20 21:15:00 +08:00
hrzandGitHub 8b9174b67e update:绑定设备时,板子类型优先获取品牌商版名称 (#903)
* update:Server端兼容老设备缺失"client-id"请求头

* update:优化iot日志

* update:绑定设备时,板子类型优先获取品牌商版名称

* update:更新版本号
2025-04-20 01:40:21 +08:00
hrzandGitHub 6c04a6cf69 update:Server端兼容老设备缺失"client-id"请求头 (#902)
* update:Server端兼容老设备缺失"client-id"请求头

* update:优化iot日志

* update:绑定设备时,板子类型优先获取品牌商版名称
2025-04-20 01:28:01 +08:00
hrzandGitHub 14a63c4b97 update:补齐ddos攻击提示音 (#896) 2025-04-19 13:24:03 +08:00
hrzandGitHub 098d13a34b update:更新0.3.7 (#892) 2025-04-19 00:11:12 +08:00
488f247744 update:增加单台设备每天最多聊天字数,防止被ddos
* 添加清空redis所有库的接口
--AdminController.java 添加了清除所有的接口
--RedisUtils.java 添加了清除redis所有key的方法,redisTemplate提供的清空方法已经被标记为弃用了,所有选择用执行lua脚本方式

* fix:修复使用本地配置时忘记附带提示词

* fix:修复意图识别插件名称格式bug

* update:版本升级后强制刷新redis

* update:增加单台设备每天最多聊天字数,防止被ddos

---------

Co-authored-by: 剑雨 <2375294554@qq.com>
2025-04-18 23:39:56 +08:00
hrzandGitHub c74dbf03cd fix:修复意图识别插件名称格式bug (#885)
* fix:修复使用本地配置时忘记附带提示词

* fix:修复意图识别插件名称格式bug
2025-04-18 16:39:18 +08:00
hrzandGitHub 1f836c3235 fix:修复使用本地配置时忘记附带提示词 (#880) 2025-04-18 11:38:31 +08:00
hrzandGitHub bd2e2e77d5 update:更新版本号 (#874) 2025-04-18 00:51:09 +08:00
6282ef14e8 fix:修复第一句话是默认配置的bug (#873)
* update: 增加对httpClient的统一管理

* update: 增加retry机制

* update: 增加retry机制

* fix:websocket连接后不说话的备用关闭方法

* fix:修复第一句话是默认配置的bug

---------

Co-authored-by: haotian <haotian@codemao.cn>
Co-authored-by: GoodyHao <865700600@qq.com>
2025-04-18 00:37:40 +08:00
558f23688f fix:websocket连接后不说话的备用关闭方法 (#872)
* update: 增加对httpClient的统一管理

* update: 增加retry机制

* update: 增加retry机制

* fix:websocket连接后不说话的备用关闭方法

---------

Co-authored-by: haotian <haotian@codemao.cn>
Co-authored-by: GoodyHao <865700600@qq.com>
2025-04-17 23:57:04 +08:00
CGDandGitHub 541f2de599 Merge pull request #856 from xinnan-tech/web-page-check
解决切换页面样式变化的问题
2025-04-17 13:26:26 +08:00
hrzandGitHub 68116254cd dify、coze对话模式支持functioncall
Function call v2
2025-04-17 11:54:04 +08:00
hrzandGitHub 77ff4599ea Merge branch 'main' into function-call-v2 2025-04-17 11:53:25 +08:00
玄凤科技 652f5a3247 Merge branch 'function-call-v2' of https://github.com/xinnan-tech/xiaozhi-esp32-server into function-call-v2 2025-04-17 10:00:23 +08:00
玄凤科技 9b6e57b143 修改提示词,增强tool调用约束 2025-04-17 10:00:13 +08:00
Ran_Chen bdc19256bf 解决切换页面样式变化的问题 2025-04-17 09:34:42 +08:00
hrzandGitHub de5eaf4467 更新版本号0.3.5 (#852)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug

* udpate:增加iot消息properties和methods可能为空的情况

* update:发布0.3.5版本
2025-04-16 23:47:37 +08:00
hrzandGitHub ca884833d1 udpate:增加iot消息properties和methods可能为空的情况 (#851)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug

* udpate:增加iot消息properties和methods可能为空的情况
2025-04-16 23:34:23 +08:00
hrz 9fc164a69d udpate:增加iot消息properties和methods可能为空的情况 2025-04-16 23:30:46 +08:00
hrzandGitHub 0da2da83a5 update:修复智控台下发配置布尔类型转换出错bug (#850)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug
2025-04-16 22:55:13 +08:00
hrzandGitHub bfdfa44edd Merge pull request #844 from xinnan-tech/web-page-modify
“角色配置”行内样式调整
2025-04-16 18:43:07 +08:00
hrzandGitHub ed1cfe5eb6 update:兼容旧设备,无Client-Id的情况 (#847)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况
2025-04-16 18:03:30 +08:00
Ran_Chen b696f3f5db “角色配置”行内样式调整 2025-04-16 17:18:11 +08:00
hrzandGitHub 7a0cf5ef9a update:测试页面增加OTA地址 (#842) 2025-04-16 16:44:44 +08:00
CGDandGitHub d1b7cb6a36 Merge pull request #837 from xinnan-tech/web-modify
“角色配置”页面间距调整
2025-04-16 14:47:58 +08:00
Ran_Chen b21d699472 “角色配置”页面间距调整 2025-04-16 14:25:46 +08:00
CGDandGitHub 029fadd778 Merge pull request #836 from xinnan-tech/web-modify
Web modify
2025-04-16 14:04:19 +08:00
Sakura-RanChenandGitHub 215d50235b Merge pull request #835 from xinnan-tech/web-vue-agent-modify
修改了部分components的样式
2025-04-16 14:03:24 +08:00
CGD 000e676397 修改了部分components的样式 2025-04-16 12:55:07 +08:00
玄凤科技 208c045d3d 触发function call后,清空回复队列 2025-04-16 11:48:40 +08:00
Ran_Chen ee20f59a8e 增加“回车”事件 2025-04-16 11:42:05 +08:00
Ran_Chen 925cf3dabb 新增智能体按钮绑定问题 2025-04-16 11:34:30 +08:00
CGDandGitHub 0049d0e926 Merge pull request #831 from xinnan-tech/web-pages-modify
Web pages modify
2025-04-16 11:23:42 +08:00
Ran_Chen 56959808a9 解决新增设备按钮绑定问题,优化“角色配置”页面 2025-04-16 11:17:02 +08:00
Ran_Chen 8667a9680e 增加时间戳转换,修改各页面的布局 2025-04-16 10:15:58 +08:00
玄凤科技 8c7d129089 同步coze llm 2025-04-16 08:58:55 +08:00
玄凤科技 a3a9b98a1d 增加系统提示词,支持dify使用function call 2025-04-16 08:56:23 +08:00
欣南科技andGitHub f2f66579cb 合并网友提交优化
合并网友提交优化
2025-04-15 22:48:03 +08:00
hrz 1354cf2a87 合并网友提交优化 2025-04-15 22:46:54 +08:00
hrzandGitHub 15f5592429 Merge pull request #814 from funshine/bug_fix_funshine
Bug fix test_page.html, 以及TTS_CosyVoiceSiliconflow
2025-04-15 22:02:19 +08:00
hrzandGitHub dc69094f00 Merge pull request #817 from GOODDAYDAY/main
update: 增加prompt个性化配置 & 增加vad,asr个性初始化 & 优化日志若干
2025-04-15 22:01:51 +08:00
hrzandGitHub 8ad3783a36 Merge pull request #812 from bohell/main
Update Deployment_all.md
2025-04-15 21:52:09 +08:00
hrzandGitHub 15d5dd8e92 Merge pull request #823 from xinnan-tech/web-vue-select-modify
修改了各个页面的“选择”表头样式
2025-04-15 21:50:46 +08:00
CGD b07edb977b 修改了各个页面的“选择”表头样式 2025-04-15 20:33:09 +08:00
hrzandGitHub 1509fc2039 update:播报音频文件
* update:websocket播报6位验证码

* update:播报音频文件
2025-04-15 18:55:23 +08:00
欣南科技andGitHub 2877bb8faa Web device modify (#818)
* “设备管理”分页响应调整

* 样式调整
2025-04-15 18:30:41 +08:00
hrzandGitHub a83410cb49 update:websocket播报6位验证码 (#821) 2025-04-15 18:30:12 +08:00
Ran_Chen 4ff9f2235b 样式调整 2025-04-15 18:02:04 +08:00
Ran_Chen 1bc611f097 “设备管理”分页响应调整 2025-04-15 17:59:29 +08:00
haotian 4e0a3941ff update: 增加prompt个性化配置 & 增加vad,asr个性初始化 & 优化日志若干 2025-04-15 17:21:44 +08:00
CGDandGitHub 4e885dac3e Merge pull request #815 from xinnan-tech/web-page-modify
Web page modify
2025-04-15 16:27:51 +08:00
Ran_Chen eafd131a22 “模型配置”响应适配,修改下拉图标样式,调整bottom高度差 2025-04-15 16:25:56 +08:00
Guijie HongandGitHub 289ce3716b Merge branch 'xinnan-tech:main' into bug_fix_funshine 2025-04-15 16:11:18 +08:00
Sakura-RanChenandGitHub 2242070bdc Merge pull request #813 from xinnan-tech/fix/manager-web-cdn
Fix/manager web cdn
2025-04-15 16:06:02 +08:00
funshine ee83cea11c 修正CosyVoiceSiliconflow音色 2025-04-15 15:51:36 +08:00
ZhilongandGitHub 6e571a43d8 Update Deployment_all.md
增加管理api的端口号
2025-04-15 15:44:21 +08:00
funshine 466a0245a9 add default value for siliconflow tts speed 2025-04-15 15:28:02 +08:00
funshine d9ce5e4ce7 fix audio test page websocket disconn 2025-04-15 15:22:47 +08:00
Sakura-RanChenandGitHub 6b0dd291c2 Merge pull request #811 from xinnan-tech/web-vue-tts
修复了“音色管理”样式
2025-04-15 14:09:50 +08:00
hrzandGitHub 4ff77d0d7e update:更新烧录时ota地址 (#810)
* update:修复默认模型没有修改到模版配置的bug

* update:智控台可设置允许用户自由注册

* update:更新烧录时ota地址
2025-04-15 13:18:23 +08:00
hrzandGitHub e00da5d8d2 update:智控台可设置允许用户自由注册 (#809)
* update:修复默认模型没有修改到模版配置的bug

* update:智控台可设置允许用户自由注册
2025-04-15 13:03:29 +08:00
CGD 1a525a59ab 修复了“音色管理”样式 2025-04-15 12:20:33 +08:00
Ran_Chen d5581c4236 “用户管理”页面,响应式调整 2025-04-15 11:27:35 +08:00
Sakura-RanChenandGitHub 4bf950574c Merge pull request #803 from xinnan-tech/web-vue-modify
修改了分页器样式
2025-04-15 10:33:34 +08:00
CGDandGitHub 0b9e434c4b Merge pull request #802 from xinnan-tech/web-pages-modify
参数页面响应调整
2025-04-15 10:33:26 +08:00
CGD c4b1466643 修改了分页器样式 2025-04-15 10:30:52 +08:00
Ran_Chen 488842029b 参数页面响应调整 2025-04-15 10:28:37 +08:00
Junsen HuangandGitHub 1b0ce59371 Merge pull request #800 from huangjunsen0406/fix/manager-web-cdn
Fix/manager web cdn
2025-04-15 10:00:36 +08:00
huangjunsen0406 97bea89204 Merge branch 'main' into fix/manager-web-cdn 2025-04-15 09:56:22 +08:00
huangjunsen0406 0892dfc601 Merge remote-tracking branch 'upstream/main' 2025-04-15 09:54:05 +08:00
huangjunsen0406 20d66ff401 fix(service_worker): 根据标志位在app.vue开启缓存检测工具 2025-04-15 09:53:06 +08:00
Sakura-RanChenandGitHub e2fda532d1 Merge pull request #796 from xinnan-tech/web-vue-modify-device
Web vue modify device
2025-04-15 09:06:01 +08:00
Junsen HuangandGitHub 68035af7b3 Merge pull request #794 from huangjunsen0406/fix/manager-web-cdn
Fix/manager web cdn
2025-04-15 09:01:35 +08:00
CGDandGitHub ee25555f0e Merge branch 'main' into web-vue-modify-device 2025-04-15 08:31:28 +08:00
CGD e528510f5c 修改了参数管理的页面高度 2025-04-15 08:24:06 +08:00
huangjunsen0406 afa0527b4c Merge remote-tracking branch 'upstream/main' 2025-04-15 04:07:27 +08:00
huangjunsen0406 dd7d23498f build(cdn): 📦 更换cdn为unpkg、新增service_worker缓存资源文件
closed #772
2025-04-15 04:03:28 +08:00
hrzandGitHub eaaea2b24c update:智控台添加版本号 (#793) 2025-04-15 01:46:36 +08:00
58d0aefae1 fix:修复manager下发配置的bug (#792)
* update: 优化系统prompt

* Update Deployment_all.md

更新部分的安装配置文档,后续需要修改 docker-compose.yaml

* Update docker-compose_all.yml

- 直接提供全量的部署配置,包括 mysql 与 Redis 实现真正意义上的一键配置。
- 将 MySQL 的数据内容本地挂载存储,需要备份迁移的时候,直接打包复制文件夹即可。MySQL 容器删除的时候也不会出现数据丢失的问题。

* Update Deployment_all.md

- 使用 docker compose 部署的时候,不应该每次更新的时候都把原来的 docker-compose 文件删除,再重新配置。
- 如果没有自己部署数据库需求的话,目前可以直接开箱即用,同步更新了 server 中的 Redis 与 MySQL 的镜像服务配置。

* Update Deployment_all.md

- MySQL 与 Redis 的安装是可选的

* 现在新版本的 docker 直接集成了 compose 命令,不需要手动安装与执行 docker-compose 命令了,提高兼容性

* fix:manager下发配置字符转数字bug

* update: 优化三方模块打印日志报错问题

* update: 修复Dify对接未过滤全量数据问题

* update:通过代理转发接口api

* fix:number熟知去掉小数点后的00

* update:优化docker启动

* update:优化参数读取

---------

Co-authored-by: haotian <haotian@codemao.cn>
Co-authored-by: expoli <31023767+expoli@users.noreply.github.com>
Co-authored-by: GoodyHao <865700600@qq.com>
2025-04-15 00:46:37 +08:00
hrz 11c9915a70 update:优化参数读取 2025-04-15 00:44:34 +08:00
hrz 6cf6b0d645 update:优化docker启动 2025-04-15 00:22:53 +08:00
hrzandGitHub 1980a39b73 Merge pull request #763 from GOODDAYDAY/main
update: 优化系统prompt & 优化三方模块打印日志报错问题 & 修复Dify未过滤全量数据问题
2025-04-14 23:09:43 +08:00
hrzandGitHub 8d3fbeeb22 Merge pull request #782 from expoli/main
[docker] 更新了 docker-compose
2025-04-14 22:50:50 +08:00
hrzandGitHub 2678644c2c Merge branch 'hot-fix' into main 2025-04-14 22:50:42 +08:00
hrz 25da9fe119 fix:number熟知去掉小数点后的00 2025-04-14 22:09:18 +08:00
hrz 03d89d22ec update:通过代理转发接口api 2025-04-14 21:53:52 +08:00
haotian 100cd25ffc Merge remote-tracking branch 'origin/main' 2025-04-14 21:44:06 +08:00
haotian 6284a74a6e update: 修复Dify对接未过滤全量数据问题 2025-04-14 21:43:55 +08:00
GoodyHaoandGitHub 978cd8ba8e Merge branch 'xinnan-tech:main' into main 2025-04-14 18:51:15 +08:00
haotian c76f28824c update: 优化三方模块打印日志报错问题 2025-04-14 18:49:24 +08:00
hrz 703ecfae40 fix:manager下发配置字符转数字bug 2025-04-14 18:30:58 +08:00
CGD b15c59aa48 fix:修复了分页器颜色不一致的问题 2025-04-14 17:23:07 +08:00
CGD 98a4ce701c fix:修复"设备管理"页面解绑设备时出现的异常,添加批量解绑功能,完善搜索功能 2025-04-14 16:10:46 +08:00
CGD 2fc0de8f4e fix:部分页面删去底部的©2025 xiaozhi-esp32-server 2025-04-14 14:38:04 +08:00
CGD 66fd551d68 完成“参数管理”的设置每页大小 2025-04-14 14:20:33 +08:00
expoliandGitHub 6e4e9a002b 现在新版本的 docker 直接集成了 compose 命令,不需要手动安装与执行 docker-compose 命令了,提高兼容性 2025-04-14 14:18:07 +08:00
expoliandGitHub 91278b0b3d Update Deployment_all.md
- MySQL 与 Redis 的安装是可选的
2025-04-14 14:16:25 +08:00
expoliandGitHub ff9b3ed1a2 Update Deployment_all.md
- 使用 docker compose 部署的时候,不应该每次更新的时候都把原来的 docker-compose 文件删除,再重新配置。
- 如果没有自己部署数据库需求的话,目前可以直接开箱即用,同步更新了 server 中的 Redis 与 MySQL 的镜像服务配置。
2025-04-14 14:15:37 +08:00
expoliandGitHub 0c855d4ba8 Update docker-compose_all.yml
- 直接提供全量的部署配置,包括 mysql 与 Redis 实现真正意义上的一键配置。
- 将 MySQL 的数据内容本地挂载存储,需要备份迁移的时候,直接打包复制文件夹即可。MySQL 容器删除的时候也不会出现数据丢失的问题。
2025-04-14 14:06:30 +08:00
expoliandGitHub 9176e9d255 Update Deployment_all.md
更新部分的安装配置文档,后续需要修改 docker-compose.yaml
2025-04-14 14:00:31 +08:00
hrzandGitHub 2bab917b3e update:优化文档 (#775)
* update:添加设计公司致谢

* update:添加设计公司致谢

* update:优化文档

* update:优化文档

* update:优化文档

* update:优化文档
2025-04-14 11:31:52 +08:00
CGD b80086a349 fix:移除“添加智能体”子元素触发弹框,将点击事件仅绑定在"添加智能体"文字部分 2025-04-14 10:48:43 +08:00
hrzandGitHub 8a4fca9bd5 update:优化文档 (#774)
* update:添加设计公司致谢

* update:添加设计公司致谢

* update:优化文档

* update:优化文档
2025-04-14 10:35:14 +08:00
hrzandGitHub 13288a6b3a update:优化文档
* update:添加设计公司致谢

* update:添加设计公司致谢

* update:优化文档
2025-04-14 10:26:34 +08:00
CGD 57ed552f43 完成“设备管理”样式的修改 2025-04-14 09:34:51 +08:00
CGD 5a4d65986d 优化了界面样式 2025-04-14 07:52:00 +08:00
CGD 427b3bb774 修改“设备管理”背景颜色,添加搜索框,完成设备管理的前端搜索功能 2025-04-13 22:32:34 +08:00
haotian dcb6d0feab update: 优化系统prompt 2025-04-13 18:46:04 +08:00
161 changed files with 10717 additions and 2208 deletions
+7 -1
View File
@@ -155,7 +155,13 @@ main/manager-web/node_modules
main/xiaozhi-server/models/SenseVoiceSmall/model.pt
main/xiaozhi-server/models/sherpa-onnx*
my_wakeup_words.mp3
!main/xiaozhi-server/config/assets/bind_code.wav
!main/xiaozhi-server/config/assets/bind_not_found.wav
!main/xiaozhi-server/config/assets/bind_code/*.wav
!main/xiaozhi-server/config/assets/max_output_size.wav
main/manager-api/.vscode
# Ignore webpack cache directory
main/manager-web/.webpack_cache/
main/manager-web/.webpack_cache/
main/xiaozhi-server/mysql
uploadfile
+1 -1
View File
@@ -23,7 +23,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
# 配置Nginx
COPY docs/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docs/docker/nginx.conf /etc/nginx/nginx.conf
# 复制前端构建产物
COPY --from=web-builder /app/dist /usr/share/nginx/html
+49 -70
View File
@@ -6,7 +6,7 @@
<p align="center">
本项目为开源智能硬件项目
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/>
根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python实现<br/>
根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python、Java、Vue实现<br/>
帮助您快速搭建小智服务器
</p>
@@ -15,7 +15,8 @@
· 简体中文
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a>
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues ">反馈问题</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
· <a href="./docs/FAQ.md">常见问题</a>
</p>
<p align="center">
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
@@ -145,42 +146,26 @@ server:
---
## 部署方式 🚀
## 部署文档
[![Banners](docs/images/banner2.png)](./docs/Deployment.md)
![Banners](docs/images/banner2.png)
### 部署文档
本项目提供两种部署方式,请根据您的具体需求选择:
本项目支持`最简化`安装和`全模块`安装。请根据具体需求选择安装方式。
#### 🚀 部署方式选择
`最简化`适合在较低资源配置中运行,无需数据库。[请参考-最简化安装架构图](./docs/images/deploy1.png)
| 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 |
|---------|------|---------|---------|---------|
| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库,仅支持虾哥1.6.0及以下固件版本 | [Docker只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) | [本地源码只运行Server](./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)|
| **全模块安装** | 智能对话、IOT、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/images/deploy2.png)
#### 1、 最简化安装
`最简化`安装包含:智能对话、IOT等功能,数据存储在`配置文件`。我们提供了两种安装教程,如果你决定使用最简化安装,可选择`Docker只运行Server``本地源码只运行Server`任意一种方式。
##### 1.1. Docker只运行Server
你可以使用编译好的docker镜像安装,[点击Docker只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)。优点:减少环境配置的烦恼;缺点:版本可能有点旧,镜像文件较大。
##### 2.2. 本地源码只运行Server
你可以以本地源码的方式运行,[点击本地源码只运行Server](./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)。优点:可以随时更新最新代码;缺点:安装Conda环境较为复杂。
#### 2、 全模块安装
`全模块`安装包含:智能对话、IOT、OTA、智控台,数据存储在`数据库`中。我们提供了两种安装教程,如果你决定使用全模块安装,可选择`Docker运行全模块``本地源码运行全模块`任意一种方式。
##### 2.1. Docker运行全模块
你可以使用编译好的docker镜像安装,[点击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)。优点:减少环境配置的烦恼;缺点:版本可能有点旧,镜像文件较大。
##### 2.2. 本地源码运行全模块
你可以以本地源码的方式运行,[点击本地源码运行全模块](./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)。优点:可以随时更新最新代码;缺点:安装Java、Node.js、Conda环境较为复杂。
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
```
智控台地址: https://2662r3426b.vicp.fun
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
```
---
## 常见问题 ❓
@@ -188,54 +173,41 @@ server:
如遇到问题或产品建议反馈[点这里](docs/FAQ.md)。
---
## 产品生态 👬
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的优秀项目
- [小智安卓客户端](https://github.com/TOM88812/xiaozhi-android-client)
一个基于xiaozhi-server的Android、IOS语音对话应用,支持实时语音交互和文字对话。现在是flutter版本,打通IOS、Android端。
- [小智电脑客户端](https://github.com/Huang-junsen/py-xiaozhi)
该项目提供了一个基于 Python 实现的小白 AI 客户端,使得在不具备实体硬件条件的情况下,依然能够体过代码体验小智 AI 的功能。主要功能包括 AI 语音交互、视觉多模态识别、IoT 设备集成、联网音乐播放、语音唤醒、自动对话模式、图形化界面、命令行模式、跨平台支持、音量控制、会话管理、加密音频传输、自动验证码处理等。
- [小智Java服务端](https://github.com/joey-zhou/xiaozhi-esp32-server-java)
小智开源后端服务 Java 版本是一个基于 Java 的开源项目,它包括前后端的服务,旨在为用户提供一个完整的后端服务解决方案。
---
## 功能清单 ✨
### 已实现 ✅
- **通信协议**
基于 `xiaozhi-esp32` 协议,通过 WebSocket 实现数据交互。
- **对话交互**
支持唤醒对话、手动对话及实时打断。长时间无对话时自动休眠
- **意图识别**
支持使用LLM意图识别、function call函数调用,减少硬编码意图判断
- **多语言识别**
支持国语、粤语、英语、日语、韩语(默认使用 FunASR)。
- **LLM 模块**
支持灵活切换 LLM 模块,默认使用 ChatGLMLLM,也可选用阿里百炼、DeepSeek、Ollama 等接口。
- **TTS 模块**
支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求。
- **记忆功能**
支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求。
- **IOT功能**
支持管理注册设备IOT功能,支持基于对话上下文语境下的智能物联网控制。
- **智控台**
提供Web管理界面,支持智能体管理、用户管理、系统配置等功能,方便管理员和用户进行管理。
| 功能模块 | 描述 |
|---------|------|
| 通信协议 | 基于 `xiaozhi-esp32` 协议,通过 WebSocket 实现数据交互 |
| 对话交互 | 支持唤醒对话、手动对话及实时打断。长时间无对话时自动休眠 |
| 意图识别 | 支持使用LLM意图识别、function call函数调用,减少硬编码意图判断 |
| 多语言识别 | 支持国语、粤语、英语、日语、韩语(默认使用 FunASR) |
| LLM 模块 | 支持灵活切换 LLM 模块,默认使用 ChatGLMLLM,也可选用阿里百炼、DeepSeek、Ollama 等接口 |
| TTS 模块 | 支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求 |
| 记忆功能 | 支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求 |
| IOT功能 | 支持管理注册设备IOT功能,支持基于对话上下文语境下的智能物联网控制 |
| 智控台 | 提供Web管理界面,支持智能体管理、用户管理、系统配置等功能,方便管理员和用户进行管理 |
### 正在开发 🚧
- 多种心情模式
想了解具体开发进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)
想了解具体开发计划进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)
如果你是一名软件开发者,这里有一份[《致开发者的公开信》](docs/contributor_open_letter.md),欢迎加入!
---
## 产品生态 👬
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的优秀项目
| 项目名称 | 项目地址 | 项目描述 |
|:---------------------|:--------|:--------|
| 小智安卓客户端 | [xiaozhi-android-client](https://github.com/TOM88812/xiaozhi-android-client) | 一个基于xiaozhi-server的Android、IOS语音对话应用,支持实时语音交互和文字对话。<br/>现在是flutter版本,打通IOS、Android端。 |
| 小智电脑客户端 | [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) | 该项目提供了一个基于 Python 实现的小白 AI 客户端,使得在不具备实体硬件条件的情况下,<br/>依然能够体过代码体验小智 AI 的功能。 |
| 小智Java服务端 | [xiaozhi-esp32-server-java](https://github.com/joey-zhou/xiaozhi-esp32-server-java) | 小智开源后端服务 Java 版本是一个基于 Java 的开源项目。<br/>它包括前后端的服务,旨在为用户提供一个完整的后端服务解决方案。 |
---
## 本项目支持的平台/组件列表 📋
### LLM 语言模型
@@ -298,10 +270,17 @@ server:
## 鸣谢 🙏
- 本项目受 [百聆语音对话机器人](https://github.com/wwbin2017/bailing) 启发,并在其基础上实现。
- 感谢 [十方融海](https://www.tenclass.com/) 对小智通讯协议提供的详尽文档支持。
| Logo | 项目/公司 | 说明 |
|:---:|:---:|:---|
| <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_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/)深化本项目视觉体系,确保整体设计风格在多场景应用中的一致性和扩展性 |
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
+5 -2
View File
@@ -137,7 +137,10 @@ docker logs -f xiaozhi-esp32-server
```
docker stop xiaozhi-esp32-server
docker rm xiaozhi-esp32-server
docker stop xiaozhi-esp32-server-web
docker rm xiaozhi-esp32-server-web
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
```
5.3、重新按docker方式部署
@@ -221,7 +224,7 @@ python app.py
## 配置项目
如果你的`xiaozhi-server`目录没有`data`,你需要创建`data`目录。
如果你的`data`下面没有`.config.yaml`文件,你可以把源码目录下的`config.yaml`文件复制一份,重命名为`.config.yaml`
如果你的`data`下面没有`.config.yaml`文件,你可以把`xiaozhi-server`目录下的`config.yaml`文件复制`data`,并重命名为`.config.yaml`
修改`xiaozhi-server``data`目录下的`.config.yaml`文件,配置本项目必须的一个配置。
@@ -287,7 +290,7 @@ LLM:
这个信息很有用的,后面`编译esp32固件`需要用到。
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
接下来,你就可以开始 [编译1.6.0版本esp32固件](firmware-build.md)了。
以下是一些常见问题,供参考:
+66 -72
View File
@@ -44,6 +44,8 @@ xiaozhi-server
在页面的右侧找到名称为`RAW`按钮,在`RAW`按钮的旁边,找到下载的图标,点击下载按钮,下载`docker-compose_all.yml`文件。 把文件下载到你的
`xiaozhi-server`中。
或者直接执行 `wget https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/docker-compose_all.yml` 下载。
下载完后,回到本教程继续往下。
##### 1.3.2 下载 config_from_api.yaml
@@ -53,6 +55,8 @@ xiaozhi-server
在页面的右侧找到名称为`RAW`按钮,在`RAW`按钮的旁边,找到下载的图标,点击下载按钮,下载`config_from_api.yaml`文件。 把文件下载到你的
`xiaozhi-server`下面的`data`文件夹中,然后把`config_from_api.yaml`文件重命名为`.config.yaml`
或者直接执行 `wget https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/config_from_api.yaml` 下载保存。
下载完配置文件后,我们确认一下整个`xiaozhi-server`里面的文件如下所示:
```
@@ -67,57 +71,37 @@ xiaozhi-server
如果你的文件目录结构也是上面的,就继续往下。如果不是,你就再仔细看看是不是漏操作了什么。
## 2. 安装Mysql和Redis
## 2. 备份数据
### 2.1 安装Mysql数据库
如果本机已经安装了MySQL,可以直接在数据库中创建名为`xiaozhi_esp32_server`的数据库。
```sql
CREATE DATABASE xiaozhi_esp32_server CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
如果还没有MySQL,你可以通过docker安装mysql
```
docker run --name xiaozhi-esp32-server-db -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -e MYSQL_DATABASE=xiaozhi_esp32_server -e MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" -d mysql:latest
```
### 2.2 安装Redis
如果还没有Redis,你可以通过docker安装redis
```
docker run --name xiaozhi-esp32-server-redis -d -p 6379:6379 redis
```
## 3. 配置docker-compose_all.yml文件文件
`xiaozhi-server`目录,打开docker-compose_all.yml
1、修改`SPRING_DATASOURCE_DRUID_URL`里,把`192.168.1.25`修改成部署mysql的电脑局域网ip,如果是你本地,你要查看一下你本机的局域网ip是什么
2、修改`SPRING_DATA_REDIS_HOST`里,把`192.168.1.25`修改成部署redis的电脑局域网ip,如果是你本地,你要查看一下你本机的局域网ip是什么
3、确认一下mysql的用户名`SPRING_DATASOURCE_DRUID_USERNAME`和密码`SPRING_DATASOURCE_DRUID_PASSWORD`
## 4. 运行程序
确认mysql和redis是否运行正常,输入
```
docker ps
```
如果你能看到`xiaozhi-esp32-server-redis``xiaozhi-esp32-server-db`信息,就可以继续往下,如果看不到,说明你的`mysql``redis`没有安装或没有启动。需要继续回到上面的教程,看看哪一步漏了。
```
CONTAINER ID IMAGE COMMAND CREATED TATUS PORTS NAMES
xxx redis "xx" xxx xxx 0.0.0.0:6379->6379/tcp xiaozhi-esp32-server-redis
xxx mysql:latest "xx" xxx xxx 0.0.0.0:3306->3306/tcp, 33060/tcp xiaozhi-esp32-server-db
```
如果你之前已经成功运行智控台,如果上面保存有你的密钥信息,请先从智控台上拷贝重要数据下来。因为升级过程中,有可能会覆盖原来的数据。
## 3. 清除历史版本镜像和容器
接下来打开命令行工具,使用`终端``命令行`工具 进入到你的`xiaozhi-server`,执行以下命令
```
docker-compose -f docker-compose_all.yml up -d
docker compose -f docker-compose_all.yml down
docker stop xiaozhi-esp32-server
docker rm xiaozhi-esp32-server
docker stop xiaozhi-esp32-server-web
docker rm xiaozhi-esp32-server-web
docker stop xiaozhi-esp32-server-db
docker rm xiaozhi-esp32-server-db
docker stop xiaozhi-esp32-server-redis
docker rm xiaozhi-esp32-server-redis
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
```
## 4. 运行程序
执行以下命令启动新版本容器
```
docker compose -f docker-compose_all.yml up -d
```
执行完后,再执行以下命令,查看日志信息。
@@ -138,11 +122,11 @@ http://localhost:8002/xiaozhi/doc.html
这时,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8002 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
接下来要做件重要的事情:
接下来要做件重要的事情:
### 第一件重要的事情
使用超级管理员账号,登录系统,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值`
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值`
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
@@ -150,17 +134,21 @@ http://localhost:8002/xiaozhi/doc.html
```
manager-api:
url: http://127.0.0.1:8002/xiaozhi
url: http://127.0.0.1:8002/xiaozhi
secret: 你的server.secret值
```
1、把你刚才从`智控台`复制过来的`server.secret``参数值`复制到`.config.yaml`文件里的`secret`里。
2、`url`里的`127.0.0.1`改成你电脑局域网内的ip
2、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
3、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
4、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
类似这样的效果
```
manager-api:
url: http://192.168.1.25:8002/xiaozhi
url: http://xiaozhi-esp32-server-web:8002/xiaozhi
secret: 12345678-xxxx-xxxx-xxxx-123456789000
```
@@ -168,7 +156,7 @@ manager-api:
### 第二件重要的事情
使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
## 5.重启xiaozhi-esp32-server
@@ -199,26 +187,18 @@ Websocket接口:
ws://你电脑局域网的ip:8000/xiaozhi/v1/
```
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
### 第三件重要的事情
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到参数编码是`server.websocket`,输入你的`Websocket接口`
## 6. 版本升级操作
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到数编码是`server.ota`,输入你的`OTA接口`
如果后期想升级版本,先备份你的模型密钥。
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
进入`xiaozhi-server`目录
1、 [编译自己的esp32固件](firmware-build.md)了。
5.1、执行以下命令
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
```
docker-compose down
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
```
5.2、删掉`docker-compose_all.yaml`文件
5.3、重新按1.1开始部署
# 方式二:本地源码运行全模块
@@ -313,13 +293,13 @@ npm run serve
运行成功后,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8001 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
## 5.安装Python环境
@@ -384,10 +364,13 @@ pip install -r requirements.txt
## 8.配置项目文件
使用超级管理员账号,登录智控台 http://127.0.0.1:8001 ,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值`
使用超级管理员账号,登录智控台 ,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值`
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
如果你的`xiaozhi-server`目录没有`data`,你需要创建`data`目录。
如果你的`data`下面没有`.config.yaml`文件,你可以把`xiaozhi-server`目录下的`config_from_api.yaml`文件复制到`data`,并重命名为`.config.yaml`
复制`参数值`后,打开`xiaozhi-server`下的`data`目录的`.config.yaml`文件。此刻你的配置文件内容应该是这样的:
```
@@ -422,7 +405,7 @@ python app.py
25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
```
由于你是全模块部署,因此你有两个重要的接口需要写入到esp32中
由于你是全模块部署,因此你有两个重要的接口。
OTA接口:
```
@@ -434,7 +417,18 @@ Websocket接口:
ws://你电脑局域网的ip:8000/xiaozhi/v1/
```
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了
请你务必把以上两个接口地址写入到智控台中:他们将会影响websocket地址发放和自动升级功能
1、使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到参数编码是`server.websocket`,输入你的`Websocket接口`
2、使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到数编码是`server.ota`,输入你的`OTA接口`
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 常见问题
+1 -35
View File
@@ -102,41 +102,7 @@ VAD:
### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡
本项目,支持以工具调用的方式控制HomeAssistant设备
1、首先选择一款支持function call支持的LLM,例如`ChatGLMLLM`
2、在配置文件中,将 `selected_module.Intent` 设置为 `function_call`
3、登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
在配置文件中,配置好你的home assistant的`devices`(被控制的设备)和`api_key``base_url`等信息。例如:
``` yaml
plugins
home_assistant:
devices:
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
- 卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1
base_url: http://你的homeassistant地址:8123
api_key: 你的home assistant api访问令牌
```
最后,允许function_call 插件在配置文件中启用`hass_get_state`(必须)、`hass_set_state`(必须)、`hass_play_music`(不想用ha听音乐可以不启动),例如:
``` yaml
Intent:
...
function_call:
type: nointent
functions:
- change_role
- get_weather
- get_news
- hass_get_state
- hass_set_state
- hass_play_music
```
参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md)
### 8、更多问题,可联系我们反馈 💬
+47 -20
View File
@@ -1,26 +1,53 @@
server {
listen 8002;
server_name localhost;
user root;
worker_processes 4;
# 静态资源服务(Vue项目)
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
events {
worker_connections 1024;
}
# API反向代理(Java项目)
location /xiaozhi/ {
proxy_pass http://127.0.0.1:8003;
proxy_set_header Host $host;
proxy_cookie_path /api/ /;
proxy_set_header Referer $http_referer;
proxy_set_header Cookie $http_cookie;
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 300;
client_header_timeout 180s;
client_body_timeout 180s;
client_max_body_size 1024M;
proxy_connect_timeout 10;
proxy_send_timeout 10;
proxy_read_timeout 10;
gzip on;
gzip_buffers 32 4K;
gzip_comp_level 6;
gzip_min_length 100;
gzip_types application/javascript text/css text/xml image/jpeg image/gif image/png;
gzip_disable "MSIE [1-6]\.";
gzip_vary on;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
server {
# 无域名访问,就用localhost
server_name localhost;
# 80端口
listen 8002;
# 转发到编译后到web目录
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# 转发到manager-api
location /xiaozhi/ {
proxy_pass http://127.0.0.1:8003;
proxy_set_header Host $host;
proxy_cookie_path /manager/ /;
proxy_set_header Referer $http_referer;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 10;
proxy_send_timeout 10;
proxy_read_timeout 10;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
+118
View File
@@ -0,0 +1,118 @@
# 编译1.6.0版本esp32固件
## 第1步 配置环境
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
## 第2步 打开配置文件
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
下载后,解压缩包,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
## 第3步 修改WEBSOCKET地址
找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
改成你自己的地址,例如,我的接口地址是`ws://192.168.1.25:8000/xiaozhi/v1/`,就把内容改成这个。
修改前:
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "wss://api.tenclass.net/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
修改后(示例)
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "ws://192.168.1.25:8000/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
## 第4步 设置编译参数
设置编译参数
```
# 终端命令行进入xiaozhi-esp32的根目录
cd xiaozhi-esp32
# 例如我使用的板子是esp32s3,所以设置编译目标为esp32s3,如果你的板子是其他型号,请替换成对应的型号
idf.py set-target esp32s3
# 进入菜单配置
idf.py menuconfig
```
![图片](images/build_setting01.png)
进入菜单配置后,再进入`Xiaozhi Assistant`,将`CONNECTION_TYPE`设置为`Websocket`
回退到主菜单,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
保存退出,回到终端命令行。
![图片](images/build_setting02.png)
## 第5步 编译固件
```
idf.py build
```
## 第6步 打包bin固件
```
cd scripts
python release.py
```
上面的打包命令执行完成后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。
## 第7步 烧录固件
将esp32设备连接电脑,使用chrome浏览器,打开以下网址
```
https://espressif.github.io/esp-launchpad/
```
打开这个教程,[Flash工具/Web端烧录固件(无IDF开发环境)](https://ccnphfhqs21z.feishu.cn/wiki/Zpz4wXBtdimBrLk25WdcXzxcnNS)。
翻到:`方式二:ESP-Launchpad 浏览器WEB端烧录`,从`3. 烧录固件/下载到开发板`开始,按照教程操作。
烧录成功且联网成功后,通过唤醒词唤醒小智,留意server端输出的控制台信息。
## 常见问题
以下是一些常见问题,供参考:
[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)
+44 -55
View File
@@ -1,67 +1,62 @@
# 编译esp32固件
# esp32固件编译
## 第1步 配置环境
## 第1步 准备你的ota地址
如果你按照教程使用的是全模块部署,就应该会有ota地址。
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
```
http://192.168.1.25:8002/xiaozhi/ota/
```
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下。
如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就:
- 1、使用超级管理员登录智控台
- 2、顶部菜单点击`参数管理`
- 3、在列表中找到`server.websocket`项目,输入你的`Websocket`地址。例如我的就是
```
ws://192.168.1.25:8000/xiaozhi/v1/
```
配置完后,再使用浏览器刷新你的ota接口地址,看看是不是正常了。如果还不正常就,就再次确认一下Websocket是否正常启动,是否配置了Websocket地址。
## 第2步 配置环境
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
## 第2步 打开配置文件
配置好编译环境后,下载虾哥[xiaozhi-esp32](https://github.com/78/xiaozhi-esp32)项目源码,进入虾哥项目,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
## 第3步 打开配置文件
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
## 第3步 修改WEBSOCKET地址
找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
改成你自己的地址,例如,我的接口地址是`ws://192.168.1.25:8000/xiaozhi/v1/`,就把内容改成这个。
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32)。
修改前:
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "wss://api.tenclass.net/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
修改后(示例)
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "ws://192.168.1.25:8000/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
下载后,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
## 第4步 修改OTA地址
如果你是全模块部署本项目,就修改OTA接口,如果你只是部署了8000端口的xiaozhi-server,可以继续沿用虾哥团队的OTA接口。如果你不修改OTA接口,请直接忽略本第4步,直接看第5步
找到`OTA_VERSION_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/`
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8000/xiaozhi/ota/`,就把内容改成这个。
找到`OTA_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/`
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。
修改前:
```
config OTA_VERSION_URL
string "OTA Version URL"
config OTA_URL
string "Default OTA URL"
default "https://api.tenclass.net/xiaozhi/ota/"
help
The application will access this URL to check for updates.
The application will access this URL to check for new firmwares and server address.
```
修改后:
```
config OTA_VERSION_URL
string "OTA Version URL"
default "http://192.168.1.25:8000/xiaozhi/ota/"
config OTA_URL
string "Default OTA URL"
default "http://192.168.1.25:8002/xiaozhi/ota/"
help
The application will access this URL to check for updates.
The application will access this URL to check for new firmwares and server address.
```
## 第5步 设置编译参数
## 第4步 设置编译参数
设置编译参数
@@ -82,32 +77,26 @@ idf.py menuconfig
![图片](images/build_setting02.png)
## 第6步 编译固件
## 第5步 编译固件
```
idf.py build
```
如果是vscode安装的idf可以使用`F1`或者`ctrl+shift+p`,输入idf然后直接选择进行编译
还可以直接进行烧录不用接下来的操作
<img src="./images/vscode_idf.png" width="500px"/>
## 第7步 打包bin固件
## 第6步 打包bin固件
```
cd scripts
python release.py
```
编译成功后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
上面的打包命令执行完成后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。
## 第8步 烧录固件
## 第7步 烧录固件
将esp32设备连接电脑,使用chrome浏览器,打开以下网址
```
+53
View File
@@ -0,0 +1,53 @@
# 基于虾哥编译好的固件配置自定义服务器
## 第1步 确认版本
烧录虾哥已经编译好的1.6.1版本固件
## 第2步 准备你的ota地址
如果你按照教程使用的是全模块部署,就应该会有ota地址。
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
```
https://2662r3426b.vicp.fun/xiaozhi/ota/
```
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下。
如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就:
- 1、使用超级管理员登录智控台
- 2、顶部菜单点击`参数管理`
- 3、在列表中找到`server.websocket`项目,输入你的`Websocket`地址。例如我的就是
```
wss://2662r3426b.vicp.fun/xiaozhi/v1/
```
配置完后,再使用浏览器刷新你的ota接口地址,看看是不是正常了。如果还不正常就,就再次确认一下Websocket是否正常启动,是否配置了Websocket地址。
## 第3步 进入配网模式
进入机器的配网模式,在页面顶部,点击“高级选项”,在里面输入你服务器的`ota`地址,点击保存。重启设备
## 第4步 唤醒小智,查看日志输出
唤醒小智,看看日志是不是正常输出。
## 常见问题
以下是一些常见问题,供参考:
[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)
+111
View File
@@ -0,0 +1,111 @@
# ESP32设备与HomeAssistant集成指南
## 简介
本文档将指导您如何将ESP32设备与HomeAssistant进行集成。
## 前提条件
- 已安装并配置好`HomeAssistant`
- 已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`
- `xiaozhi-esp32-server智控台`能正常使用
- 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用
## 网络环境
1、我的`HomeAssistant`部署在下面,版本是`2025.3.4`
```
http://192.168.4.7:8123
```
2、我的`xiaozhi-esp32-server智控台``HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
```
http://192.168.4.7:8002
```
## 配置步骤
### 1. 登录`HomeAssistant`整理需要控制的设备清单
登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`
然后在实体中搜索你相关控制的开关,结果出来后,在列表中,点击其中一个结果,这是会出现一个开关的界面。
在开关的界面,我们尝试点击开关,看看是开发会随着我们的点击开/关。如果能操作,说明是正常联网的。
接着在开关面板找到设置按钮,点击后,可以查看这个开关的`实体标识符`
我们打开一个记事本,按照这样格式整理一条数据:
位置+英文逗号+设备名称+英文逗号+`实体标识符`+英文分号
例如,我在公司,我有一个玩具灯,他的标识符是switch.cuco_cn_460494544_cp1_on_p_2_1,那么就这个写这一条数据
```
公司,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;
```
当然最后我可能要操作两个灯,我的最终的结果是:
```
公司,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;
公司,台灯,switch.iot_cn_831898993_socn1_on_p_2_1;
```
这段字符,我们成为“设备清单字符”需要保存好,等一下有用。
### 2. 登录`HomeAssistant`拿到开发密钥
登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
### 3. 登录`智控台`
使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来
编辑`plugins.home_assistant.devices`,把刚才整理的设备清单字符粘贴进去。
编辑`plugins.home_assistant.base_url`,把你部署的`HomeAssistant`接口地址粘贴进去,我粘贴进去的地址是这样的
```
http://192.168.4.7:8123
```
编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去
### 4. 设置`意图识别`函数
在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑
然后在弹框中,在原来的基础上追加两个函数:“hass_get_state”和“hass_set_state”
修改前
```
change_role;get_weather;get_news;play_music
```
修改后
```
change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state
```
### 6. 手动重启xiaozhi-server
重启xiaozhi-server程序
### 5. 确认角色配置是否设置了函数意图识别
在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色”
确认意图识别(Intent),是否选择“函数调用意图识别”
### 6. 唤醒设别进行控制
尝试和esp32说,“打开XXX灯”
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,17 @@
package xiaozhi.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate配置
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@@ -84,6 +84,26 @@ public interface Constant {
*/
String SERVER_SECRET = "server.secret";
/**
* websocket地址
*/
String SERVER_WEBSOCKET = "server.websocket";
/**
* ota地址
*/
String SERVER_OTA = "server.ota";
/**
* 是否允许用户注册
*/
String SERVER_ALLOW_USER_REGISTER = "server.allow_user_register";
/**
* 下发六位验证码时显示的控制面板地址
*/
String SERVER_FRONTED_URL = "server.fronted_url";
/**
* 路径分割符
*/
@@ -153,4 +173,9 @@ public interface Constant {
return value;
}
}
/**
* 版本号
*/
public static final String VERSION = "0.3.11";
}
@@ -52,4 +52,7 @@ public interface ErrorCode {
int PARAM_BOOLEAN_INVALID = 10038;
int PARAM_ARRAY_INVALID = 10039;
int PARAM_JSON_INVALID = 10040;
int OTA_DEVICE_NOT_FOUND = 10041;
int OTA_DEVICE_NEED_BIND = 10042;
}
@@ -75,4 +75,25 @@ public class RedisKeys {
public static String getTimbreDetailsKey(String id) {
return "timbre:details:" + id;
}
/**
* 获取版本号Key
*/
public static String getVersionKey() {
return "sys:version";
}
/**
* OTA固件ID的Key
*/
public static String getOtaIdKey(String uuid) {
return "ota:id:" + uuid;
}
/**
* OTA固件下载次数的Key
*/
public static String getOtaDownloadCountKey(String uuid) {
return "ota:download:count:" + uuid;
}
}
@@ -1,11 +1,14 @@
package xiaozhi.common.redis;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
@@ -27,7 +30,7 @@ public class RedisUtils {
/**
* 过期时长为1小时,单位:秒
*/
public final static long HOUR_ONE_EXPIRE = 60 * 60 * 1L;
public final static long HOUR_ONE_EXPIRE = (long) 60 * 60;
/**
* 过期时长为6小时,单位:秒
*/
@@ -124,4 +127,24 @@ public class RedisUtils {
public Object rightPop(String key) {
return redisTemplate.opsForList().rightPop(key);
}
/**
* 清空所有 Redis 数据库中的所有键
*/
public void emptyAll() {
// Lua 脚本 FLUSHALL是redis清空所有库的命令
String luaScript ="redis.call('FLUSHALL')";
// 创建 DefaultRedisScript 对象
DefaultRedisScript<Void> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript); // 设置 Lua 脚本内容
redisScript.setResultType(Void.class); // 设置返回值类型
// 执行 Lua 脚本
List<String> keys = Collections.emptyList(); // 如果脚本不依赖 key,可以传入空列表
redisTemplate.execute(redisScript, keys);
}
}
@@ -3,6 +3,7 @@ package xiaozhi.modules.agent.service.impl;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import xiaozhi.modules.agent.dao.AgentTemplateDao;
@@ -39,29 +40,30 @@ public class AgentTemplateServiceImpl extends ServiceImpl<AgentTemplateDao, Agen
@Override
public void updateDefaultTemplateModelId(String modelType, String modelId) {
modelType = modelType.toUpperCase();
AgentTemplateEntity defaultTemplate = getDefaultTemplate();
if (defaultTemplate != null) {
switch (modelType) {
case "ASR":
defaultTemplate.setAsrModelId(modelId);
break;
case "VAD":
defaultTemplate.setVadModelId(modelId);
break;
case "LLM":
defaultTemplate.setLlmModelId(modelId);
break;
case "TTS":
defaultTemplate.setTtsModelId(modelId);
break;
case "Memory":
defaultTemplate.setMemModelId(modelId);
break;
case "Intent":
defaultTemplate.setIntentModelId(modelId);
break;
}
this.updateById(defaultTemplate);
UpdateWrapper<AgentTemplateEntity> wrapper = new UpdateWrapper<>();
switch (modelType) {
case "ASR":
wrapper.set("asr_model_id", modelId);
break;
case "VAD":
wrapper.set("vad_model_id", modelId);
break;
case "LLM":
wrapper.set("llm_model_id", modelId);
break;
case "TTS":
wrapper.set("tts_model_id", modelId);
wrapper.set("tts_voice_id", null);
break;
case "MEMORY":
wrapper.set("mem_model_id", modelId);
break;
case "INTENT":
wrapper.set("intent_model_id", modelId);
break;
}
wrapper.ge("sort", 0);
update(wrapper);
}
}
@@ -5,6 +5,9 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import jakarta.annotation.PostConstruct;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.sys.service.SysParamsService;
@@ -18,8 +21,20 @@ public class SystemInitConfig {
@Autowired
private ConfigService configService;
@Autowired
private RedisUtils redisUtils;
@PostConstruct
public void init() {
// 检查版本号
String redisVersion = (String) redisUtils.get(RedisKeys.getVersionKey());
if (!Constant.VERSION.equals(redisVersion)) {
// 如果版本不一致,清空Redis
redisUtils.emptyAll();
// 存储新版本号
redisUtils.set(RedisKeys.getVersionKey(), Constant.VERSION);
}
sysParamsService.initServerSecret();
configService.getConfig(false);
}
@@ -9,6 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
@@ -83,8 +84,14 @@ public class ConfigServiceImpl implements ConfigService {
// 根据MAC地址查找设备
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) {
throw new RenException("设备未找到");
// 如果设备,去redis里看看有没有需要连接的设备
String cachedCode = deviceService.geCodeByDeviceId(macAddress);
if (StringUtils.isNotBlank(cachedCode)) {
throw new RenException(ErrorCode.OTA_DEVICE_NEED_BIND, cachedCode);
}
throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND, "not found device");
}
// 获取智能体信息
AgentEntity agent = agentService.getAgentById(device.getAgentId());
if (agent == null) {
@@ -98,7 +105,9 @@ public class ConfigServiceImpl implements ConfigService {
}
// 构建返回数据
Map<String, Object> result = new HashMap<>();
// 获取单台设备每天最多输出字数
String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
result.put("device_max_output_size", deviceMaxOutputSize);
// 如果客户端已实例化模型,则不返回
String alreadySelectedVadModelId = (String) selectedModule.get("VAD");
if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) {
@@ -171,7 +180,13 @@ public class ConfigServiceImpl implements ConfigService {
switch (param.getValueType().toLowerCase()) {
case "number":
try {
current.put(lastKey, Double.parseDouble(value));
double doubleValue = Double.parseDouble(value);
// 如果数值是整数形式,则转换为Integer
if (doubleValue == (int) doubleValue) {
current.put(lastKey, (int) doubleValue);
} else {
current.put(lastKey, doubleValue);
}
} catch (NumberFormatException e) {
current.put(lastKey, value);
}
@@ -255,6 +270,12 @@ public class ConfigServiceImpl implements ConfigService {
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
intentLLMModelId = null;
}
} else if ("function_call".equals(map.get("type"))) {
String functionStr = (String) map.get("functions");
if (StringUtils.isNotBlank(functionStr)) {
String[] functions = functionStr.split("\\;");
map.put("functions", functions);
}
}
}
// 如果是LLM类型,且intentLLMModelId不为空,则添加附加模型
@@ -270,7 +291,7 @@ public class ConfigServiceImpl implements ConfigService {
result.put("selected_module", selectedModule);
if (StringUtils.isNotBlank(prompt)) {
prompt = prompt.replace("{{assistant_name}}", "小智");
prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
}
result.put("prompt", prompt);
}
@@ -7,6 +7,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -79,4 +80,16 @@ public class DeviceController {
return new Result<Void>();
}
@PutMapping("/enableOta/{id}/{status}")
@Operation(summary = "启用/关闭OTA自动升级")
@RequiresPermissions("sys:role:normal")
public Result<Void> enableOtaUpgrade(@PathVariable String id, @PathVariable Integer status) {
DeviceEntity entity = deviceService.selectById(id);
if (entity == null) {
return new Result<Void>().error("设备不存在");
}
entity.setAutoUpdate(status);
deviceService.updateById(entity);
return new Result<Void>();
}
}
@@ -15,48 +15,79 @@ import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.utils.NetworkUtil;
import xiaozhi.modules.sys.service.SysParamsService;
@Tag(name = "设备管理", description = "OTA 相关接口")
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/ota/")
public class OTAController {
private final DeviceService deviceService;
private final SysParamsService sysParamsService;
@Operation(summary = "检查 OTA 版本和设备激活状态")
@Operation(summary = "OTA版本和设备激活状态检查")
@PostMapping
public ResponseEntity<String> checkOTAVersion(
@RequestBody DeviceReportReqDTO deviceReportReqDTO,
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
@Parameter(name = "Client-Id", description = "客户端标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Client-Id") String clientId) {
if (StringUtils.isAnyBlank(deviceId, clientId)) {
@Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
if (StringUtils.isBlank(deviceId)) {
return createResponse(DeviceReportRespDTO.createError("Device ID is required"));
}
if (StringUtils.isBlank(clientId)) {
clientId = deviceId;
}
String macAddress = deviceReportReqDTO.getMacAddress();
boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress);
// 设备Id和Mac地址应是一致的, 并且必须需要application字段
if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) {
return createResponse(DeviceReportRespDTO.createError("Invalid OTA request"));
}
return createResponse(deviceService.checkDeviceActive(macAddress, deviceId, clientId, deviceReportReqDTO));
return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
}
@Operation(summary = "设备快速检查激活状态")
@PostMapping("activate")
public ResponseEntity<String> activateDevice(
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
@Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
if (StringUtils.isBlank(deviceId)) {
return ResponseEntity.status(202).build();
}
DeviceEntity device = deviceService.getDeviceByMacAddress(deviceId);
if (device == null) {
return ResponseEntity.status(202).build();
}
return ResponseEntity.ok("success");
}
@Operation(summary = "获取 OTA 提示信息")
@GetMapping
public ResponseEntity<String> getOTAPrompt() {
return createResponse(DeviceReportRespDTO.createError("请提交正确的ota参数"));
@Hidden
public ResponseEntity<String> getOTA() {
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
return ResponseEntity.ok("OTA接口不正常,缺少websocket地址,请登录智控台,在参数管理找到【server.websocket】配置");
}
String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
return ResponseEntity.ok("OTA接口不正常,缺少ota地址,请登录智控台,在参数管理找到【server.ota】配置");
}
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
}
@SneakyThrows
@@ -0,0 +1,291 @@
package xiaozhi.modules.device.controller;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.OtaService;
@Tag(name = "设备管理", description = "OTA 相关接口")
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/otaMag")
public class OTAMagController {
private static final Logger logger = LoggerFactory.getLogger(OTAController.class);
private final OtaService otaService;
private final RedisUtils redisUtils;
@GetMapping
@Operation(summary = "分页查询 OTA 固件信息")
@Parameters({
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true)
})
@RequiresPermissions("sys:role:superAdmin")
public Result<PageData<OtaEntity>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
ValidatorUtils.validateEntity(params);
PageData<OtaEntity> page = otaService.page(params);
return new Result<PageData<OtaEntity>>().ok(page);
}
@GetMapping("{id}")
@Operation(summary = "信息 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<OtaEntity> get(@PathVariable("id") String id) {
OtaEntity data = otaService.selectById(id);
return new Result<OtaEntity>().ok(data);
}
@PostMapping
@Operation(summary = "保存 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> save(@RequestBody OtaEntity entity) {
if (entity == null) {
return new Result<Void>().error("固件信息不能为空");
}
if (StringUtils.isBlank(entity.getFirmwareName())) {
return new Result<Void>().error("固件名称不能为空");
}
if (StringUtils.isBlank(entity.getType())) {
return new Result<Void>().error("固件类型不能为空");
}
if (StringUtils.isBlank(entity.getVersion())) {
return new Result<Void>().error("版本号不能为空");
}
try {
otaService.save(entity);
return new Result<Void>();
} catch (RuntimeException e) {
return new Result<Void>().error(e.getMessage());
}
}
@DeleteMapping("/{id}")
@Operation(summary = "OTA 删除")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> delete(@PathVariable("id") String[] ids) {
if (ids == null || ids.length == 0) {
return new Result<Void>().error("删除的固件ID不能为空");
}
otaService.delete(ids);
return new Result<Void>();
}
@PutMapping("/{id}")
@Operation(summary = "修改 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<?> update(@PathVariable("id") String id, @RequestBody OtaEntity entity) {
if (entity == null) {
return new Result<>().error("固件信息不能为空");
}
entity.setId(id);
try {
otaService.update(entity);
return new Result<>();
} catch (RuntimeException e) {
return new Result<>().error(e.getMessage());
}
}
@GetMapping("/getDownloadUrl/{id}")
@Operation(summary = "获取 OTA 固件下载链接")
@RequiresPermissions("sys:role:superAdmin")
public Result<String> getDownloadUrl(@PathVariable("id") String id) {
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getOtaIdKey(uuid), id);
return new Result<String>().ok(uuid);
}
@GetMapping("/download/{uuid}")
@Operation(summary = "下载固件文件")
public ResponseEntity<byte[]> downloadFirmware(@PathVariable("uuid") String uuid) {
String id = (String) redisUtils.get(RedisKeys.getOtaIdKey(uuid));
if (StringUtils.isBlank(id)) {
return ResponseEntity.notFound().build();
}
// 检查下载次数
String downloadCountKey = RedisKeys.getOtaDownloadCountKey(uuid);
Integer downloadCount = (Integer) redisUtils.get(downloadCountKey);
if (downloadCount == null) {
downloadCount = 0;
}
// 如果下载次数超过3次,返回404
if (downloadCount >= 3) {
redisUtils.delete(downloadCountKey);
redisUtils.delete(RedisKeys.getOtaIdKey(uuid));
logger.warn("Download limit exceeded for UUID: {}", uuid);
return ResponseEntity.notFound().build();
}
redisUtils.set(downloadCountKey, downloadCount + 1);
try {
// 获取固件信息
OtaEntity otaEntity = otaService.selectById(id);
if (otaEntity == null || StringUtils.isBlank(otaEntity.getFirmwarePath())) {
logger.warn("Firmware not found or path is empty for ID: {}", id);
return ResponseEntity.notFound().build();
}
// 获取文件路径 - 确保路径是绝对路径或正确的相对路径
String firmwarePath = otaEntity.getFirmwarePath();
Path path;
// 检查是否是绝对路径
if (Paths.get(firmwarePath).isAbsolute()) {
path = Paths.get(firmwarePath);
} else {
// 如果是相对路径,则从当前工作目录解析
path = Paths.get(System.getProperty("user.dir"), firmwarePath);
}
logger.info("Attempting to download firmware for ID: {}, DB path: {}, resolved path: {}",
id, firmwarePath, path.toAbsolutePath());
if (!Files.exists(path) || !Files.isRegularFile(path)) {
// 尝试直接从firmware目录下查找文件名
String fileName = new File(firmwarePath).getName();
Path altPath = Paths.get(System.getProperty("user.dir"), "firmware", fileName);
logger.info("File not found at primary path, trying alternative path: {}", altPath.toAbsolutePath());
if (Files.exists(altPath) && Files.isRegularFile(altPath)) {
path = altPath;
} else {
logger.error("Firmware file not found at either path: {} or {}",
path.toAbsolutePath(), altPath.toAbsolutePath());
return ResponseEntity.notFound().build();
}
}
// 读取文件内容
byte[] fileContent = Files.readAllBytes(path);
// 设置响应头
String originalFilename = otaEntity.getType() + "_" + otaEntity.getVersion();
if (firmwarePath.contains(".")) {
String extension = firmwarePath.substring(firmwarePath.lastIndexOf("."));
originalFilename += extension;
}
// 清理文件名,移除不安全字符
String safeFilename = originalFilename.replaceAll("[^a-zA-Z0-9._-]", "_");
logger.info("Providing download for firmware ID: {}, filename: {}, size: {} bytes",
id, safeFilename, fileContent.length);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeFilename + "\"")
.body(fileContent);
} catch (IOException e) {
logger.error("Error reading firmware file for ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
} catch (Exception e) {
logger.error("Unexpected error during firmware download for ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/upload")
@Operation(summary = "上传固件文件")
@RequiresPermissions("sys:role:superAdmin")
public Result<String> uploadFirmware(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return new Result<String>().error("上传文件不能为空");
}
// 检查文件扩展名
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
return new Result<String>().error("文件名不能为空");
}
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
if (!extension.equals(".bin") && !extension.equals(".apk")) {
return new Result<String>().error("只允许上传.bin和.apk格式的文件");
}
try {
// 计算文件的MD5值
String md5 = calculateMD5(file);
// 设置存储路径
String uploadDir = "uploadfile";
Path uploadPath = Paths.get(uploadDir);
// 如果目录不存在,创建目录
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
}
// 使用MD5作为文件名,固定使用.bin扩展名
String uniqueFileName = md5 + extension;
Path filePath = uploadPath.resolve(uniqueFileName);
// 检查文件是否已存在
if (Files.exists(filePath)) {
return new Result<String>().ok(filePath.toString());
}
// 保存文件
Files.copy(file.getInputStream(), filePath);
// 返回文件路径
return new Result<String>().ok(filePath.toString());
} catch (IOException | NoSuchAlgorithmException e) {
return new Result<String>().error("文件上传失败:" + e.getMessage());
}
}
private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(file.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
@@ -0,0 +1,15 @@
package xiaozhi.modules.device.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.device.entity.OtaEntity;
/**
* OTA固件管理
*/
@Mapper
public interface OtaDao extends BaseMapper<OtaEntity> {
}
@@ -19,6 +19,9 @@ public class DeviceReportRespDTO {
@Schema(description = "固件版本信息")
private Firmware firmware;
@Schema(description = "WebSocket配置")
private Websocket websocket;
@Getter
@Setter
@@ -44,6 +47,8 @@ public class DeviceReportRespDTO {
@Schema(description = "激活码信息: 激活地址")
private String message;
@Schema(description = "挑战码")
private String challenge;
}
@Getter
@@ -58,4 +63,11 @@ public class DeviceReportRespDTO {
@Schema(description = "时区偏移量,单位为分钟")
private Integer timezone_offset;
}
@Getter
@Setter
public static class Websocket {
@Schema(description = "WebSocket服务器地址")
private String url;
}
}
@@ -0,0 +1,61 @@
package xiaozhi.modules.device.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("ai_ota")
@Schema(description = "固件信息")
public class OtaEntity {
@TableId(type = IdType.ASSIGN_UUID)
@Schema(description = "ID")
private String id;
@Schema(description = "固件名称")
private String firmwareName;
@Schema(description = "固件类型")
private String type;
@Schema(description = "版本号")
private String version;
@Schema(description = "文件大小(字节)")
private Long size;
@Schema(description = "备注/说明")
private String remark;
@Schema(description = "固件路径")
private String firmwarePath;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "更新者")
@TableField(fill = FieldFill.UPDATE)
private Long updater;
@Schema(description = "更新时间")
@TableField(fill = FieldFill.UPDATE)
private Date updateDate;
@Schema(description = "创建者")
@TableField(fill = FieldFill.INSERT)
private Long creator;
@Schema(description = "创建时间")
@TableField(fill = FieldFill.INSERT)
private Date createDate;
}
@@ -3,23 +3,19 @@ package xiaozhi.modules.device.service;
import java.util.List;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.device.dto.DevicePageUserDTO;
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
public interface DeviceService {
/**
* 根据Mac地址获取设备信息
*/
DeviceEntity getDeviceById(String macAddress);
public interface DeviceService extends BaseService<DeviceEntity> {
/**
* 检查设备是否激活
*/
DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId,
DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
DeviceReportReqDTO deviceReport);
/**
@@ -74,4 +70,12 @@ public interface DeviceService {
* @return 设备信息
*/
DeviceEntity getDeviceByMacAddress(String macAddress);
/**
* 根据设备ID获取激活码
*
* @param deviceId 设备ID
* @return 激活码
*/
String geCodeByDeviceId(String deviceId);
}
@@ -0,0 +1,22 @@
package xiaozhi.modules.device.service;
import java.util.Map;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.device.entity.OtaEntity;
/**
* OTA固件管理
*/
public interface OtaService extends BaseService<OtaEntity> {
PageData<OtaEntity> page(Map<String, Object> params);
boolean save(OtaEntity entity);
void update(OtaEntity entity);
void delete(String[] ids);
OtaEntity getLatestOta(String type);
}
@@ -6,22 +6,26 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.util.RandomUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
@@ -31,38 +35,24 @@ import xiaozhi.modules.device.dto.DevicePageUserDTO;
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.service.OtaService;
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserUtilService;
@Slf4j
@Service
@AllArgsConstructor
public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService {
private final DeviceDao deviceDao;
private final SysUserUtilService sysUserUtilService;
private final String frontedUrl;
private final RedisTemplate<String, Object> redisTemplate;
// 添加构造函数来初始化 deviceMapper
public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
@Value("${app.fronted-url:http://localhost:8001}") String frontedUrl,
RedisTemplate<String, Object> redisTemplate) {
this.deviceDao = deviceDao;
this.sysUserUtilService = sysUserUtilService;
this.frontedUrl = frontedUrl;
this.redisTemplate = redisTemplate;
}
@Override
public DeviceEntity getDeviceById(String deviceId) {
LambdaQueryWrapper<DeviceEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DeviceEntity::getId, deviceId);
return deviceDao.selectOne(queryWrapper);
}
private final SysParamsService sysParamsService;
private final RedisUtils redisUtils;
private final OtaService otaService;
@Override
public Boolean deviceActivation(String agentId, String activationCode) {
@@ -70,14 +60,14 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
throw new RenException("激活码不能为空");
}
String deviceKey = "ota:activation:code:" + activationCode;
Object cacheDeviceId = redisTemplate.opsForValue().get(deviceKey);
Object cacheDeviceId = redisUtils.get(deviceKey);
if (cacheDeviceId == null) {
throw new RenException("激活码错误");
}
String deviceId = (String) cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(cacheDeviceKey);
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(cacheDeviceKey);
if (cacheMap == null) {
throw new RenException("激活码错误");
}
@@ -107,6 +97,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
deviceEntity.setMacAddress(macAddress);
deviceEntity.setUserId(user.getId());
deviceEntity.setCreator(user.getId());
deviceEntity.setAutoUpdate(1);
deviceEntity.setCreateDate(currentTime);
deviceEntity.setUpdater(user.getId());
deviceEntity.setUpdateDate(currentTime);
@@ -114,64 +105,60 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
deviceDao.insert(deviceEntity);
// 清理redis缓存
redisTemplate.delete(cacheDeviceKey);
redisTemplate.delete(deviceKey);
redisUtils.delete(cacheDeviceKey);
redisUtils.delete(deviceKey);
return true;
}
@Override
public DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId,
public DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO response = new DeviceReportRespDTO();
response.setServer_time(buildServerTime());
// todo: 此处是固件信息,目前是针对固件上传上来的版本号再返回回去
// 在未来开发了固件更新功能,需要更换此处代码,
// 或写定时任务定期请求虾哥的OTA,获取最新的版本讯息保存到服务内
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
firmware.setVersion(deviceReport.getApplication().getVersion());
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
response.setFirmware(firmware);
DeviceEntity deviceById = getDeviceById(deviceId);
if (deviceById != null) { // 如果设备存在,则更新上次连接时间
deviceById.setLastConnectedAt(new Date());
deviceDao.updateById(deviceById);
} else { // 如果设备不存在,则生成激活码
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey);
DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
if (deviceById == null || deviceById.getAutoUpdate() != 0) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
response.setFirmware(firmware);
} else {
response.setFirmware(null);
}
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code");
code.setCode(cachedCode);
code.setMessage(frontedUrl + "\n" + cachedCode);
// 添加WebSocket配置
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
websocket.setUrl(wsUrl);
} else {
String[] wsUrls = wsUrl.split("\\;");
if (wsUrls.length > 0) {
// 随机选择一个WebSocket URL
websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
} else {
String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode);
code.setMessage(frontedUrl + "\n" + newCode);
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", deviceId);
dataMap.put("mac_address", macAddress);
dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName()
: (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown"));
dataMap.put("app_version", (deviceReport.getApplication() != null)
? deviceReport.getApplication().getVersion()
: null);
dataMap.put("deviceId", deviceId);
dataMap.put("activation_code", newCode);
// 写入主数据 key
redisTemplate.opsForHash().putAll(dataKey, dataMap);
redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
// 写入反查激活码 key
String codeKey = "ota:activation:code:" + newCode;
redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS);
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
}
}
response.setWebsocket(websocket);
if (deviceById != null) {
// 如果设备存在,则更新上次连接时间
deviceById.setLastConnectedAt(new Date());
if (deviceReport.getApplication() != null
&& StringUtils.isNotBlank(deviceReport.getApplication().getVersion())) {
deviceById.setAppVersion(deviceReport.getApplication().getVersion());
}
deviceDao.updateById(deviceById);
} else {
// 如果设备不存在,则生成激活码
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
response.setActivation(code);
}
@@ -258,4 +245,129 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
serverTime.setTimezone_offset(tz.getOffset(System.currentTimeMillis()) / (60 * 1000));
return serverTime;
}
@Override
public String geCodeByDeviceId(String deviceId) {
String dataKey = getDeviceCacheKey(deviceId);
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(dataKey);
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code");
return cachedCode;
}
return null;
}
private String getDeviceCacheKey(String deviceId) {
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
return dataKey;
}
public DeviceReportRespDTO.Activation buildActivation(String deviceId, DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
String cachedCode = geCodeByDeviceId(deviceId);
if (StringUtils.isNotBlank(cachedCode)) {
code.setCode(cachedCode);
String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
code.setMessage(frontedUrl + "\n" + cachedCode);
code.setChallenge(deviceId);
} else {
String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode);
String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
code.setMessage(frontedUrl + "\n" + newCode);
code.setChallenge(deviceId);
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", deviceId);
dataMap.put("mac_address", deviceId);
dataMap.put("board", (deviceReport.getBoard() != null && deviceReport.getBoard().getType() != null)
? deviceReport.getBoard().getType()
: (deviceReport.getChipModelName() != null ? deviceReport.getChipModelName() : "unknown"));
dataMap.put("app_version", (deviceReport.getApplication() != null)
? deviceReport.getApplication().getVersion()
: null);
dataMap.put("deviceId", deviceId);
dataMap.put("activation_code", newCode);
// 写入主数据 key
String dataKey = getDeviceCacheKey(deviceId);
redisUtils.set(dataKey, dataMap);
// 写入反查激活码 key
String codeKey = "ota:activation:code:" + newCode;
redisUtils.set(codeKey, deviceId);
}
return code;
}
private DeviceReportRespDTO.Firmware buildFirmwareInfo(String type, String currentVersion) {
if (StringUtils.isBlank(type)) {
return null;
}
if (StringUtils.isBlank(currentVersion)) {
currentVersion = "0.0.0";
}
OtaEntity ota = otaService.getLatestOta(type);
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
String downloadUrl = null;
if (ota != null) {
// 如果设备没有版本信息,或者OTA版本比设备版本新,则返回下载地址
if (compareVersions(ota.getVersion(), currentVersion) > 0) {
String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
log.error("OTA地址未配置,请登录智控台,在参数管理找到【server.ota】配置");
// 尝试从请求中获取
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes())
.getRequest();
otaUrl = request.getRequestURL().toString();
}
// 将URL中的/ota/替换为/otaMag/download/
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getOtaIdKey(uuid), ota.getId());
downloadUrl = otaUrl.replace("/ota/", "/otaMag/download/") + uuid;
}
}
firmware.setVersion(ota == null ? currentVersion : ota.getVersion());
firmware.setUrl(downloadUrl == null ? "" : downloadUrl);
return firmware;
}
/**
* 比较两个版本号
*
* @param version1 版本1
* @param version2 版本2
* @return 如果version1 > version2返回1version1 < version2返回-1,相等返回0
*/
private static int compareVersions(String version1, String version2) {
if (version1 == null || version2 == null) {
return 0;
}
String[] v1Parts = version1.split("\\.");
String[] v2Parts = version2.split("\\.");
int length = Math.max(v1Parts.length, v2Parts.length);
for (int i = 0; i < length; i++) {
int v1 = i < v1Parts.length ? Integer.parseInt(v1Parts[i]) : 0;
int v2 = i < v2Parts.length ? Integer.parseInt(v2Parts[i]) : 0;
if (v1 > v2) {
return 1;
} else if (v1 < v2) {
return -1;
}
}
return 0;
}
}
@@ -0,0 +1,85 @@
package xiaozhi.modules.device.service.impl;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.micrometer.common.util.StringUtils;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.modules.device.dao.OtaDao;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.OtaService;
@Service
public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implements OtaService {
@Override
public PageData<OtaEntity> page(Map<String, Object> params) {
IPage<OtaEntity> page = baseDao.selectPage(
getPage(params, "update_date", true),
getWrapper(params));
return new PageData<>(page.getRecords(), page.getTotal());
}
private QueryWrapper<OtaEntity> getWrapper(Map<String, Object> params) {
String firmwareName = (String) params.get("firmwareName");
QueryWrapper<OtaEntity> wrapper = new QueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(firmwareName), "firmware_name", firmwareName);
return wrapper;
}
@Override
public void update(OtaEntity entity) {
// 检查是否存在相同类型和版本的固件(排除当前记录)
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
.eq("type", entity.getType())
.eq("version", entity.getVersion())
.ne("id", entity.getId()); // 排除当前记录
if (baseDao.selectCount(queryWrapper) > 0) {
throw new RuntimeException("已存在相同类型和版本的固件,请修改后重试");
}
entity.setUpdateDate(new Date());
baseDao.updateById(entity);
}
@Override
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
}
@Override
public boolean save(OtaEntity entity) {
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
.eq("type", entity.getType());
// 同类固件只保留最新的一条
List<OtaEntity> otaList = baseDao.selectList(queryWrapper);
if (otaList != null && otaList.size() > 0) {
OtaEntity otaBefore = otaList.getFirst();
entity.setId(otaBefore.getId());
baseDao.updateById(entity);
return true;
}
return baseDao.insert(entity) > 0;
}
@Override
public OtaEntity getLatestOta(String type) {
QueryWrapper<OtaEntity> wrapper = new QueryWrapper<>();
wrapper.eq("type", type)
.orderByDesc("update_date")
.last("LIMIT 1");
return baseDao.selectOne(wrapper);
}
}
@@ -135,6 +135,7 @@ public class ModelController {
}
// 将其他模型设置为非默认
modelConfigService.setDefaultModel(entity.getModelType(), 0);
entity.setIsEnabled(1);
entity.setIsDefault(1);
modelConfigService.updateById(entity);
@@ -42,6 +42,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
List<ModelConfigEntity> entities = modelConfigDao.selectList(
new QueryWrapper<ModelConfigEntity>()
.eq("model_type", modelType)
.eq("is_enabled", 1)
.like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%")
.select("id", "model_name"));
return ConvertUtils.sourceToTarget(entities, ModelBasicInfoDTO.class);
@@ -69,6 +69,7 @@ public class ShiroConfig {
*/
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/ota/**", "anon");
filterMap.put("/otaMag/download/**", "anon");
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/v3/api-docs/**", "anon");
@@ -76,6 +77,7 @@ public class ShiroConfig {
filterMap.put("/favicon.ico", "anon");
filterMap.put("/user/captcha", "anon");
filterMap.put("/user/login", "anon");
filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/config/server-base", "anon");
filterMap.put("/config/agent-models", "anon");
@@ -33,11 +33,15 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// 特殊用途的转换器
converters.add(new ByteArrayHttpMessageConverter());
converters.add(new StringHttpMessageConverter());
converters.add(new ResourceHttpMessageConverter());
converters.add(new AllEncompassingFormHttpMessageConverter());
// 通用转换器
converters.add(new StringHttpMessageConverter());
converters.add(new AllEncompassingFormHttpMessageConverter());
// JSON 转换器
converters.add(jackson2HttpMessageConverter());
}
@@ -1,6 +1,8 @@
package xiaozhi.modules.security.controller;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -13,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO;
@@ -75,6 +78,9 @@ public class LoginController {
@PostMapping("/register")
@Operation(summary = "注册")
public Result<Void> register(@RequestBody LoginDTO login) {
if (!sysUserService.getAllowUserRegister()) {
throw new RenException("当前不允许普通用户注册");
}
// 验证是否正确输入验证码
boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha());
if (!validate) {
@@ -111,4 +117,13 @@ public class LoginController {
sysUserTokenService.changePassword(userId, passwordDTO);
return new Result<>();
}
@GetMapping("/pub-config")
@Operation(summary = "公共配置")
public Result<Map<String, Object>> pubConfig() {
Map<String, Object> config = new HashMap<>();
config.put("version", Constant.VERSION);
config.put("allowUserRegister", sysUserService.getAllowUserRegister());
return new Result<Map<String, Object>>().ok(config);
}
}
@@ -2,7 +2,10 @@ package xiaozhi.modules.sys.controller;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -11,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -20,6 +24,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import xiaozhi.common.annotation.LogOperation;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.AssertUtils;
@@ -27,8 +32,10 @@ import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.common.validator.group.AddGroup;
import xiaozhi.common.validator.group.DefaultGroup;
import xiaozhi.common.validator.group.UpdateGroup;
import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.sys.dto.SysParamsDTO;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.utils.WebSocketValidator;
/**
* 参数管理
@@ -42,6 +49,8 @@ import xiaozhi.modules.sys.service.SysParamsService;
@AllArgsConstructor
public class SysParamsController {
private final SysParamsService sysParamsService;
private final ConfigService configService;
private final RestTemplate restTemplate;
@GetMapping("page")
@Operation(summary = "分页")
@@ -77,7 +86,7 @@ public class SysParamsController {
ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class);
sysParamsService.save(dto);
configService.getConfig(false);
return new Result<Void>();
}
@@ -89,11 +98,91 @@ public class SysParamsController {
// 效验数据
ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class);
sysParamsService.update(dto);
// 验证WebSocket地址列表
validateWebSocketUrls(dto.getParamCode(), dto.getParamValue());
// 验证OTA地址
validateOtaUrl(dto.getParamCode(), dto.getParamValue());
sysParamsService.update(dto);
configService.getConfig(false);
return new Result<Void>();
}
/**
* 验证WebSocket地址列表
*
* @param urls WebSocket地址列表,以分号分隔
* @return 验证结果
*/
private void validateWebSocketUrls(String paramCode, String urls) {
if (!paramCode.equals(Constant.SERVER_WEBSOCKET)) {
return;
}
String[] wsUrls = urls.split("\\;");
if (wsUrls.length == 0) {
throw new RenException("WebSocket地址列表不能为空");
}
for (String url : wsUrls) {
if (StringUtils.isNotBlank(url)) {
// 检查是否包含localhost或127.0.0.1
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException("WebSocket地址不能使用localhost或127.0.0.1");
}
// 验证WebSocket地址格式
if (!WebSocketValidator.validateUrlFormat(url)) {
throw new RenException("WebSocket地址格式不正确: " + url);
}
// 测试WebSocket连接
if (!WebSocketValidator.testConnection(url)) {
throw new RenException("WebSocket连接测试失败: " + url);
}
}
}
}
/**
* 验证OTA地址
*/
private void validateOtaUrl(String paramCode, String url) {
if (!paramCode.equals(Constant.SERVER_OTA)) {
return;
}
if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException("OTA地址不能为空");
}
// 检查是否包含localhost或127.0.0.1
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException("OTA地址不能使用localhost或127.0.0.1");
}
// 验证URL格式
if (!url.toLowerCase().startsWith("http")) {
throw new RenException("OTA地址必须以http或https开头");
}
if (!url.endsWith("/ota/")) {
throw new RenException("OTA地址必须以/ota/结尾");
}
try {
// 发送GET请求
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
if (response.getStatusCode() != HttpStatus.OK) {
throw new RenException("OTA接口访问失败,状态码:" + response.getStatusCode());
}
// 检查响应内容是否包含OTA相关信息
String body = response.getBody();
if (body == null || !body.contains("OTA")) {
throw new RenException("OTA接口返回内容格式不正确,可能不是一个真实的OTA接口");
}
} catch (Exception e) {
throw new RenException("OTA接口验证失败:" + e.getMessage());
}
}
@PostMapping("/delete")
@Operation(summary = "删除")
@LogOperation("删除")
@@ -103,7 +192,7 @@ public class SysParamsController {
AssertUtils.isArrayEmpty(ids, "id");
sysParamsService.delete(ids);
configService.getConfig(false);
return new Result<Void>();
}
}
@@ -65,4 +65,11 @@ public interface SysUserService extends BaseService<SysUserEntity> {
* @param userIds 用户ID数组
*/
void changeStatus(Integer status, String[] userIds);
/**
* 获取是否允许用户注册
*
* @return 是否允许用户注册
*/
boolean getAllowUserRegister();
}
@@ -30,6 +30,7 @@ import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.entity.SysUserEntity;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserService;
import xiaozhi.modules.sys.vo.AdminPageUserVO;
@@ -45,6 +46,8 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
private final AgentService agentService;
private final SysParamsService sysParamsService;
@Override
public SysUserDTO getByUsername(String username) {
QueryWrapper<SysUserEntity> queryWrapper = new QueryWrapper<>();
@@ -205,4 +208,17 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
updateById(entity);
}
}
@Override
public boolean getAllowUserRegister() {
String allowUserRegister = sysParamsService.getValue(Constant.SERVER_ALLOW_USER_REGISTER, true);
if (allowUserRegister.equals("true")) {
return true;
}
Long userCount = baseDao.selectCount(new QueryWrapper<SysUserEntity>());
if (userCount == 0) {
return true;
}
return false;
}
}
@@ -0,0 +1,46 @@
package xiaozhi.modules.sys.utils;
import java.util.concurrent.CompletableFuture;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
public class WebSocketTestHandler implements WebSocketHandler {
private final CompletableFuture<Boolean> future;
public WebSocketTestHandler(CompletableFuture<Boolean> future) {
this.future = future;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
future.complete(true);
try {
session.close();
} catch (Exception e) {
// 忽略关闭异常
}
}
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) {
// 不需要处理消息
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
future.complete(false);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
// 连接关闭时不做任何处理
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}
@@ -0,0 +1,58 @@
package xiaozhi.modules.sys.utils;
import java.net.URI;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
public class WebSocketValidator {
private static final Logger logger = LoggerFactory.getLogger(WebSocketValidator.class);
// WebSocket URL正则表达式
private static final Pattern WS_URL_PATTERN = Pattern
.compile("^wss?://[\\w.-]+(?:\\.[\\w.-]+)*(?::\\d+)?(?:/[\\w.-]*)*$");
/**
* 验证WebSocket地址格式
*
* @param url WebSocket地址
* @return 是否有效
*/
public static boolean validateUrlFormat(String url) {
if (StringUtils.isBlank(url)) {
return false;
}
return WS_URL_PATTERN.matcher(url.trim()).matches();
}
/**
* 测试WebSocket连接
*
* @param url WebSocket地址
* @return 是否可连接
*/
public static boolean testConnection(String url) {
if (!validateUrlFormat(url)) {
return false;
}
try {
WebSocketClient client = new StandardWebSocketClient();
CompletableFuture<Boolean> future = new CompletableFuture<>();
client.doHandshake(new WebSocketTestHandler(future), null, URI.create(url));
// 等待最多5秒获取连接结果
return future.get(5, TimeUnit.SECONDS);
} catch (Exception e) {
logger.error("WebSocket连接测试失败: {}", url, e);
return false;
}
}
}
@@ -24,7 +24,6 @@ public class TimbreDataDTO {
private String name;
@Schema(description = "备注")
@NotBlank(message = "{timbre.remark.require}")
private String remark;
@Schema(description = "排序")
@@ -40,6 +39,5 @@ public class TimbreDataDTO {
private String ttsVoice;
@Schema(description = "音频播放地址")
@NotBlank(message = "{timbre.voiceDemo.require}")
private String voiceDemo;
}
@@ -63,7 +63,4 @@ mybatis-plus:
configuration-properties:
prefix:
blobType: BLOB
boolValue: TRUE
app:
fronted-url: http://localhost:8001
boolValue: TRUE
@@ -1,3 +0,0 @@
-- 对0.3.0版本之前的参数进行修改
update `sys_params` set param_value = '.mp3;.wav;.p3' where param_code = 'plugins.play_music.music_ext';
update `ai_model_config` set config_json = '{\"type\": \"intent_llm\", \"llm\": \"LLM_ChatGLMLLM\"}' where id = 'Intent_intent_llm';
@@ -15,4 +15,14 @@ INSERT INTO `ai_tts_voice` VALUES
('TTS_EdgeTTS0008', 'TTS_EdgeTTS', 'EdgeTTS女声-陕西小妮', 'zh-CN-shaanxi-XiaoniNeural', '陕西', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0009', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海佳', 'zh-HK-HiuGaaiNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0010', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海曼', 'zh-HK-HiuMaanNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0011', 'TTS_EdgeTTS', 'EdgeTTS男声-香港万龙', 'zh-HK-WanLungNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL);
('TTS_EdgeTTS0011', 'TTS_EdgeTTS', 'EdgeTTS男声-香港万龙', 'zh-HK-WanLungNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL);
-- 增加是否允许用户注册参数
delete from `sys_params` where id in (103,104);
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (103, 'server.allow_user_register', 'false', 'boolean', 1, '是否运行管理员以外的人注册');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (104, 'server.fronted_url', 'http://xiaozhi.server.com', 'string', 1, '下发六位验证码时显示的控制面板地址');
-- 修正CosyVoiceSiliconflow音色
delete from `ai_tts_voice` where tts_model_id = 'TTS_CosyVoiceSiliconflow';
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'FunAudioLLM/CosyVoice2-0.5B:alex', '中文', 'https://example.com/cosyvoice/alex.mp3', NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0002', 'TTS_CosyVoiceSiliconflow', 'CosyVoice女声', 'FunAudioLLM/CosyVoice2-0.5B:bella', '中文', 'https://example.com/cosyvoice/bella.mp3', NULL, 6, NULL, NULL, NULL, NULL);
@@ -0,0 +1,7 @@
-- 调整意图识别配置
delete from `ai_model_config` where id = 'Intent_function_call';
INSERT INTO `ai_model_config` VALUES ('Intent_function_call', 'Intent', 'function_call', '函数调用意图识别', 0, 1, '{\"type\": \"function_call\", \"functions\": \"change_role;get_weather;get_news;play_music\"}', NULL, NULL, 3, NULL, NULL, NULL, NULL);
-- 增加单台设备每天最多聊天句数
delete from `sys_params` where id = 105;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (105, 'device_max_output_size', '0', 'number', 1, '单台设备每天最多输出字数,0表示不限制');
@@ -0,0 +1,88 @@
-- 删除无用模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_LLM_doubao';
delete from `ai_model_provider` where id = 'SYSTEM_LLM_chatglm';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_302ai';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_gizwits';
-- 添加模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_ASR_TencentASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_TencentASR', 'ASR', 'tencent', '腾讯语音识别', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 4, 1, NOW(), 1, NOW());
-- 添加腾讯语音合成模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_TTS_TencentTTS';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_TTS_TencentTTS', 'TTS', 'tencent', '腾讯语音合成', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"region","label":"区域","type":"string"},{"key":"voice","label":"音色ID","type":"string"}]', 5, 1, NOW(), 1, NOW());
-- 添加edge音色
delete from `ai_tts_voice` where id in ('TTS_EdgeTTS0001', 'TTS_EdgeTTS0002', 'TTS_EdgeTTS0003', 'TTS_EdgeTTS0004', 'TTS_EdgeTTS0005', 'TTS_EdgeTTS0006', 'TTS_EdgeTTS0007', 'TTS_EdgeTTS0008', 'TTS_EdgeTTS0009', 'TTS_EdgeTTS0010', 'TTS_EdgeTTS0011');
INSERT INTO `ai_tts_voice` VALUES
('TTS_EdgeTTS0001', 'TTS_EdgeTTS', 'EdgeTTS女声-晓晓', 'zh-CN-XiaoxiaoNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0002', 'TTS_EdgeTTS', 'EdgeTTS男声-云扬', 'zh-CN-YunyangNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0003', 'TTS_EdgeTTS', 'EdgeTTS女声-晓伊', 'zh-CN-XiaoyiNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0004', 'TTS_EdgeTTS', 'EdgeTTS男声-云健', 'zh-CN-YunjianNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0005', 'TTS_EdgeTTS', 'EdgeTTS男声-云希', 'zh-CN-YunxiNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0006', 'TTS_EdgeTTS', 'EdgeTTS男声-云夏', 'zh-CN-YunxiaNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0007', 'TTS_EdgeTTS', 'EdgeTTS女声-辽宁小贝', 'zh-CN-liaoning-XiaobeiNeural', '辽宁', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0008', 'TTS_EdgeTTS', 'EdgeTTS女声-陕西小妮', 'zh-CN-shaanxi-XiaoniNeural', '陕西', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0009', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海佳', 'zh-HK-HiuGaaiNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0010', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海曼', 'zh-HK-HiuMaanNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0011', 'TTS_EdgeTTS', 'EdgeTTS男声-香港万龙', 'zh-HK-WanLungNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL);
-- DoubaoTTS音色
delete from `ai_tts_voice` where id in ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS0002', 'TTS_DoubaoTTS0003', 'TTS_DoubaoTTS0004', 'TTS_DoubaoTTS0005');
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS', '通用女声', 'BV001_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV001.mp3', NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0002', 'TTS_DoubaoTTS', '通用男声', 'BV002_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV002.mp3', NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0003', 'TTS_DoubaoTTS', '阳光男生', 'BV056_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV056.mp3', NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0004', 'TTS_DoubaoTTS', '奶气萌娃', 'BV051_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV051.mp3', NULL, 5, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0005', 'TTS_DoubaoTTS', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '普通话', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 6, NULL, NULL, NULL, NULL);
-- 修正CosyVoiceSiliconflow音色
delete from `ai_tts_voice` where id in ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow0002');
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'FunAudioLLM/CosyVoice2-0.5B:alex', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0002', 'TTS_CosyVoiceSiliconflow', 'CosyVoice女声', 'FunAudioLLM/CosyVoice2-0.5B:bella', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
-- CozeCnTTS音色
delete from `ai_tts_voice` where id = 'TTS_CozeCnTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_CozeCnTTS0001', 'TTS_CozeCnTTS', 'CozeCn音色', '7426720361733046281', '中文', NULL, NULL, 7, NULL, NULL, NULL, NULL);
-- MinimaxTTS音色
delete from `ai_tts_voice` where id = 'TTS_MinimaxTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxTTS0001', 'TTS_MinimaxTTS', 'Minimax少女音', 'female-shaonv', '中文', NULL, NULL, 8, NULL, NULL, NULL, NULL);
-- AliyunTTS音色
delete from `ai_tts_voice` where id = 'TTS_AliyunTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunTTS0001', 'TTS_AliyunTTS', '阿里云小云', 'xiaoyun', '中文', NULL, NULL, 9, NULL, NULL, NULL, NULL);
-- TTS302AI音色
delete from `ai_tts_voice` where id = 'TTS_TTS302AI0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_TTS302AI0001', 'TTS_TTS302AI', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 10, NULL, NULL, NULL, NULL);
-- GizwitsTTS音色
delete from `ai_tts_voice` where id = 'TTS_GizwitsTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_GizwitsTTS0001', 'TTS_GizwitsTTS', '机智云湾湾', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 11, NULL, NULL, NULL, NULL);
-- ACGNTTS音色
delete from `ai_tts_voice` where id = 'TTS_ACGNTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_ACGNTTS0001', 'TTS_ACGNTTS', 'ACG音色', '1695', '中文', NULL, NULL, 12, NULL, NULL, NULL, NULL);
-- OpenAITTS音色
delete from `ai_tts_voice` where id = 'TTS_OpenAITTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_OpenAITTS0001', 'TTS_OpenAITTS', 'OpenAI男声', 'onyx', '中文', NULL, NULL, 13, NULL, NULL, NULL, NULL);
-- 添加腾讯语音合成音色
delete from `ai_tts_voice` where id = 'TTS_TencentTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_TencentTTS0001', 'TTS_TencentTTS', '智瑜', '101001', '中文', NULL, NULL, 1, NULL, NULL, NULL, NULL);
-- 其他音色
delete from `ai_tts_voice` where id = 'TTS_FishSpeech0000';
INSERT INTO `ai_tts_voice` VALUES ('TTS_FishSpeech0000', 'TTS_FishSpeech', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
delete from `ai_tts_voice` where id = 'TTS_GPT_SOVITS_V20000';
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V20000', 'TTS_GPT_SOVITS_V2', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
delete from `ai_tts_voice` where id in ('TTS_GPT_SOVITS_V30000', 'TTS_CustomTTS0000');
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V30000', 'TTS_GPT_SOVITS_V3', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CustomTTS0000', 'TTS_CustomTTS', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
@@ -0,0 +1,49 @@
-- 添加火山引擎边缘大模型网关(LLM + TTS)
INSERT INTO `ai_model_config` VALUES ('LLM_VolcesAiGatewayLLM', 'LLM', 'VolcesAiGatewayTTS', '火山引擎边缘大模型网关', 0, 1, '{\"type\": \"openai\", \"base_url\": \"https://ai-gateway.vei.volces.com/v1\", \"model_name\": \"doubao-pro-32k-functioncall\", \"api_key\": \"你的网关访问密钥\"}', NULL, NULL, 14, NULL, NULL, NULL, NULL);
INSERT INTO `ai_model_config` VALUES ('TTS_VolcesAiGatewayTTS', 'TTS', 'VolcesAiGatewayTTS', '火山引擎边缘大模型网关', 0, 1, '{\"type\": \"openai\", \"api_key\": \"你的网关访问密钥\", \"api_url\": \"https://ai-gateway.vei.volces.com/v1/audio/speech\", \"model\": \"doubao-tts\", \"voice\": \"zh_male_shaonianzixin_moon_bigtts\", \"speed\": 1, \"output_dir\": \"tmp/\"}', NULL, NULL, 15, NULL, NULL, NULL, NULL);
-- 添加火山引擎边缘大模型网关语音合成音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0001', 'TTS_VolcesAiGatewayTTS', '灿灿/Shiny', 'zh_female_cancan_mars_bigtts', '中文、美式英语', NULL, NULL, 1, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0002', 'TTS_VolcesAiGatewayTTS', '清新女声', 'zh_female_qingxinnvsheng_mars_bigtts', '中文', NULL, NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0003', 'TTS_VolcesAiGatewayTTS', '爽快思思/Skye', 'zh_female_shuangkuaisisi_moon_bigtts', '中文、美式英语', NULL, NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0004', 'TTS_VolcesAiGatewayTTS', '温暖阿虎/Alvin', 'zh_male_wennuanahu_moon_bigtts', '中文、美式英语', NULL, NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0005', 'TTS_VolcesAiGatewayTTS', '少年梓辛/Brayan', 'zh_male_shaonianzixin_moon_bigtts', '中文、美式英语', NULL, NULL, 5, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0006', 'TTS_VolcesAiGatewayTTS', '知性女声', 'zh_female_zhixingnvsheng_mars_bigtts', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0007', 'TTS_VolcesAiGatewayTTS', '清爽男大', 'zh_male_qingshuangnanda_mars_bigtts', '中文', NULL, NULL, 7, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0008', 'TTS_VolcesAiGatewayTTS', '邻家女孩', 'zh_female_linjianvhai_moon_bigtts', '中文', NULL, NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0009', 'TTS_VolcesAiGatewayTTS', '渊博小叔', 'zh_male_yuanboxiaoshu_moon_bigtts', '中文', NULL, NULL, 9, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0010', 'TTS_VolcesAiGatewayTTS', '阳光青年', 'zh_male_yangguangqingnian_moon_bigtts', '中文', NULL, NULL, 10, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0011', 'TTS_VolcesAiGatewayTTS', '甜美小源', 'zh_female_tianmeixiaoyuan_moon_bigtts', '中文', NULL, NULL, 11, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0012', 'TTS_VolcesAiGatewayTTS', '清澈梓梓', 'zh_female_qingchezizi_moon_bigtts', '中文', NULL, NULL, 12, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0013', 'TTS_VolcesAiGatewayTTS', '解说小明', 'zh_male_jieshuoxiaoming_moon_bigtts', '中文', NULL, NULL, 13, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0014', 'TTS_VolcesAiGatewayTTS', '开朗姐姐', 'zh_female_kailangjiejie_moon_bigtts', '中文', NULL, NULL, 14, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0015', 'TTS_VolcesAiGatewayTTS', '邻家男孩', 'zh_male_linjiananhai_moon_bigtts', '中文', NULL, NULL, 15, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0016', 'TTS_VolcesAiGatewayTTS', '甜美悦悦', 'zh_female_tianmeiyueyue_moon_bigtts', '中文', NULL, NULL, 16, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0017', 'TTS_VolcesAiGatewayTTS', '心灵鸡汤', 'zh_female_xinlingjitang_moon_bigtts', '中文', NULL, NULL, 17, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0018', 'TTS_VolcesAiGatewayTTS', '知性温婉', 'ICL_zh_female_zhixingwenwan_tob', '中文', NULL, NULL, 18, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0019', 'TTS_VolcesAiGatewayTTS', '暖心体贴', 'ICL_zh_male_nuanxintitie_tob', '中文', NULL, NULL, 19, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0020', 'TTS_VolcesAiGatewayTTS', '温柔文雅', 'ICL_zh_female_wenrouwenya_tob', '中文', NULL, NULL, 20, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0021', 'TTS_VolcesAiGatewayTTS', '开朗轻快', 'ICL_zh_male_kailangqingkuai_tob', '中文', NULL, NULL, 21, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0022', 'TTS_VolcesAiGatewayTTS', '活泼爽朗', 'ICL_zh_male_huoposhuanglang_tob', '中文', NULL, NULL, 22, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0023', 'TTS_VolcesAiGatewayTTS', '率真小伙', 'ICL_zh_male_shuaizhenxiaohuo_tob', '中文', NULL, NULL, 23, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0024', 'TTS_VolcesAiGatewayTTS', '温柔小哥', 'zh_male_wenrouxiaoge_mars_bigtts', '中文', NULL, NULL, 24, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0025', 'TTS_VolcesAiGatewayTTS', 'Smith', 'en_male_smith_mars_bigtts', '英式英语', NULL, NULL, 25, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0026', 'TTS_VolcesAiGatewayTTS', 'Anna', 'en_female_anna_mars_bigtts', '英式英语', NULL, NULL, 26, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0027', 'TTS_VolcesAiGatewayTTS', 'Adam', 'en_male_adam_mars_bigtts', '美式英语', NULL, NULL, 27, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0028', 'TTS_VolcesAiGatewayTTS', 'Sarah', 'en_female_sarah_mars_bigtts', '澳洲英语', NULL, NULL, 28, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0029', 'TTS_VolcesAiGatewayTTS', 'Dryw', 'en_male_dryw_mars_bigtts', '澳洲英语', NULL, NULL, 29, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0030', 'TTS_VolcesAiGatewayTTS', 'かずね(和音)', 'multi_male_jingqiangkanye_moon_bigtts', '日语、西语', NULL, NULL, 30, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0031', 'TTS_VolcesAiGatewayTTS', 'はるこ(晴子)', 'multi_female_shuangkuaisisi_moon_bigtts', '日语、西语', NULL, NULL, 31, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0032', 'TTS_VolcesAiGatewayTTS', 'ひろし(広志)', 'multi_male_wanqudashu_moon_bigtts', '日语、西语', NULL, NULL, 32, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0033', 'TTS_VolcesAiGatewayTTS', 'あけみ(朱美)', 'multi_female_gaolengyujie_moon_bigtts', '日语', NULL, NULL, 33, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0034', 'TTS_VolcesAiGatewayTTS', 'Amanda', 'en_female_amanda_mars_bigtts', '美式英语', NULL, NULL, 34, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0035', 'TTS_VolcesAiGatewayTTS', 'Jackson', 'en_male_jackson_mars_bigtts', '美式英语', NULL, NULL, 35, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0036', 'TTS_VolcesAiGatewayTTS', '京腔侃爷/Harmony', 'zh_male_jingqiangkanye_moon_bigtts', '中文-北京口音、英文', NULL, NULL, 36, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0037', 'TTS_VolcesAiGatewayTTS', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文-台湾口音', NULL, NULL, 37, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0038', 'TTS_VolcesAiGatewayTTS', '湾区大叔', 'zh_female_wanqudashu_moon_bigtts', '中文-广东口音', NULL, NULL, 38, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0039', 'TTS_VolcesAiGatewayTTS', '呆萌川妹', 'zh_female_daimengchuanmei_moon_bigtts', '中文-四川口音', NULL, NULL, 39, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0040', 'TTS_VolcesAiGatewayTTS', '广州德哥', 'zh_male_guozhoudege_moon_bigtts', '中文-广东口音', NULL, NULL, 40, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0041', 'TTS_VolcesAiGatewayTTS', '北京小爷', 'zh_male_beijingxiaoye_moon_bigtts', '中文-北京口音', NULL, NULL, 41, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0042', 'TTS_VolcesAiGatewayTTS', '浩宇小哥', 'zh_male_haoyuxiaoge_moon_bigtts', '中文-青岛口音', NULL, NULL, 42, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0043', 'TTS_VolcesAiGatewayTTS', '广西远舟', 'zh_male_guangxiyuanzhou_moon_bigtts', '中文-广西口音', NULL, NULL, 43, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0044', 'TTS_VolcesAiGatewayTTS', '妹坨洁儿', 'zh_female_meituojieer_moon_bigtts', '中文-长沙口音', NULL, NULL, 44, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0045', 'TTS_VolcesAiGatewayTTS', '豫州子轩', 'zh_male_yuzhouzixuan_moon_bigtts', '中文-河南口音', NULL, NULL, 45, NULL, NULL, NULL, NULL);
@@ -0,0 +1,30 @@
-- 增加server.ota,用于配置ota地址
delete from `sys_params` where id = 100;
delete from `sys_params` where id = 101;
delete from `sys_params` where id = 106;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (106, 'server.websocket', 'null', 'string', 1, 'websocket地址,多个用;分隔');
delete from `sys_params` where id = 107;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (107, 'server.ota', 'null', 'string', 1, 'ota地址');
-- 增加固件信息表
CREATE TABLE IF NOT EXISTS `ai_ota` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`firmware_name` varchar(100) DEFAULT NULL COMMENT '固件名称',
`type` varchar(50) DEFAULT NULL COMMENT '固件类型',
`version` varchar(50) DEFAULT NULL COMMENT '版本号',
`size` bigint DEFAULT NULL COMMENT '文件大小(字节)',
`remark` varchar(500) DEFAULT NULL COMMENT '备注/说明',
`firmware_path` varchar(255) DEFAULT NULL COMMENT '固件路径',
`sort` int unsigned DEFAULT '0' COMMENT '排序',
`updater` bigint DEFAULT NULL COMMENT '更新者',
`update_date` datetime DEFAULT NULL COMMENT '更新时间',
`creator` bigint DEFAULT NULL COMMENT '创建者',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='固件信息表';
update ai_device set auto_update = 1;
@@ -1,44 +0,0 @@
-- 本文件用于初始化音色数据,无需手动执行,在项目启动时会自动执行
-- ----------------------------------------------------
-- 初始化音色数据
DELETE FROM `ai_tts_voice`;
-- EdgeTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_EdgeTTS0001', 'TTS_EdgeTTS', 'EdgeTTS女声', 'zh-CN-XiaoxiaoNeural', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/EdgeTTS.mp3', NULL, 1, NULL, NULL, NULL, NULL);
-- DoubaoTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS', '通用女声', 'BV001_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV001.mp3', NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0002', 'TTS_DoubaoTTS', '通用男声', 'BV002_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV002.mp3', NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0003', 'TTS_DoubaoTTS', '阳光男生', 'BV056_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV056.mp3', NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0004', 'TTS_DoubaoTTS', '奶气萌娃', 'BV051_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV051.mp3', NULL, 5, NULL, NULL, NULL, NULL);
-- CosyVoiceSiliconflow音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'alex', '中文', 'https://example.com/cosyvoice/alex.mp3', NULL, 6, NULL, NULL, NULL, NULL);
-- CozeCnTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_CozeCnTTS0001', 'TTS_CozeCnTTS', 'CozeCn音色', '7426720361733046281', '中文', 'https://example.com/cozecn/voice.mp3', NULL, 7, NULL, NULL, NULL, NULL);
-- MinimaxTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxTTS0001', 'TTS_MinimaxTTS', 'Minimax少女音', 'female-shaonv', '中文', 'https://example.com/minimax/female-shaonv.mp3', NULL, 8, NULL, NULL, NULL, NULL);
-- AliyunTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunTTS0001', 'TTS_AliyunTTS', '阿里云小云', 'xiaoyun', '中文', 'https://example.com/aliyun/xiaoyun.mp3', NULL, 9, NULL, NULL, NULL, NULL);
-- TTS302AI音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_TTS302AI0001', 'TTS_TTS302AI', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://example.com/302ai/wanwanxiaohe.mp3', NULL, 10, NULL, NULL, NULL, NULL);
-- GizwitsTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_GizwitsTTS0001', 'TTS_GizwitsTTS', '机智云湾湾', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://example.com/gizwits/wanwanxiaohe.mp3', NULL, 11, NULL, NULL, NULL, NULL);
-- ACGNTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_ACGNTTS0001', 'TTS_ACGNTTS', 'ACG音色', '1695', '中文', 'https://example.com/acgn/voice.mp3', NULL, 12, NULL, NULL, NULL, NULL);
-- OpenAITTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_OpenAITTS0001', 'TTS_OpenAITTS', 'OpenAI男声', 'onyx', '中文', 'https://example.com/openai/onyx.mp3', NULL, 13, NULL, NULL, NULL, NULL);
-- 其他音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_FishSpeech0000', 'TTS_FishSpeech', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V20000', 'TTS_GPT_SOVITS_V2', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V30000', 'TTS_GPT_SOVITS_V3', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CustomTTS0000', 'TTS_CustomTTS', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
@@ -16,13 +16,6 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202503141346.sql
- changeSet:
id: 2025_tts_voive
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/2025_tts_voive.sql
- changeSet:
id: 202504082211
author: John
@@ -52,9 +45,37 @@ databaseChangeLog:
encoding: utf8
path: classpath:db/changelog/202504112058.sql
- changeSet:
id: 202504131543
id: 202504151206
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504131543.sql
path: classpath:db/changelog/202504151206.sql
- changeSet:
id: 202504181536
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504181536.sql
- changeSet:
id: 202504221135
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504221135.sql
- changeSet:
id: 202504221555
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504221555.sql
- changeSet:
id: 202504251422
author: jiangkunyin
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504251422.sql
@@ -41,3 +41,6 @@
10038=\u53C2\u6570\u503C\u5FC5\u987B\u662Ftrue\u6216false
10039=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u6570\u7EC4\u683C\u5F0F
10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8BBE\u5907\u672A\u627E\u5230
10042={0}
@@ -40,4 +40,7 @@
10037=Parameter value must be a valid number
10038=Parameter value must be true or false
10039=Parameter value must be a valid JSON array format
10040=Parameter value must be a valid JSON format
10040=Parameter value must be a valid JSON format
10041=Device not found
10042={0}
@@ -40,4 +40,7 @@
10037=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684\u6570\u5B57
10038=\u53C2\u6570\u503C\u5FC5\u987B\u662Ftrue\u6216false
10039=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u6570\u7EC4\u683C\u5F0F
10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8BBE\u5907\u672A\u627E\u5230
10042={0}
@@ -40,4 +40,7 @@
10037=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684\u6578\u5B57
10038=\u53C3\u6578\u503C\u5FC5\u9808\u662Ftrue\u6216false
10039=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u6578\u7D44\u683C\u5F0F
10040=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10040=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8A2D\u5099\u672A\u627E\u5230
10042={0}
@@ -28,8 +28,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8BBE\u5907\u672A\u627E\u5230
ota.device.need.bind={0}
@@ -27,8 +27,8 @@ sysuser.uuid.require=The unique identifier cannot be empty
timbre.languages.require=The language of the timbre cannot be empty
timbre.name.require=The name of the timbre cannot be empty
timbre.remark.require=The remark of the timbre cannot be empty
timbre.ttsModelId.require=The TTS model ID of the timbre cannot be empty
timbre.ttsVoice.require=The TTS voice of the timbre cannot be empty
timbre.voiceDemo.require=The voice demo of the timbre cannot be empty
ota.device.not.found=Device not found
ota.device.need.bind={0}
@@ -27,8 +27,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8A9E\u8A00\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u7A31\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5099\u6CE8\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u9375\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7DE8\u78BC\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653E\u5730\u5740\u4E0D\u53EF\u4EE5\u70BA\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0}
@@ -27,7 +27,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0}
+1 -4
View File
@@ -1,4 +1 @@
# 暂时使用群主的接口
# VUE_APP_API_BASE_URL=https://2662r3426b.vicp.fun/xiaozhi
# 如果本地开发,请使用以下接口
VUE_APP_API_BASE_URL=http://localhost:8002/xiaozhi
VUE_APP_API_BASE_URL=/xiaozhi
+4 -1
View File
@@ -1 +1,4 @@
VUE_APP_API_BASE_URL=/xiaozhi
VUE_APP_API_BASE_URL=/xiaozhi
VUE_APP_PUBLIC_PATH=/
# 是否开启CDN
VUE_APP_USE_CDN=false
+3249 -11
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -10,6 +10,7 @@
"dependencies": {
"core-js": "^3.41.0",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"element-ui": "^2.15.14",
"flyio": "^0.6.14",
"normalize.css": "^8.0.1",
@@ -31,7 +32,8 @@
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14",
"webpack-bundle-analyzer": "^4.10.2"
"webpack-bundle-analyzer": "^4.10.2",
"workbox-webpack-plugin": "^7.3.0"
},
"browserslist": [
"> 1%",
@@ -43,4 +45,4 @@
"*.scss",
"*.vue"
]
}
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -9,7 +9,7 @@
<title>
<%= process.env.VUE_APP_TITLE %>
</title>
<% if (htmlWebpackPlugin.options.cdn) { %>
<% if (htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.css) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.css) { %>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>">
<% } %>
@@ -23,7 +23,7 @@
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<% if (htmlWebpackPlugin.options.cdn) { %>
<% if (htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.js) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.js) { %>
<script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
<% } %>
+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线模式 - 小智控制台</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f5f7fa;
color: #333;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
max-width: 80%;
}
h1 {
margin-bottom: 1rem;
color: #409EFF;
}
p {
margin-bottom: 1.5rem;
line-height: 1.6;
}
.btn {
background-color: #409EFF;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #337ecc;
}
.icon {
font-size: 4rem;
margin-bottom: 1rem;
color: #409EFF;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📶</div>
<h1>您当前处于离线模式</h1>
<p>看起来您的网络连接有问题,无法连接到小智控制台服务器。</p>
<p>部分已缓存的内容和静态资源可能仍然可用,但功能可能受到限制。</p>
<button class="btn" onclick="window.location.reload()">重新尝试连接</button>
</div>
<script>
// 检测网络状态变化
window.addEventListener('online', () => {
window.location.reload();
});
</script>
</body>
</html>
+115
View File
@@ -1,6 +1,7 @@
<template>
<div id="app">
<router-view />
<cache-viewer v-if="isCDNEnabled" :visible.sync="showCacheViewer" />
</div>
</template>
@@ -45,4 +46,118 @@ nav {
}
</style>
<script>
import CacheViewer from '@/components/CacheViewer.vue';
import { logCacheStatus } from '@/utils/cacheViewer';
export default {
components: {
CacheViewer
},
data() {
return {
showCacheViewer: false,
isCDNEnabled: process.env.VUE_APP_USE_CDN === 'true'
};
},
mounted() {
// 只有在启用CDN时才添加相关事件和功能
if (this.isCDNEnabled) {
// 添加全局快捷键Alt+C用于显示缓存查看器
document.addEventListener('keydown', this.handleKeyDown);
// 在全局对象上添加缓存检查方法,便于调试
window.checkCDNCacheStatus = () => {
this.showCacheViewer = true;
};
// 在控制台输出提示信息
console.info(
'%c[小智服务] CDN缓存检查工具已加载',
'color: #409EFF; font-weight: bold;'
);
console.info(
'按下 Alt+C 组合键或在控制台运行 checkCDNCacheStatus() 可以查看CDN缓存状态'
);
// 检查Service Worker状态
this.checkServiceWorkerStatus();
} else {
console.info(
'%c[小智服务] CDN模式已禁用,使用本地打包资源',
'color: #67C23A; font-weight: bold;'
);
}
},
beforeDestroy() {
// 只有在启用CDN时才需要移除事件监听
if (this.isCDNEnabled) {
document.removeEventListener('keydown', this.handleKeyDown);
}
},
methods: {
handleKeyDown(e) {
// Alt+C 快捷键
if (e.altKey && e.key === 'c') {
this.showCacheViewer = true;
}
},
async checkServiceWorkerStatus() {
// 检查Service Worker是否已注册
if ('serviceWorker' in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
if (registrations.length > 0) {
console.info(
'%c[小智服务] Service Worker已注册',
'color: #67C23A; font-weight: bold;'
);
// 输出缓存状态到控制台
setTimeout(async () => {
const hasCaches = await logCacheStatus();
if (!hasCaches) {
console.info(
'%c[小智服务] 还未检测到缓存,请刷新页面或等待缓存建立',
'color: #E6A23C; font-weight: bold;'
);
// 开发环境下提供额外提示
if (process.env.NODE_ENV === 'development') {
console.info(
'%c[小智服务] 在开发环境中,Service Worker可能无法正常初始化缓存',
'color: #E6A23C; font-weight: bold;'
);
console.info('请尝试以下方法检查Service Worker是否生效:');
console.info('1. 在开发者工具的Application/Application标签页中查看Service Worker状态');
console.info('2. 在开发者工具的Application/Cache/Cache Storage中查看缓存内容');
console.info('3. 使用生产构建(npm run build)并通过HTTP服务器访问以测试完整功能');
}
}
}, 2000);
} else {
console.info(
'%c[小智服务] Service Worker未注册,CDN资源可能无法缓存',
'color: #F56C6C; font-weight: bold;'
);
if (process.env.NODE_ENV === 'development') {
console.info(
'%c[小智服务] 在开发环境中,这是正常现象',
'color: #E6A23C; font-weight: bold;'
);
console.info('Service Worker通常只在生产环境中生效');
console.info('要测试Service Worker功能:');
console.info('1. 运行npm run build构建生产版本');
console.info('2. 通过HTTP服务器访问构建后的页面');
}
}
} catch (error) {
console.error('检查Service Worker状态失败:', error);
}
} else {
console.warn('当前浏览器不支持Service WorkerCDN资源缓存功能不可用');
}
}
}
};
</script>
+2
View File
@@ -5,6 +5,7 @@ import device from './module/device.js'
import model from './module/model.js'
import timbre from "./module/timbre.js"
import user from './module/user.js'
import ota from './module/ota.js'
/**
* 接口地址
@@ -31,4 +32,5 @@ export default {
device,
model,
timbre,
ota
}
@@ -51,4 +51,20 @@ export default {
});
}).send();
},
enableOtaUpgrade(id, status, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/enableOta/${id}/${status}`)
.method('PUT')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('更新OTA状态失败:', err)
this.$message.error(err.msg || '更新OTA状态失败')
RequestService.reAjaxFun(() => {
this.enableOtaUpgrade(id, status, callback)
})
}).send()
},
}
+123
View File
@@ -0,0 +1,123 @@
import { getServiceUrl } from '../api';
import RequestService from '../httpRequest';
export default {
// 分页查询OTA固件信息
getOtaList(params, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag`)
.method('GET')
.data(params)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取OTA固件列表失败:', err);
RequestService.reAjaxFun(() => {
this.getOtaList(params, callback);
});
}).send();
},
// 获取单个OTA固件信息
getOtaInfo(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.getOtaInfo(id, callback);
});
}).send();
},
// 保存OTA固件信息
saveOta(entity, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag`)
.method('POST')
.data(entity)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('保存OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.saveOta(entity, callback);
});
}).send();
},
// 更新OTA固件信息
updateOta(id, entity, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('PUT')
.data(entity)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('更新OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.updateOta(id, entity, callback);
});
}).send();
},
// 删除OTA固件
deleteOta(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('DELETE')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('删除OTA固件失败:', err);
RequestService.reAjaxFun(() => {
this.deleteOta(id, callback);
});
}).send();
},
// 上传固件文件
uploadFirmware(file, callback) {
const formData = new FormData();
formData.append('file', file);
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/upload`)
.method('POST')
.data(formData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('上传固件文件失败:', err);
RequestService.reAjaxFun(() => {
this.uploadFirmware(file, callback);
});
}).send();
},
// 获取固件下载链接
getDownloadUrl(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/getDownloadUrl/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取下载链接失败:', err);
RequestService.reAjaxFun(() => {
this.getDownloadUrl(id, callback);
});
}).send();
}
}
+17 -1
View File
@@ -106,7 +106,7 @@ export default {
},
// 修改用户状态
changeUserStatus(status, userIds, successCallback) {
console.log(555,userIds)
console.log(555, userIds)
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/users/changeStatus/${status}`)
.method('put')
@@ -122,4 +122,20 @@ export default {
})
}).send()
},
// 获取公共配置
getPubConfig(callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/user/pub-config`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取公共配置失败:', err);
RequestService.reAjaxFun(() => {
this.getPubConfig(callback);
});
}).send();
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -1,5 +1,5 @@
<template>
<el-dialog :visible.sync="visible" width="400px" center>
<el-dialog :visible="visible" @close="handleClose" width="400px" center>
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
@@ -79,7 +79,10 @@ export default {
cancel() {
this.$emit('update:visible', false)
this.deviceCode = ""
}
},
handleClose() {
this.$emit('update:visible', false);
},
}
}
</script>
@@ -1,12 +1,11 @@
<template>
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="975px" center custom-class="custom-dialog" :show-close="false"
class="center-dialog">
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="975px" center
custom-class="custom-dialog" :show-close="false" class="center-dialog">
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
添加模型
</div>
<button class="custom-close-btn" @click="handleClose">
×
</button>
@@ -19,7 +18,7 @@
<span style="margin-right: 8px;">是否启用</span>
<el-switch v-model="formData.isEnabled" class="custom-switch"></el-switch>
</div>
<div style="display: flex; align-items: center;">
<div style="display: none; align-items: center;">
<span style="margin-right: 8px;">设为默认</span>
<el-switch v-model="formData.isDefault" class="custom-switch"></el-switch>
</div>
@@ -45,7 +44,7 @@
</el-select>
</el-form-item>
<el-form-item label="排序号" prop="sortOrder" style="flex: 1;">
<el-input v-model="formData.sort" placeholder="请输入排序号" class="custom-input-bg"></el-input>
<el-input v-model="formData.sort" type="number" placeholder="请输入排序号" class="custom-input-bg"></el-input>
</el-form-item>
</div>
@@ -66,18 +65,10 @@
<el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form">
<template v-for="(row, rowIndex) in chunkedCallInfoFields">
<div :key="rowIndex" style="display: flex; gap: 20px; margin-bottom: 0;">
<el-form-item
v-for="field in row"
:key="field.prop"
:label="field.label"
:prop="field.prop"
<el-form-item v-for="field in row" :key="field.prop" :label="field.label" :prop="field.prop"
style="flex: 1;">
<el-input
v-model="formData.configJson[field.prop]"
:placeholder="field.placeholder"
:type="field.type || 'text'"
class="custom-input-bg"
:show-password="field.type === 'password'">
<el-input v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
:type="field.type || 'text'" class="custom-input-bg" :show-password="field.type === 'password'">
</el-input>
</el-form-item>
</div>
@@ -86,7 +77,12 @@
</div>
<div style="display: flex;justify-content: center;">
<el-button type="primary" @click="confirm" class="save-btn">
<el-button
type="primary"
@click="confirm"
class="save-btn"
:loading="saving"
:disabled="saving">
保存
</el-button>
</div>
@@ -103,6 +99,7 @@ export default {
},
data() {
return {
saving: false,
providers: [],
dialogVisible: false,
providersLoaded: false,
@@ -124,7 +121,7 @@ export default {
watch: {
visible(val) {
this.dialogVisible = val;
if(val) {
if (val) {
this.initConfigJson();
} else {
this.resetForm();
@@ -184,6 +181,7 @@ export default {
},
handleClose() {
this.saving = false;
this.$emit('update:visible', false);
},
initDynamicConfig() {
@@ -194,8 +192,11 @@ export default {
this.formData.configJson = newConfig;
},
confirm() {
this.saving = true;
if (!this.formData.supplier) {
this.$message.error('请选择供应器');
this.saving = false;
return;
}
@@ -215,11 +216,18 @@ export default {
}
};
this.$emit('confirm', submitData);
this.$emit('update:visible', false);
this.resetForm();
try {
this.$emit('confirm', submitData);
this.$emit('update:visible', false);
this.resetForm();
} catch (e) {
console.error(e);
} finally {
this.saving = false;
}
},
resetForm() {
this.saving = false;
this.formData = {
modelName: '',
modelCode: '',
@@ -1,5 +1,5 @@
<template>
<el-dialog :visible.sync="visible" width="400px" center @open="handleOpen">
<el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen">
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
@@ -11,7 +11,7 @@
<div style="height: 1px;background: #e8f0ff;" />
<div style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div> 体名称
<div style="color: red;display: inline-block;">*</div> 体名称
</div>
<div class="input-46" style="margin-top: 12px;">
<el-input ref="inputRef" placeholder="请输入智能体名称.." v-model="wisdomBodyName" @keyup.enter.native="confirm" />
@@ -66,7 +66,10 @@ export default {
cancel() {
this.$emit('update:visible', false)
this.wisdomBodyName = ""
}
},
handleClose() {
this.$emit('update:visible', false);
},
}
}
</script>
@@ -0,0 +1,207 @@
<template>
<el-dialog
title="CDN资源缓存状态"
:visible.sync="visible"
width="70%"
:before-close="handleClose"
>
<div v-if="isLoading" class="loading-container">
<p>正在加载缓存信息...</p>
</div>
<div v-else>
<div v-if="!cacheAvailable" class="no-cache-message">
<i class="el-icon-warning-outline"></i>
<p>您的浏览器不支持Cache API或Service Worker未安装</p>
<el-button type="primary" @click="refreshPage">刷新页面</el-button>
</div>
<div v-else>
<el-alert
v-if="cacheData.totalCached === 0"
title="未发现缓存的CDN资源"
type="warning"
:closable="false"
show-icon
>
<p>Service Worker可能尚未完成初始化或缓存尚未建立请刷新页面或等待一会后再试</p>
</el-alert>
<div v-else>
<el-alert
title="CDN资源缓存状态"
type="success"
:closable="false"
show-icon
>
共发现 {{ cacheData.totalCached }} 个缓存资源
</el-alert>
<h3>JavaScript 资源 ({{ cacheData.js.length }})</h3>
<el-table :data="cacheData.js" stripe style="width: 100%">
<el-table-column prop="url" label="URL" width="auto" show-overflow-tooltip />
<el-table-column prop="cached" label="状态" width="100">
<template slot-scope="scope">
<el-tag type="success" v-if="scope.row.cached">已缓存</el-tag>
<el-tag type="danger" v-else>未缓存</el-tag>
</template>
</el-table-column>
</el-table>
<h3>CSS 资源 ({{ cacheData.css.length }})</h3>
<el-table :data="cacheData.css" stripe style="width: 100%">
<el-table-column prop="url" label="URL" width="auto" show-overflow-tooltip />
<el-table-column prop="cached" label="状态" width="100">
<template slot-scope="scope">
<el-tag type="success" v-if="scope.row.cached">已缓存</el-tag>
<el-tag type="danger" v-else>未缓存</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
<el-button type="primary" @click="refreshCache">刷新缓存状态</el-button>
<el-button type="danger" @click="clearCache">清除缓存</el-button>
</span>
</el-dialog>
</template>
<script>
import {
getCacheNames,
checkCdnCacheStatus,
clearAllCaches,
logCacheStatus
} from '../utils/cacheViewer';
export default {
name: 'CacheViewer',
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
isLoading: true,
cacheAvailable: false,
cacheData: {
css: [],
js: [],
totalCached: 0,
totalNotCached: 0
}
};
},
watch: {
visible(newVal) {
if (newVal) {
this.loadCacheData();
}
}
},
methods: {
async loadCacheData() {
this.isLoading = true;
try {
// 先检查是否支持缓存API
if (!('caches' in window)) {
this.cacheAvailable = false;
this.isLoading = false;
return;
}
// 检查是否有Service Worker缓存
const cacheNames = await getCacheNames();
this.cacheAvailable = cacheNames.length > 0;
if (this.cacheAvailable) {
// 获取CDN缓存状态
this.cacheData = await checkCdnCacheStatus();
// 在控制台输出完整缓存状态
await logCacheStatus();
}
} catch (error) {
console.error('加载缓存数据失败:', error);
this.$message.error('加载缓存数据失败');
} finally {
this.isLoading = false;
}
},
async refreshCache() {
this.loadCacheData();
this.$message.success('正在刷新缓存状态');
},
async clearCache() {
this.$confirm('确定要清除所有缓存吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const success = await clearAllCaches();
if (success) {
this.$message.success('缓存已清除');
await this.loadCacheData();
} else {
this.$message.error('清除缓存失败');
}
} catch (error) {
console.error('清除缓存失败:', error);
this.$message.error('清除缓存失败');
}
}).catch(() => {
this.$message.info('已取消清除');
});
},
refreshPage() {
window.location.reload();
},
handleClose() {
this.$emit('update:visible', false);
}
}
};
</script>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.loading-spinner {
margin-bottom: 10px;
}
.no-cache-message {
text-align: center;
padding: 20px;
}
.no-cache-message i {
font-size: 48px;
color: #E6A23C;
margin-bottom: 10px;
}
h3 {
margin-top: 20px;
margin-bottom: 10px;
font-weight: 500;
}
</style>
@@ -23,12 +23,6 @@
<div class="settings-btn" @click="handleConfigure">
配置角色
</div>
<!-- <div class="settings-btn">-->
<!-- 声纹识别-->
<!-- </div>-->
<!-- <div class="settings-btn">-->
<!-- 历史对话-->
<!-- </div>-->
<div class="settings-btn" @click="handleDeviceManage">
设备管理({{ device.deviceCount }})
</div>
@@ -80,10 +74,11 @@ export default {
.settings-btn {
font-weight: 500;
font-size: 10px;
font-size: 12px;
color: #5778ff;
background: #e6ebff;
width: 57px;
width: auto;
padding: 0 12px;
height: 21px;
line-height: 21px;
cursor: pointer;
@@ -94,7 +89,7 @@ export default {
display: flex;
justify-content: space-between;
margin-top: 15px;
font-size: 10px;
font-size: 12px;
color: #979db1;
font-weight: 400;
}
@@ -0,0 +1,215 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen">
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="固件名称" prop="firmwareName">
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
</el-form-item>
<el-form-item label="固件类型" prop="type">
<el-select v-model="form.type" placeholder="请选择固件类型" style="width: 100%;" filterable :disabled="isTypeDisabled">
<el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option>
</el-select>
</el-form-item>
<el-form-item label="版本号" prop="version">
<el-input v-model="form.version" placeholder="请输入版本号(x.x.x格式)"></el-input>
</el-form-item>
<el-form-item label="固件文件" prop="firmwarePath">
<el-upload ref="upload" class="upload-demo" action="#" :http-request="handleUpload"
:before-upload="beforeUpload" :accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true"
:on-remove="handleRemove">
<el-button size="small" type="primary">点击上传</el-button>
<div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk)且不超过100MB</div>
</el-upload>
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
:status="uploadStatus"></el-progress>
<div class="hint-text">
<span>温馨提示请上传xiaozhi.bin文件而不是merged-binary.bin文件</span>
</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input type="textarea" v-model="form.remark" placeholder="请输入备注信息"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handleCancel"> </el-button>
<el-button type="primary" @click="handleSubmit"> </el-button>
</div>
</el-dialog>
</template>
<script>
import Api from '@/apis/api';
import { FIRMWARE_TYPES } from '@/utils';
export default {
name: 'FirmwareDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
form: {
type: Object,
default: () => ({})
}
},
data() {
return {
firmwareTypes: FIRMWARE_TYPES,
uploadProgress: 0,
uploadStatus: '',
isUploading: false,
rules: {
firmwareName: [
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择固件类型', trigger: 'change' }
],
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' },
{ pattern: /^\d+\.\d+\.\d+$/, message: '版本号格式不正确,请输入x.x.x格式', trigger: 'blur' }
],
firmwarePath: [
{ required: false, message: '请上传固件文件', trigger: 'change' }
]
}
}
},
computed: {
isTypeDisabled() {
// 如果有id,说明是编辑模式,禁用类型选择
return !!this.form.id
}
},
methods: {
handleClose() {
this.$refs.form.clearValidate();
this.$emit('cancel');
},
handleCancel() {
this.$refs.form.clearValidate();
this.$emit('cancel');
},
handleSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
// 如果是新增模式且没有上传文件,则提示错误
if (!this.form.id && !this.form.firmwarePath) {
this.$message.error('请上传固件文件')
return
}
// 提交成功后将关闭对话框的逻辑交给父组件处理
this.$emit('submit', this.form)
}
})
},
beforeUpload(file) {
const isValidSize = file.size / 1024 / 1024 < 100
const isValidType = ['.bin', '.apk'].some(ext => file.name.toLowerCase().endsWith(ext))
if (!isValidType) {
this.$message.error('只能上传.bin/.apk格式的固件文件!')
return false
}
if (!isValidSize) {
this.$message.error('固件文件大小不能超过100MB!')
return false
}
return true
},
handleUpload(options) {
const { file } = options
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = true
// 使用setTimeout实现简单的0-50%过渡
const timer = setTimeout(() => {
if (this.uploadProgress < 50) { // 只有当进度小于50%时才设置
this.uploadProgress = 50
}
}, 1000)
Api.ota.uploadFirmware(file, (res) => {
clearTimeout(timer) // 清除定时器
res = res.data
if (res.code === 0) {
this.form.firmwarePath = res.data
this.form.size = file.size
this.uploadProgress = 100
this.uploadStatus = 'success'
this.$message.success('固件文件上传成功')
// 延迟2秒后隐藏进度条
setTimeout(() => {
this.isUploading = false
}, 2000)
} else {
this.uploadStatus = 'exception'
this.$message.error(res.msg || '文件上传失败')
this.isUploading = false
}
}, (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
// 只有当进度大于50%时才更新
if (progress > 50) {
this.uploadProgress = progress
}
// 如果上传完成但还没收到成功响应,保持进度条显示
if (progress === 100) {
this.uploadStatus = ''
}
}
})
},
handleRemove() {
this.form.firmwarePath = ''
this.form.size = 0
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = false
},
handleOpen() {
// 重置上传相关状态
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = false
// 重置表单中的文件相关字段
if (!this.form.id) { // 只在新增时重置
this.form.firmwarePath = ''
this.form.size = 0
// 重置上传组件
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
})
}
}
}
}
</script>
<style lang="scss" scoped>
.upload-demo {
text-align: left;
}
.el-upload__tip {
line-height: 1.2;
padding-top: 5px;
color: #909399;
}
.hint-text {
display: flex;
align-items: center;
gap: 8px;
color: #979db1;
font-size: 11px;
}
</style>
+16 -6
View File
@@ -32,6 +32,12 @@
:style="{ filter: $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数管理
</div>
<div v-if="isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement">
<img loading="lazy" alt="" src="@/assets/header/firmware_update.png"
:style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
OTA管理
</div>
</div>
<!-- 右侧元素 -->
@@ -105,6 +111,9 @@ export default {
goParamManagement() {
this.$router.push('/params-management')
},
goOtaManagement() {
this.$router.push('/ota-management')
},
// 获取用户信息
fetchUserInfo() {
userApi.getUserInfo(({ data }) => {
@@ -170,7 +179,7 @@ export default {
.header {
background: #f6fcfe66;
border: 1px solid #fff;
height: 53px !important;
height: 63px !important;
min-width: 900px;
/* 设置最小宽度防止过度压缩 */
overflow: hidden;
@@ -197,7 +206,7 @@ export default {
}
.brand-img {
height: 18px;
height: 20px;
}
.header-center {
@@ -219,13 +228,13 @@ export default {
.equipment-management {
padding: 0 9px;
width: 82px;
height: 24px;
border-radius: 12px;
width: px;
height: 30px;
border-radius: 15px;
background: #deeafe;
display: flex;
justify-content: center;
font-size: 10px;
font-size: 14px;
font-weight: 500;
gap: 7px;
color: #3d4566;
@@ -235,6 +244,7 @@ export default {
cursor: pointer;
flex-shrink: 0;
/* 防止导航按钮被压缩 */
padding: 0px 15px;
}
.equipment-management.active-tab {
@@ -17,7 +17,7 @@
<span style="margin-right: 8px;">是否启用</span>
<el-switch v-model="form.isEnabled" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch>
</div>
<div style="display: flex; align-items: center;">
<div style="display: none; align-items: center;">
<span style="margin-right: 8px;">设为默认</span>
<el-switch v-model="form.isDefault" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch>
</div>
@@ -64,18 +64,10 @@
<el-form :model="form.configJson" ref="callInfoForm" label-width="auto" class="custom-form">
<template v-for="(row, rowIndex) in chunkedCallInfoFields">
<div :key="rowIndex" style="display: flex; gap: 20px; margin-bottom: 0;">
<el-form-item
v-for="field in row"
:key="field.prop"
:label="field.label"
:prop="field.prop"
<el-form-item v-for="field in row" :key="field.prop" :label="field.label" :prop="field.prop"
style="flex: 1;">
<el-input
v-model="form.configJson[field.prop]"
:placeholder="field.placeholder"
:type="field.type"
class="custom-input-bg"
:show-password="field.type === 'password'">
<el-input v-model="form.configJson[field.prop]" :placeholder="field.placeholder" :type="field.type"
class="custom-input-bg" :show-password="field.type === 'password'">
</el-input>
</el-form-item>
</div>
@@ -84,7 +76,12 @@
</div>
<div style="display: flex;justify-content: center;">
<el-button type="primary" @click="handleSave" class="save-btn">
<el-button
type="primary"
@click="handleSave"
class="save-btn"
:loading="saving"
:disabled="saving">
保存
</el-button>
</div>
@@ -110,6 +107,7 @@ export default {
dialogVisible: this.visible,
providers: [],
providersLoaded: false,
saving: false,
allProvidersData: null,
pendingProviderType: null,
pendingModelData: null,
@@ -165,60 +163,73 @@ export default {
},
methods: {
resetForm() {
this.form = {
id: "",
modelType: "",
modelCode: "",
modelName: "",
isDefault: false,
isEnabled: false,
docLink: "",
remark: "",
sort: 0,
configJson: {}
};
this.dynamicCallInfoFields.forEach(field => {
this.$set(this.form.configJson, field.prop, '');
});
this.form = {
id: "",
modelType: "",
modelCode: "",
modelName: "",
isDefault: false,
isEnabled: false,
docLink: "",
remark: "",
sort: 0,
configJson: {}
};
this.dynamicCallInfoFields.forEach(field => {
this.$set(this.form.configJson, field.prop, '');
});
},
resetProviders() {
this.providers = [];
this.providersLoaded = false;
},
loadModelData() {
if (this.modelData.id) {
Api.model.getModelConfig(this.modelData.id, ({ data }) => {
if (data.code === 0 && data.data) {
const model = data.data;
this.pendingProviderType = model.configJson.type;
this.pendingModelData = model;
if (this.modelData.id) {
Api.model.getModelConfig(this.modelData.id, ({ data }) => {
if (data.code === 0 && data.data) {
const model = data.data;
this.pendingProviderType = model.configJson.type;
this.pendingModelData = model;
if (this.providersLoaded) {
this.loadProviderFields(model.configJson.type);
} else {
this.loadProviders();
}
}
});
}
if (this.providersLoaded) {
this.loadProviderFields(model.configJson.type);
} else {
this.loadProviders();
}
}
});
}
},
handleSave() {
const provideCode = this.form.configJson.type;
const formData = {
id: this.modelData.id,
modelCode: this.form.modelCode,
modelName: this.form.modelName,
isDefault: this.form.isDefault ? 1 : 0,
isEnabled: this.form.isEnabled ? 1 : 0,
docLink: this.form.docLink,
remark: this.form.remark,
sort: this.form.sort || 0,
configJson: {
...this.form.configJson,
}
this.saving = true; // 开始保存加载
const provideCode = this.form.configJson.type;
const formData = {
id: this.modelData.id,
modelCode: this.form.modelCode,
modelName: this.form.modelName,
isDefault: this.form.isDefault ? 1 : 0,
isEnabled: this.form.isEnabled ? 1 : 0,
docLink: this.form.docLink,
remark: this.form.remark,
sort: this.form.sort || 0,
configJson: {
...this.form.configJson,
}
};
this.$emit("save", { provideCode, formData });
this.dialogVisible = false;
this.$emit("save", {
provideCode,
formData,
done: () => {
this.saving = false; // 保存完成后回调
}
});
// 如果父组件不处理done回调,3秒后自动关闭加载状态
setTimeout(() => {
this.saving = false;
}, 3000);
},
loadProviders() {
if (this.providersLoaded) return;
@@ -40,7 +40,12 @@
</el-form>
<div class="dialog-footer">
<el-button type="primary" @click="submit" class="save-btn">
<el-button
type="primary"
@click="submit"
class="save-btn"
:loading="saving"
:disabled="saving">
保存
</el-button>
<el-button @click="cancel" class="cancel-btn">
@@ -76,6 +81,7 @@ export default {
data() {
return {
dialogKey: Date.now(),
saving: false,
valueTypeOptions: [
{ value: 'string', label: '字符串(string)' },
{ value: 'number', label: '数字(number)' },
@@ -100,11 +106,22 @@ export default {
submit() {
this.$refs.form.validate((valid) => {
if (valid) {
this.$emit('submit', this.form);
this.saving = true; // 开始加载
this.$emit('submit', {
form: this.form,
done: () => {
this.saving = false; // 加载完成
}
});
setTimeout(() => {
this.saving = false;
}, 3000);
}
});
},
cancel() {
this.saving = false; // 取消时重置状态
this.$emit('cancel');
}
},
+79 -34
View File
@@ -14,13 +14,16 @@
class="table-container"
ref="tableContainer"
@scroll="handleScroll">
<el-table
v-loading="loading"
:data="filteredTtsModels"
style="width: 100%;"
class="data-table"
header-row-class-name="table-header"
:fit="true">
<el-table
v-loading="loading"
:data="filteredTtsModels"
style="width: 100%;"
class="data-table"
header-row-class-name="table-header"
:fit="true"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(0, 0, 0, 0.8)">
<el-table-column label="选择" width="50" align="center">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
@@ -187,28 +190,28 @@ export default {
},
methods: {
loadData() {
this.loading = true;
const params = {
ttsModelId: this.ttsModelId,
page: this.currentPage,
limit: this.pageSize,
name: this.searchQuery
};
Api.timbre.getVoiceList(params, (data) => {
if (data.code === 0) {
this.ttsModels = data.data.list
.map(item => ({
id: item.id || '',
voiceCode: item.ttsVoice || '',
voiceName: item.name || '未命名音色',
languageType: item.languages || '',
remark: item.remark || '',
voiceDemo: item.voiceDemo || '',
selected: false,
editing: false,
sort: Number(item.sort)
}))
.sort((a, b) => a.sort - b.sort);
.map(item => ({
id: item.id || '',
voiceCode: item.ttsVoice || '',
voiceName: item.name || '未命名音色',
languageType: item.languages || '',
remark: item.remark || '',
voiceDemo: item.voiceDemo || '',
selected: false,
editing: false,
sort: Number(item.sort)
}))
.sort((a, b) => a.sort - b.sort);
this.total = data.total;
} else {
this.$message.error({
@@ -216,12 +219,14 @@ export default {
showClose: true
});
}
this.loading = false;
}, (err) => {
console.error('加载失败:', err);
this.$message.error({
message: '加载音色数据失败',
showClose: true
});
this.loading = false;
});
},
@@ -551,28 +556,26 @@ export default {
/* 备注文本 */
::v-deep .remark-input .el-textarea__inner {
background-color: #f5f5f5;
border-radius: 4px;
border: 1px solid #e6e6e6;
padding: 8px 12px;
resize: none;
max-height: 40px !important;
line-height: 1.5;
}
::v-deep .remark-input .el-textarea__inner::placeholder {
color: black !important;
opacity: 0.7;
}
::v-deep .remark-input .el-textarea__inner {
background-color: #f4f6fa;
background-color: transparent !important;
}
::v-deep .remark-input .el-textarea__inner:focus {
background-color: #edeffb;
border-color: #409EFF !important;
outline: none;
}
::v-deep .remark-input .el-textarea__inner::placeholder {
color: #c0c4cc !important;
opacity: 1;
}
/* 滚动容器 */
.scroll-wrapper {
display: flex;
@@ -582,7 +585,7 @@ export default {
.table-container {
flex: 1;
overflow-y: scroll;
overflow: auto;
scrollbar-width: none;
padding-right: 15px;
width: calc(100% - 16px);
@@ -641,8 +644,8 @@ export default {
/* 音频播放器容器样式 */
.custom-audio-container {
width: 280px;
margin: auto;
width: 90%;
margin: 0 auto;
}
/* 新增按钮组样式 */
@@ -650,6 +653,12 @@ export default {
bottom: 20px;
padding-top: 10px;
}
.action-buttons .el-button {
padding: 8px 15px;
font-size: 11px;
}
.edit-btn,
.delete-btn,
.save-btn {
@@ -668,4 +677,40 @@ export default {
.save-btn {
color: #5cca8e !important;
}
/* 表格单元格自适应 */
::v-deep .el-table__body-wrapper {
overflow-x: hidden !important;
}
::v-deep .el-table td {
white-space: pre-wrap !important;
word-break: break-all !important;
}
/* 按钮组定位调整 */
.action-buttons {
position: static;
padding: 15px 0;
background: white;
box-shadow: 0 -2px 12px rgba(0,0,0,0.05);
}
/* 输入框自适应 */
::v-deep .el-input__inner,
::v-deep .el-textarea__inner {
width: 100% !important;
min-width: 120px;
}
/* 音频输入框特殊处理 */
.audio-input ::v-deep .el-input__inner {
min-width: 200px;
}
/* 操作按钮弹性布局 */
::v-deep .el-table__row .el-button {
flex-shrink: 0;
margin: 2px !important;
}
</style>
@@ -0,0 +1,23 @@
<template>
<div class="copyright">
©2025 xiaozhi-esp32-server v{{ version }}
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'VersionFooter',
computed: {
...mapState({
version: state => state.pubConfig.version
})
},
mounted() {
this.$store.dispatch('fetchPubConfig');
}
}
</script>
<style scoped></style>
+5
View File
@@ -6,11 +6,16 @@ import App from './App.vue';
import router from './router';
import store from './store';
import './styles/global.scss';
import { register as registerServiceWorker } from './registerServiceWorker';
Vue.use(ElementUI);
Vue.config.productionTip = false
// 注册Service Worker
registerServiceWorker();
// 创建Vue实例
new Vue({
router,
store,
@@ -0,0 +1,113 @@
/* eslint-disable no-console */
export const register = () => {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swUrl = `${process.env.BASE_URL}service-worker.js`;
console.info(`[小智服务] 正在尝试注册Service WorkerURL: ${swUrl}`);
// 先检查Service Worker是否已注册
navigator.serviceWorker.getRegistrations().then(registrations => {
if (registrations.length > 0) {
console.info('[小智服务] 发现已有Service Worker注册,正在检查更新');
}
// 继续注册Service Worker
navigator.serviceWorker
.register(swUrl)
.then(registration => {
console.info('[小智服务] Service Worker注册成功');
// 更新处理
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 内容已缓存更新,通知用户刷新
console.log('[小智服务] 新内容可用,请刷新页面');
// 可以在这里展示更新提示
const updateNotification = document.createElement('div');
updateNotification.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #409EFF;
color: white;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
z-index: 9999;
`;
updateNotification.innerHTML = `
<div style="display: flex; align-items: center;">
<span style="margin-right: 10px;">发现新版本,点击刷新应用</span>
<button style="background: white; color: #409EFF; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;">刷新</button>
</div>
`;
document.body.appendChild(updateNotification);
updateNotification.querySelector('button').addEventListener('click', () => {
window.location.reload();
});
} else {
// 一切正常,Service Worker已成功安装
console.log('[小智服务] 内容已缓存供离线使用');
// 可以在这里初始化缓存
setTimeout(() => {
// 预热CDN缓存
const cdnUrls = [
'https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css',
'https://unpkg.com/vue@2.6.14/dist/vue.min.js',
'https://unpkg.com/vue-router@3.6.5/dist/vue-router.min.js',
'https://unpkg.com/vuex@3.6.2/dist/vuex.min.js',
'https://unpkg.com/element-ui@2.15.14/lib/index.js',
'https://unpkg.com/axios@0.27.2/dist/axios.min.js',
'https://unpkg.com/opus-decoder@0.7.7/dist/opus-decoder.min.js'
];
// 预热缓存
cdnUrls.forEach(url => {
fetch(url, { mode: 'no-cors' }).catch(err => {
console.log(`预热缓存 ${url} 失败`, err);
});
});
}, 2000);
}
}
};
};
})
.catch(error => {
console.error('Service Worker 注册失败:', error);
if (error.name === 'TypeError' && error.message.includes('Failed to register a ServiceWorker')) {
console.warn('[小智服务] 注册Service Worker时出现网络错误,CDN资源可能无法缓存');
if (process.env.NODE_ENV === 'production') {
console.info(
'可能原因:1. 服务器未配置正确的MIME类型 2. 服务器SSL证书问题 3. 服务器未返回service-worker.js文件'
);
}
}
});
});
});
}
};
export const unregister = () => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
};
+12 -7
View File
@@ -62,13 +62,6 @@ const routes = [
return import('../views/ModelConfig.vue')
}
},
{
path: '/test',
name: 'TestServer',
component: function () {
return import('../views/test.vue')
}
},
{
path: '/params-management',
name: 'ParamsManagement',
@@ -80,9 +73,21 @@ const routes = [
title: '参数管理'
}
},
{
path: '/ota-management',
name: 'OtaManagement',
component: function () {
return import('../views/OtaManagement.vue')
},
meta: {
requiresAuth: true,
title: 'OTA管理'
}
},
]
const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/',
routes
})
+174
View File
@@ -0,0 +1,174 @@
/* global self, workbox */
// 自定义Service Worker安装和激活的处理逻辑
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// CDN资源列表
const CDN_CSS = [
'https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
];
const CDN_JS = [
'https://unpkg.com/vue@2.6.14/dist/vue.min.js',
'https://unpkg.com/vue-router@3.6.5/dist/vue-router.min.js',
'https://unpkg.com/vuex@3.6.2/dist/vuex.min.js',
'https://unpkg.com/element-ui@2.15.14/lib/index.js',
'https://unpkg.com/axios@0.27.2/dist/axios.min.js',
'https://unpkg.com/opus-decoder@0.7.7/dist/opus-decoder.min.js'
];
// 当Service Worker被注入manifest后会自动执行
const manifest = self.__WB_MANIFEST || [];
// 检查是否启用CDN模式
const isCDNEnabled = manifest.some(entry =>
entry.url === 'cdn-mode' && entry.revision === 'enabled'
);
console.log(`Service Worker 已初始化, CDN模式: ${isCDNEnabled ? '启用' : '禁用'}`);
// 注入workbox相关代码
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
workbox.setConfig({ debug: false });
// 开启workbox
workbox.core.skipWaiting();
workbox.core.clientsClaim();
// 预缓存离线页面
const OFFLINE_URL = '/offline.html';
workbox.precaching.precacheAndRoute([
{ url: OFFLINE_URL, revision: null }
]);
// 添加安装完成事件处理器,在控制台显示安装消息
self.addEventListener('install', event => {
if (isCDNEnabled) {
console.log('Service Worker 已安装,开始缓存CDN资源');
} else {
console.log('Service Worker 已安装,CDN模式禁用,仅缓存本地资源');
}
// 确保离线页面被缓存
event.waitUntil(
caches.open('offline-cache').then((cache) => {
return cache.add(OFFLINE_URL);
})
);
});
// 添加激活事件处理器
self.addEventListener('activate', event => {
console.log('Service Worker 已激活,现在控制着页面');
// 清理旧版本缓存
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(cacheName => {
// 清理除当前版本外的缓存
return cacheName.startsWith('workbox-') && !workbox.core.cacheNames.runtime.includes(cacheName);
}).map(cacheName => {
return caches.delete(cacheName);
})
);
})
);
});
// 添加fetch事件拦截器,用于查看CDN资源是否命中缓存
self.addEventListener('fetch', event => {
// 只有启用CDN模式时才进行CDN资源缓存监控
if (isCDNEnabled) {
const url = new URL(event.request.url);
// 针对CDN资源,输出是否命中缓存的信息
if ([...CDN_CSS, ...CDN_JS].includes(url.href)) {
// 不干扰正常的fetch流程,只添加日志
console.log(`请求CDN资源: ${url.href}`);
}
}
});
// 仅在CDN模式下缓存CDN资源
if (isCDNEnabled) {
// 缓存CDN的CSS资源
workbox.routing.registerRoute(
({ url }) => CDN_CSS.includes(url.href),
new workbox.strategies.CacheFirst({
cacheName: 'cdn-stylesheets',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 365 * 24 * 60 * 60, // 增加到1年缓存
maxEntries: 10, // 最多缓存10个CSS文件
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200], // 缓存成功响应
}),
],
})
);
// 缓存CDN的JS资源
workbox.routing.registerRoute(
({ url }) => CDN_JS.includes(url.href),
new workbox.strategies.CacheFirst({
cacheName: 'cdn-scripts',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 365 * 24 * 60 * 60, // 增加到1年缓存
maxEntries: 20, // 最多缓存20个JS文件
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200], // 缓存成功响应
}),
],
})
);
}
// 无论是否启用CDN模式,都缓存本地静态资源
workbox.routing.registerRoute(
/\.(?:js|css|png|jpg|jpeg|svg|gif|ico|woff|woff2|eot|ttf|otf)$/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'static-resources',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天缓存
maxEntries: 50, // 最多缓存50个文件
}),
],
})
);
// 缓存HTML页面
workbox.routing.registerRoute(
/\.html$/,
new workbox.strategies.NetworkFirst({
cacheName: 'html-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 1 * 24 * 60 * 60, // 1天缓存
maxEntries: 10, // 最多缓存10个HTML文件
}),
],
})
);
// 离线页面 - 使用更可靠的处理方式
workbox.routing.setCatchHandler(async ({ event }) => {
// 根据请求类型返回适当的默认页面
switch (event.request.destination) {
case 'document':
// 如果是网页请求,返回离线页面
return caches.match(OFFLINE_URL);
default:
// 所有其他请求返回错误
return Response.error();
}
});
+23 -1
View File
@@ -1,6 +1,7 @@
import { goToPage } from "@/utils";
import Vue from 'vue';
import Vuex from 'vuex';
import Api from '../apis/api';
import Constant from '../utils/constant';
Vue.use(Vuex)
@@ -9,7 +10,11 @@ export default new Vuex.Store({
state: {
token: '',
userInfo: {}, // 添加用户信息存储
isSuperAdmin: false // 添加superAdmin状态
isSuperAdmin: false, // 添加superAdmin状态
pubConfig: { // 添加公共配置存储
version: '',
allowUserRegister: false
}
},
getters: {
getToken(state) {
@@ -26,6 +31,9 @@ export default new Vuex.Store({
return state.isSuperAdmin
}
return localStorage.getItem('isSuperAdmin') === 'true'
},
getPubConfig(state) {
return state.pubConfig
}
},
mutations: {
@@ -39,6 +47,9 @@ export default new Vuex.Store({
state.isSuperAdmin = isSuperAdmin
localStorage.setItem('isSuperAdmin', isSuperAdmin)
},
setPubConfig(state, config) {
state.pubConfig = config
},
clearAuth(state) {
state.token = ''
state.userInfo = {}
@@ -55,6 +66,17 @@ export default new Vuex.Store({
goToPage(Constant.PAGE.LOGIN, true);
window.location.reload(); // 彻底重置状态
})
},
// 添加获取公共配置的 action
fetchPubConfig({ commit }) {
return new Promise((resolve) => {
Api.user.getPubConfig(({ data }) => {
if (data.code === 0) {
commit('setPubConfig', data.data);
}
resolve();
});
});
}
},
modules: {
+142
View File
@@ -0,0 +1,142 @@
/**
* 缓存查看工具 - 用于检查CDN资源是否已被Service Worker缓存
*/
/**
* 获取所有Service Worker缓存的名称
* @returns {Promise<string[]>} 缓存名称列表
*/
export const getCacheNames = async () => {
if (!('caches' in window)) {
return [];
}
try {
return await caches.keys();
} catch (error) {
console.error('获取缓存名称失败:', error);
return [];
}
};
/**
* 获取指定缓存中的所有URL
* @param {string} cacheName 缓存名称
* @returns {Promise<string[]>} 缓存的URL列表
*/
export const getCacheUrls = async (cacheName) => {
if (!('caches' in window)) {
return [];
}
try {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
return requests.map(request => request.url);
} catch (error) {
console.error(`获取缓存 ${cacheName} 的URL失败:`, error);
return [];
}
};
/**
* 检查特定URL是否已被缓存
* @param {string} url 要检查的URL
* @returns {Promise<boolean>} 是否已缓存
*/
export const isUrlCached = async (url) => {
if (!('caches' in window)) {
return false;
}
try {
const cacheNames = await getCacheNames();
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const match = await cache.match(url);
if (match) {
return true;
}
}
return false;
} catch (error) {
console.error(`检查URL ${url} 是否缓存失败:`, error);
return false;
}
};
/**
* 获取当前页面所有CDN资源的缓存状态
* @returns {Promise<Object>} 缓存状态对象
*/
export const checkCdnCacheStatus = async () => {
// 从CDN缓存中查找资源
const cdnCaches = ['cdn-stylesheets', 'cdn-scripts'];
const results = {
css: [],
js: [],
totalCached: 0,
totalNotCached: 0
};
for (const cacheName of cdnCaches) {
try {
const urls = await getCacheUrls(cacheName);
// 区分CSS和JS资源
for (const url of urls) {
if (url.endsWith('.css')) {
results.css.push({ url, cached: true });
} else if (url.endsWith('.js')) {
results.js.push({ url, cached: true });
}
results.totalCached++;
}
} catch (error) {
console.error(`获取 ${cacheName} 缓存信息失败:`, error);
}
}
return results;
};
/**
* 清除所有Service Worker缓存
* @returns {Promise<boolean>} 是否成功清除
*/
export const clearAllCaches = async () => {
if (!('caches' in window)) {
return false;
}
try {
const cacheNames = await getCacheNames();
for (const cacheName of cacheNames) {
await caches.delete(cacheName);
}
return true;
} catch (error) {
console.error('清除所有缓存失败:', error);
return false;
}
};
/**
* 将缓存状态输出到控制台
*/
export const logCacheStatus = async () => {
console.group('Service Worker 缓存状态');
const cacheNames = await getCacheNames();
console.log('已发现的缓存:', cacheNames);
for (const cacheName of cacheNames) {
const urls = await getCacheUrls(cacheName);
console.group(`缓存: ${cacheName} (${urls.length} 项)`);
urls.forEach(url => console.log(url));
console.groupEnd();
}
console.groupEnd();
return cacheNames.length > 0;
};
+21
View File
@@ -0,0 +1,21 @@
// 日期格式化函数
export function formatDate(date) {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
const second = String(d.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
// 文件大小格式化函数
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i];
}
+60
View File
@@ -136,3 +136,63 @@ export function getUUID() {
})
}
/**
* 固件类型选项
*/
export const FIRMWARE_TYPES = [
{ "key": "bread-compact-wifi", "name": "面包板新版接线(WiFi" },
{ "key": "bread-compact-wifi-lcd", "name": "面包板新版接线(WiFi+ LCD" },
{ "key": "bread-compact-ml307", "name": "面包板新版接线(ML307 AT" },
{ "key": "bread-compact-esp32", "name": "面包板(WiFi ESP32 DevKit" },
{ "key": "bread-compact-esp32-lcd", "name": "面包板(WiFi+ LCD ESP32 DevKit" },
{ "key": "df-k10", "name": "DFRobot 行空板 k10" },
{ "key": "esp32-cgc", "name": "ESP32 CGC" },
{ "key": "esp-box-3", "name": "ESP BOX 3" },
{ "key": "esp-box", "name": "ESP BOX" },
{ "key": "esp-box-lite", "name": "ESP BOX Lite" },
{ "key": "kevin-box-1", "name": "Kevin Box 1" },
{ "key": "kevin-box-2", "name": "Kevin Box 2" },
{ "key": "kevin-c3", "name": "Kevin C3" },
{ "key": "kevin-sp-v3-dev", "name": "Kevin SP V3开发板" },
{ "key": "kevin-sp-v4-dev", "name": "Kevin SP V4开发板" },
{ "key": "kevin-yuying-313lcd", "name": "鱼鹰科技3.13LCD开发板" },
{ "key": "lichuang-dev", "name": "立创·实战派ESP32-S3开发板" },
{ "key": "lichuang-c3-dev", "name": "立创·实战派ESP32-C3开发板" },
{ "key": "magiclick-2p4", "name": "神奇按钮 Magiclick_2.4" },
{ "key": "magiclick-2p5", "name": "神奇按钮 Magiclick_2.5" },
{ "key": "magiclick-c3", "name": "神奇按钮 Magiclick_C3" },
{ "key": "magiclick-c3-v2", "name": "神奇按钮 Magiclick_C3_v2" },
{ "key": "m5stack-core-s3", "name": "M5Stack CoreS3" },
{ "key": "atoms3-echo-base", "name": "AtomS3 + Echo Base" },
{ "key": "atoms3r-echo-base", "name": "AtomS3R + Echo Base" },
{ "key": "atoms3r-cam-m12-echo-base", "name": "AtomS3R CAM/M12 + Echo Base" },
{ "key": "atommatrix-echo-base", "name": "AtomMatrix + Echo Base" },
{ "key": "xmini-c3", "name": "虾哥 Mini C3" },
{ "key": "esp32s3-korvo2-v3", "name": "ESP32S3_KORVO2_V3开发板" },
{ "key": "esp-sparkbot", "name": "ESP-SparkBot开发板" },
{ "key": "esp-spot-s3", "name": "ESP-Spot-S3" },
{ "key": "esp32-s3-touch-amoled-1.8", "name": "Waveshare ESP32-S3-Touch-AMOLED-1.8" },
{ "key": "esp32-s3-touch-lcd-1.85c", "name": "Waveshare ESP32-S3-Touch-LCD-1.85C" },
{ "key": "esp32-s3-touch-lcd-1.85", "name": "Waveshare ESP32-S3-Touch-LCD-1.85" },
{ "key": "esp32-s3-touch-lcd-1.46", "name": "Waveshare ESP32-S3-Touch-LCD-1.46" },
{ "key": "esp32-s3-touch-lcd-3.5", "name": "Waveshare ESP32-S3-Touch-LCD-3.5" },
{ "key": "tudouzi", "name": "土豆子" },
{ "key": "lilygo-t-circle-s3", "name": "LILYGO T-Circle-S3" },
{ "key": "lilygo-t-cameraplus-s3", "name": "LILYGO T-CameraPlus-S3" },
{ "key": "movecall-moji-esp32s3", "name": "Movecall Moji 小智AI衍生版" },
{ "key": "movecall-cuican-esp32s3", "name": "Movecall CuiCan 璀璨·AI吊坠" },
{ "key": "atk-dnesp32s3", "name": "正点原子DNESP32S3开发板" },
{ "key": "atk-dnesp32s3-box", "name": "正点原子DNESP32S3-BOX" },
{ "key": "du-chatx", "name": "嘟嘟开发板CHATX(wifi)" },
{ "key": "taiji-pi-s3", "name": "太极小派esp32s3" },
{ "key": "xingzhi-cube-0.85tft-wifi", "name": "无名科技星智0.85(WIFI)" },
{ "key": "xingzhi-cube-0.85tft-ml307", "name": "无名科技星智0.85(ML307)" },
{ "key": "xingzhi-cube-0.96oled-wifi", "name": "无名科技星智0.96(WIFI)" },
{ "key": "xingzhi-cube-0.96oled-ml307", "name": "无名科技星智0.96(ML307)" },
{ "key": "xingzhi-cube-1.54tft-wifi", "name": "无名科技星智1.54(WIFI)" },
{ "key": "xingzhi-cube-1.54tft-ml307", "name": "无名科技星智1.54(ML307)" },
{ "key": "sensecap-watcher", "name": "SenseCAP Watcher" },
{ "key": "doit-s3-aibox", "name": "四博智联AI陪伴盒子" },
{ "key": "mixgo-nova", "name": "元控·青春" }
]
+501 -153
View File
@@ -1,74 +1,98 @@
<template>
<div class="welcome">
<HeaderBar />
<el-main style="padding: 20px; display: flex; flex-direction: column;">
<div class="table-container">
<h3 class="device-list-title">设备列表</h3>
<el-table ref="deviceTable" :data="paginatedDeviceList" @selection-change="handleSelectionChange" style="width: 100%; margin-top: 20px" border stripe>
<el-table-column type="selection" align="center" width="60"></el-table-column>
<el-table-column label="设备型号" prop="model" flex></el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" width="120"></el-table-column>
<el-table-column label="Mac地址" prop="macAddress"></el-table-column>
<el-table-column label="绑定时间" prop="bindTime" width="200"></el-table-column>
<el-table-column label="最近对话" prop="lastConversation" width="140"></el-table-column>
<el-table-column label="备注" width="180">
<template slot-scope="scope">
<el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini"
@blur="stopEditRemark(scope.$index)"></el-input>
<span v-else>
<i v-if="!scope.row.remark" class="el-icon-edit" @click="startEditRemark(scope.$index, scope.row)"></i>
<span v-else @click="startEditRemark(scope.$index, scope.row)">
{{ scope.row.remark }}
</span>
</span>
</template>
</el-table-column>
<el-table-column label="OTA升级" width="120">
<template slot-scope="scope">
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66"
inactive-color="#ff4949"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="handleUnbind(scope.row.device_id)" style="color: #ff4949">
解绑
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="toggleAllSelection">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button type="primary" size="mini" class="add-device-btn" @click="handleAddDevice">
新增
</el-button>
</div>
<div class="custom-pagination">
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button
v-for="page in visiblePages"
:key="page"
class="pagination-btn"
:class="{ active: page === currentPage }"
@click="goToPage(page)"
>
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
<span class="total-text">{{ deviceList.length }}条记录</span>
</div>
<div class="operation-bar">
<h2 class="page-title">设备管理</h2>
<div class="right-operations">
<el-input placeholder="请输入设备型号或Mac地址查询" v-model="searchKeyword" class="search-input"
@keyup.enter.native="handleSearch" clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="device-card" shadow="never">
<el-table ref="deviceTable" :data="paginatedDeviceList" class="transparent-table"
:header-cell-class-name="headerCellClassName" v-loading="loading" element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="设备型号" prop="model" align="center">
<template slot-scope="scope">
{{ getFirmwareTypeName(scope.row.model) }}
</template>
</el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" align="center"></el-table-column>
<el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column>
<el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column>
<el-table-column label="最近对话" prop="lastConversation" align="center"></el-table-column>
<el-table-column label="备注" align="center">
<template slot-scope="scope">
<el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini"
@blur="stopEditRemark(scope.$index)"></el-input>
<span v-else>
<i v-if="!scope.row.remark" class="el-icon-edit"
@click="startEditRemark(scope.$index, scope.row)"></i>
<span v-else @click="startEditRemark(scope.$index, scope.row)">
{{ scope.row.remark }}
</span>
</span>
</template>
</el-table-column>
<el-table-column label="OTA升级" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66" inactive-color="#ff4949"
@change="handleOtaSwitchChange(scope.row)"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="handleUnbind(scope.row.device_id)">
解绑
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button type="success" size="mini" class="add-device-btn" @click="handleAddDevice">
新增
</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelected">解绑</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
<span class="total-text">{{ deviceList.length }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
<div class="copyright">
©2025 xiaozhi-esp32-server
</div>
<AddDeviceDialog :visible.sync="addDeviceDialogVisible" :agent-id="currentAgentId"
@refresh="fetchBindDevices(currentAgentId)" />
</el-main>
</div>
<AddDeviceDialog :visible.sync="addDeviceDialogVisible" :agent-id="currentAgentId"
@refresh="fetchBindDevices(currentAgentId)" />
</div>
</template>
@@ -76,30 +100,46 @@
import Api from '@/apis/api';
import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import { FIRMWARE_TYPES } from "@/utils";
export default {
components: { HeaderBar, AddDeviceDialog },
data() {
return {
addDeviceDialogVisible: false,
selectedDevices: [],
selectedDevices: [],
isAllSelected: false,
searchKeyword: "",
activeSearchKeyword: "",
currentAgentId: this.$route.query.agentId || '',
currentPage: 1,
pageSize: 5,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
deviceList: [],
loading: false,
userApi: null,
};
},
computed: {
filteredDeviceList() {
const keyword = this.activeSearchKeyword.toLowerCase();
if (!keyword) return this.deviceList;
return this.deviceList.filter(device =>
(device.model && device.model.toLowerCase().includes(keyword)) ||
(device.macAddress && device.macAddress.toLowerCase().includes(keyword))
);
},
paginatedDeviceList() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.deviceList.slice(start, end);
return this.filteredDeviceList.slice(start, end).map(item => ({
...item,
selected: false
}));
},
pageCount() {
return Math.ceil(this.deviceList.length / this.pageSize);
return Math.ceil(this.filteredDeviceList.length / this.pageSize);
},
visiblePages() {
const pages = [];
@@ -124,15 +164,73 @@ export default {
}
},
methods: {
handleSelectionChange(val) {
this.selectedDevices = val;
this.isAllSelected = val.length === this.paginatedDeviceList.length;
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
},
toggleAllSelection() {
this.$refs.deviceTable.toggleAllSelection();
handleSearch() {
this.activeSearchKeyword = this.searchKeyword;
this.currentPage = 1;
},
handleSelectAll() {
this.isAllSelected = !this.isAllSelected;
this.paginatedDeviceList.forEach(row => {
row.selected = this.isAllSelected;
});
this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
},
deleteSelected() {
this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
if (this.selectedDevices.length === 0) {
this.$message.warning({
message: '请至少选择一条记录',
showClose: true
});
return;
}
this.$confirm(`确认要解绑选中的 ${this.selectedDevices.length} 台设备吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const deviceIds = this.selectedDevices.map(device => device.device_id);
this.batchUnbindDevices(deviceIds);
});
},
batchUnbindDevices(deviceIds) {
const promises = deviceIds.map(id => {
return new Promise((resolve, reject) => {
Api.device.unbindDevice(id, ({ data }) => {
if (data.code === 0) {
resolve();
} else {
reject(data.msg || '解绑失败');
}
});
});
});
Promise.all(promises)
.then(() => {
this.$message.success({
message: `成功解绑 ${deviceIds.length} 台设备`,
showClose: true
});
this.fetchBindDevices(this.currentAgentId);
this.selectedDevices = [];
this.isAllSelected = false;
})
.catch(error => {
this.$message.error({
message: error || '批量解绑过程中出现错误',
showClose: true
});
});
},
handleAddDevice() {
this.addDeviceDialogVisible = true;
@@ -183,35 +281,57 @@ export default {
Api.device.getAgentBindDevices(agentId, ({ data }) => {
this.loading = false;
if (data.code === 0) {
// 格式化日期并按照绑定时间降序排列
this.deviceList = data.data.map(device => {
// 格式化绑定时间
const bindDate = new Date(device.createDate);
const formattedBindTime = `${bindDate.getFullYear()}-${(bindDate.getMonth() + 1).toString().padStart(2, '0')}-${bindDate.getDate().toString().padStart(2, '0')} ${bindDate.getHours().toString().padStart(2, '0')}:${bindDate.getMinutes().toString().padStart(2, '0')}:${bindDate.getSeconds().toString().padStart(2, '0')}`;
let formattedLastConversation = '';
if (device.lastConnectedAt) {
const lastConvoDate = new Date(device.lastConnectedAt);
formattedLastConversation = `${lastConvoDate.getFullYear()}-${(lastConvoDate.getMonth() + 1).toString().padStart(2, '0')}-${lastConvoDate.getDate().toString().padStart(2, '0')} ${lastConvoDate.getHours().toString().padStart(2, '0')}:${lastConvoDate.getMinutes().toString().padStart(2, '0')}:${lastConvoDate.getSeconds().toString().padStart(2, '0')}`;
}
return {
device_id: device.id,
model: device.board,
firmwareVersion: device.appVersion,
macAddress: device.macAddress,
bindTime: formattedBindTime, // 使用格式化后的时间
lastConversation: device.lastConnectedAt,
bindTime: formattedBindTime,
lastConversation: formattedLastConversation,
remark: device.alias,
isEdit: false,
otaSwitch: device.autoUpdate === 1,
// 添加原始时间用于排序
rawBindTime: new Date(device.createDate).getTime()
};
})
// 按照绑定时间降序排序
.sort((a, b) => a.rawBindTime - b.rawBindTime);
this.activeSearchKeyword = "";
this.searchKeyword = "";
} else {
this.$message.error(data.msg || '获取设备列表失败');
}
});
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type;
},
handleOtaSwitchChange(row) {
Api.device.enableOtaUpgrade(row.device_id, row.otaSwitch ? 1 : 0, ({ data }) => {
if (data.code === 0) {
this.$message.success(row.otaSwitch ? '已设置成自动升级' : '已关闭自动升级')
} else {
row.otaSwitch = !row.otaSwitch
this.$message.error(data.msg || '操作失败')
}
})
},
}
};
</script>
<style scoped>
@@ -220,62 +340,214 @@ export default {
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background: linear-gradient(145deg, #e6eeff, #eff0ff);
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
background-size: cover;
background-position: center;
-webkit-background-size: cover;
-o-background-size: cover;
}
.table-container {
background: #f9fafc;
padding: 20px;
border-radius: 20px;
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
margin-top: 15px;
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.add-device-btn {
background: linear-gradient(135deg, #6b8cff, #a966ff) !important;
border: none !important;
color: white !important;
margin-left: 10px;
height: 32px;
padding: 7px 12px;
border-radius: 4px !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.device-list-title {
float: left;
font-size: 18px;
font-weight: 700;
margin: 5px;
.page-title {
font-size: 24px;
margin: 0;
color: #2c3e50;
}
.el-icon-edit {
color: #409eff;
cursor: pointer;
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.search-input {
width: 280px;
border-radius: 4px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
::v-deep .search-input .el-input__inner {
border-radius: 4px;
border: 1px solid #DCDFE6;
background-color: white;
transition: border-color 0.2s;
}
::v-deep .page-size-select {
width: 100px;
margin-right: 8px;
}
::v-deep .page-size-select .el-input__inner {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
vertical-align: middle;
}
::v-deep .page-size-select .el-input__suffix {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
::v-deep .page-size-select .el-input__suffix-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
::v-deep .page-size-select .el-icon-arrow-up:before {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
::v-deep .search-input .el-input__inner:focus {
border-color: #6b8cff;
outline: none;
}
.content-panel {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.content-area {
flex: 1;
height: 100%;
min-width: 600px;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.device-card {
background: white;
border: none;
box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 26px;
}
.ctrl_btn .el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.ctrl_btn .el-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.ctrl_btn .el-button--primary {
background: #5f70f3;
color: white;
}
.ctrl_btn .el-button--success {
background: #5bc98c;
color: white;
}
.ctrl_btn .el-button--danger {
background: #fd5b63;
color: white;
}
.custom-pagination {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
gap: 10px;
}
.pagination-btn {
min-width: 28px;
.custom-pagination .el-select {
margin-right: 8px;
}
.custom-pagination .pagination-btn:first-child,
.custom-pagination .pagination-btn:nth-child(2),
.custom-pagination .pagination-btn:nth-last-child(2),
.custom-pagination .pagination-btn:nth-child(3) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
@@ -287,65 +559,141 @@ export default {
transition: all 0.3s ease;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2) {
min-width: 60px;
}
.pagination-btn:hover {
.custom-pagination .pagination-btn:first-child:hover,
.custom-pagination .pagination-btn:nth-child(2):hover,
.custom-pagination .pagination-btn:nth-last-child(2):hover,
.custom-pagination .pagination-btn:nth-child(3):hover {
background: #d7dce6;
}
.pagination-btn:disabled {
.custom-pagination .pagination-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pagination-btn.active {
.custom-pagination .pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.custom-pagination .pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)):hover {
background: rgba(245, 247, 250, 0.3);
}
.custom-pagination .pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
}
.total-text {
.custom-pagination .pagination-btn.active:hover {
background: #6d7cf5 !important;
}
.custom-pagination .total-text {
color: #909399;
font-size: 14px;
margin-left: 10px;
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
:deep(.transparent-table) {
background: white;
border: none;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 26px;
:deep(.transparent-table .el-table__header th) {
background: white !important;
color: black;
border-right: none !important;
}
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px;
border-radius: 4px;
border: none;
transition: all 0.3s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
:deep(.transparent-table .el-table__body tr td) {
border-top: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-right: none !important;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
:deep(.transparent-table .el-table__header tr th:first-child .cell),
:deep(.transparent-table .el-table__body tr td:first-child .cell) {
padding-left: 10px;
}
.el-button--primary {
background: #5f70f3;
color: white;
}
:deep(.el-icon-edit) {
color: #7079aa;
cursor: pointer;
}
:deep(.el-icon-edit:hover) {
color: #5a64b5;
}
:deep(.custom-selection-header .el-checkbox) {
display: none !important;
}
:deep(.el-table .el-button--text) {
color: #7079aa;
}
:deep(.el-table .el-button--text:hover) {
color: #5a64b5;
}
:deep(.transparent-table) {
flex: 1;
display: flex;
flex-direction: column;
max-height: calc(100vh - 40vh);
}
:deep(.el-table__body-wrapper) {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
:deep(.el-table__header-wrapper) {
flex-shrink: 0;
}
@media (min-width: 1144px) {
.table_bottom {
margin-top: 40px;
}
:deep(.transparent-table) .el-table__body tr td {
padding-top: 16px;
padding-bottom: 16px;
}
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
::v-deep .el-table--border::after,
::v-deep .el-table--group::after,
::v-deep .el-table::before {
display: none !important;
}
</style>
+212 -149
View File
@@ -3,21 +3,15 @@
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">{{ modelTypeText }}</h2>
<h2 class="page-title">{{ modelTypeText }}</h2>
<div class="action-group">
<div class="search-group">
<el-input
placeholder="请输入模型名称查询"
v-model="search"
class="search-input"
clearable
@keyup.enter.native="handleSearch"
style="width: 240px"
/>
<el-button class="btn-search" @click="handleSearch">
搜索
</el-button>
</div>
<div class="search-group">
<el-input placeholder="请输入模型名称查询" v-model="search" class="search-input" clearable
@keyup.enter.native="handleSearch" style="width: 240px" />
<el-button class="btn-search" @click="handleSearch">
搜索
</el-button>
</div>
</div>
</div>
@@ -49,74 +43,83 @@
<!-- 右侧内容 -->
<div class="content-area">
<el-table ref="modelTable" style="width: 100%" :header-cell-style="{ background: 'transparent' }"
:data="modelList" class="data-table" header-row-class-name="table-header"
:header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
<el-table-column label="模型编码" prop="modelCode" align="center"></el-table-column>
<el-table-column label="提供商" align="center">
<template slot-scope="scope">
{{ scope.row.configJson.type || '未知' }}
</template>
</el-table-column>
<el-table-column label="是否启用" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isEnabled" class="custom-switch" :active-value="1" :inactive-value="0"
@change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="是否默认" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isDefault" class="custom-switch" :active-value="1" :inactive-value="0"
@change="handleDefaultChange(scope.row)" />
</template>
</el-table-column>
<el-table-column v-if="activeTab === 'tts'" label="音色管理" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="openTtsDialog(scope.row)" class="voice-management-btn">
音色管理
<el-card class="model-card" shadow="never">
<el-table ref="modelTable" style="width: 100%" v-loading="loading" element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-style="{ background: 'transparent' }" :data="modelList" class="data-table"
header-row-class-name="table-header" :header-cell-class-name="headerCellClassName"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="模型ID" prop="id" align="center"></el-table-column>
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
<el-table-column label="提供商" align="center">
<template slot-scope="scope">
{{ scope.row.configJson.type || '未知' }}
</template>
</el-table-column>
<el-table-column label="是否启用" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isEnabled" class="custom-switch" :active-value="1" :inactive-value="0"
@change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="是否默认" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isDefault" class="custom-switch" :active-value="1" :inactive-value="0"
@change="handleDefaultChange(scope.row)" />
</template>
</el-table-column>
<el-table-column v-if="activeTab === 'tts'" label="音色管理" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="openTtsDialog(scope.row)" class="voice-management-btn">
音色管理
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150px">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
修改
</el-button>
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<div class="batch-actions">
<el-button size="mini" type="primary" @click="selectAll">
{{ isAllSelected ?
'取消全选' : '全选' }}
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150px">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
修改
<el-button type="success" size="mini" @click="addModel" class="add-btn">
新增
</el-button>
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="custom-pagination">
<div class="table-footer">
<div class="batch-actions">
<el-button size="mini" type="primary" @click="selectAll" >
{{ isAllSelected ?
'取消全选' : '全选' }}
</el-button>
<el-button type="success" size="mini" @click="addModel" class="add-btn">
新增
</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">
删除
</el-button>
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
<div class="custom-pagination">
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
@@ -125,10 +128,9 @@
<TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" />
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" />
</div>
<div class="copyright">
©2025 xiaozhi-esp32-server
</div>
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
@@ -138,9 +140,9 @@ import AddModelDialog from "@/components/AddModelDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import ModelEditDialog from "@/components/ModelEditDialog.vue";
import TtsModel from "@/components/TtsModel.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog },
components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog, VersionFooter },
data() {
return {
addDialogVisible: false,
@@ -151,11 +153,13 @@ export default {
ttsDialogVisible: false,
selectedTtsModelId: '',
modelList: [],
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1,
pageSize: 5,
pageSize: 10,
total: 0,
selectedModels: [],
isAllSelected: false
isAllSelected: false,
loading: false
};
},
@@ -164,7 +168,6 @@ export default {
},
computed: {
modelTypeText() {
const map = {
vad: '语言活动检测模型(VAD)',
@@ -176,8 +179,6 @@ export default {
}
return map[this.activeTab] || '模型配置'
},
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
@@ -199,6 +200,11 @@ export default {
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.loadData();
},
openTtsDialog(row) {
this.selectedTtsModelId = row.id;
this.ttsDialogVisible = true;
@@ -211,7 +217,8 @@ export default {
},
handleMenuSelect(index) {
this.activeTab = index;
this.currentPage = 1;
this.currentPage = 1; // 重置到第一页
this.pageSize = 10; // 可选:重置每页条数
this.loadData();
},
handleSearch() {
@@ -296,9 +303,10 @@ export default {
this.currentPage = page;
this.$refs.modelTable.clearSelection();
},
handleModelSave({ provideCode, formData }) {
handleModelSave({ provideCode, formData, done }) {
const modelType = this.activeTab;
const id = formData.id;
Api.model.updateModel(
{ modelType, provideCode, id, formData },
({ data }) => {
@@ -309,6 +317,7 @@ export default {
} else {
this.$message.error(data.msg || '保存失败');
}
done && done(); // 调用done回调关闭加载状态
}
);
},
@@ -380,6 +389,7 @@ export default {
// 获取模型配置列表
loadData() {
this.loading = true; // 开始加载
const params = {
modelType: this.activeTab,
modelName: this.search,
@@ -388,6 +398,7 @@ export default {
};
Api.model.getModelList(params, ({ data }) => {
this.loading = false; // 结束加载
if (data.code === 0) {
this.modelList = data.data.list;
this.total = data.data.total;
@@ -456,7 +467,9 @@ export default {
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: 600px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -474,12 +487,6 @@ export default {
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.content-panel {
flex: 1;
display: flex;
@@ -508,8 +515,9 @@ export default {
.nav-panel .el-menu-item {
height: 50px;
background: #e9f0ff;
line-height: 50px;
border-radius: 4px;
border-radius: 4px 0 0 4px !important;
transition: all 0.3s;
display: flex !important;
justify-content: flex-end;
@@ -520,8 +528,7 @@ export default {
}
.nav-panel .el-menu-item.is-active {
background: #e9f0ff;
color: #0ba6f4 !important;
background: #5778ff;
position: relative;
padding-left: 40px !important;
}
@@ -534,7 +541,7 @@ export default {
transform: translateY(-50%);
width: 13px;
height: 13px;
background: #409EFF;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 4px rgba(64, 158, 255, 0.5);
}
@@ -552,17 +559,10 @@ export default {
padding: 24px;
height: 100%;
min-width: 600px;
overflow-x: auto;
overflow: hidden;
background-color: white;
}
.title-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: nowrap;
flex-direction: column;
}
.action-group {
@@ -586,6 +586,11 @@ export default {
color: white;
}
.btn-search:hover {
opacity: 0.9;
transform: translateY(-1px);
}
::v-deep .search-input .el-input__inner {
border-radius: 4px;
border: 1px solid #DCDFE6;
@@ -593,22 +598,55 @@ export default {
transition: border-color 0.2s;
}
::v-deep .page-size-select {
width: 100px;
margin-right: 8px;
}
::v-deep .page-size-select .el-input__inner {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
::v-deep .page-size-select .el-input__suffix {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
::v-deep .page-size-select .el-input__suffix-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
::v-deep .page-size-select .el-icon-arrow-up:before {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
::v-deep .search-input .el-input__inner:focus {
border-color: #6b8cff;
outline: none;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.btn-search:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.data-table {
border-radius: 6px;
overflow: hidden;
@@ -626,12 +664,14 @@ export default {
}
.table-footer {
margin-top: 24px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
width: 100%;
flex-shrink: 0;
min-height: 60px;
background: white;
}
.batch-actions {
@@ -639,26 +679,6 @@ export default {
gap: 8px;
}
.copyright {
text-align: center;
color: #979db1;
font-size: 12px;
font-weight: 400;
margin-top: auto;
padding: 30px 0 20px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
}
.title-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.batch-actions .el-button {
min-width: 72px;
height: 32px;
@@ -710,11 +730,6 @@ export default {
background-color: transparent !important;
}
.pagination-container {
display: flex;
justify-content: flex-end;
}
::v-deep .el-table .custom-selection-header .cell .el-checkbox__inner {
display: none !important;
}
@@ -738,7 +753,7 @@ export default {
}
::v-deep .nav-panel .el-menu-item.is-active .menu-text {
color: #409EFF !important;
color: #fff !important;
}
::v-deep .data-table {
@@ -815,11 +830,11 @@ export default {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
/* 导航按钮样式 (首页、上一页、下一页) */
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-child(3),
.pagination-btn:nth-last-child(2) {
min-width: 60px;
height: 32px;
@@ -843,7 +858,7 @@ export default {
}
/* 数字按钮样式 */
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) {
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-child(3)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
@@ -876,4 +891,52 @@ export default {
margin-left: 10px;
}
}
.model-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
}
.model-card ::v-deep .el-card__body {
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.data-table {
--table-max-height: calc(100vh - 45vh);
max-height: var(--table-max-height);
}
.data-table ::v-deep .el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 80px);
overflow-y: auto;
}
::v-deep .el-loading-mask {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
::v-deep .el-loading-spinner .circular {
width: 28px;
height: 28px;
}
::v-deep .el-loading-spinner .path {
stroke: #6b8cff;
}
::v-deep .el-loading-text {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,756 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">固件管理</h2>
<div class="right-operations">
<el-input placeholder="请输入固件名称查询" v-model="searchName" class="search-input"
@keyup.enter.native="handleSearch" clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="固件名称" prop="firmwareName" align="center"></el-table-column>
<el-table-column label="固件类型" prop="type" align="center">
<template slot-scope="scope">
{{ getFirmwareTypeName(scope.row.type) }}
</template>
</el-table-column>
<el-table-column label="版本号" prop="version" align="center"></el-table-column>
<el-table-column label="文件大小" prop="size" align="center">
<template slot-scope="scope">
{{ formatFileSize(scope.row.size) }}
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" align="center"
show-overflow-tooltip></el-table-column>
<el-table-column label="创建时间" prop="createDate" align="center">
<template slot-scope="scope">
{{ formatDate(scope.row.createDate) }}
</template>
</el-table-column>
<el-table-column label="更新时间" prop="updateDate" align="center">
<template slot-scope="scope">
{{ formatDate(scope.row.updateDate) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text"
@click="downloadFirmware(scope.row)">下载</el-button>
<el-button size="mini" type="text" @click="editParam(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="deleteParam(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button size="mini" type="success" @click="showAddDialog"
style="background: #5bc98c;border: None;">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteSelectedParams">删除</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
:value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
上一页
</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
下一页
</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
</div>
<!-- 新增/编辑固件对话框 -->
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit"
@cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import Api from "@/apis/api";
import FirmwareDialog from "@/components/FirmwareDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import { FIRMWARE_TYPES } from "@/utils";
import { formatDate, formatFileSize } from "@/utils/format";
export default {
components: { HeaderBar, FirmwareDialog, VersionFooter },
data() {
return {
searchName: "",
loading: false,
paramsList: [],
firmwareList: [],
currentPage: 1,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0,
dialogVisible: false,
dialogTitle: "新增固件",
isAllSelected: false,
firmwareForm: {
id: null,
firmwareName: "",
type: "",
version: "",
size: 0,
remark: "",
firmwarePath: ""
},
};
},
created() {
this.fetchFirmwareList();
},
computed: {
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
const pages = [];
const maxVisible = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.pageCount, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchFirmwareList();
},
fetchFirmwareList() {
this.loading = true;
const params = {
pageNum: this.currentPage,
pageSize: this.pageSize,
firmwareName: this.searchName || "",
orderField: "create_date",
order: "desc"
};
Api.ota.getOtaList(params, (res) => {
this.loading = false;
res = res.data
if (res.code === 0) {
this.firmwareList = res.data.list.map(item => ({
...item,
selected: false
}));
this.paramsList = this.firmwareList;
this.total = res.data.total || 0;
} else {
this.firmwareList = [];
this.paramsList = [];
this.total = 0;
this.$message.error({
message: res?.data?.msg || '获取固件列表失败',
showClose: true
});
}
});
},
handleSearch() {
this.currentPage = 1;
this.fetchFirmwareList();
},
handleSelectAll() {
this.isAllSelected = !this.isAllSelected;
this.firmwareList.forEach(row => {
row.selected = this.isAllSelected;
});
},
showAddDialog() {
this.dialogTitle = "新增固件";
// 完全重置表单数据
this.firmwareForm = {
id: null,
firmwareName: "",
type: "",
version: "",
size: 0,
remark: "",
firmwarePath: ""
};
this.$nextTick(() => {
// 重置表单的校验状态
if (this.$refs.firmwareDialog && this.$refs.firmwareDialog.$refs.form) {
this.$refs.firmwareDialog.$refs.form.clearValidate();
}
});
this.dialogVisible = true;
},
editParam(row) {
this.dialogTitle = "编辑固件";
this.firmwareForm = { ...row };
this.dialogVisible = true;
},
handleSubmit(form) {
if (form.id) {
// 编辑
Api.ota.updateOta(form.id, form, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: "修改成功",
showClose: true
});
this.dialogVisible = false;
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || "修改失败",
showClose: true
});
}
});
} else {
// 新增
Api.ota.saveOta(form, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: "新增成功",
showClose: true
});
this.dialogVisible = false;
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || "新增失败",
showClose: true
});
}
});
}
},
deleteSelectedParams() {
const selectedRows = this.firmwareList.filter(row => row.selected);
if (selectedRows.length === 0) {
this.$message.warning({
message: "请先选择需要删除的固件",
showClose: true
});
return;
}
this.deleteParam(selectedRows);
},
deleteParam(row) {
// 处理单个参数或参数数组
const params = Array.isArray(row) ? row : [row];
if (Array.isArray(row) && row.length === 0) {
this.$message.warning({
message: "请先选择需要删除的参数",
showClose: true
});
return;
}
const paramCount = params.length;
this.$confirm(`确定要删除选中的${paramCount}个固件吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
distinguishCancelAndClose: true
}).then(() => {
const ids = params.map(param => param.id);
if (ids.some(id => !id)) {
this.$message.error({
message: '存在无效的参数ID',
showClose: true
});
return;
}
Api.ota.deleteOta(ids, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: `成功删除${paramCount}个固件`,
showClose: true
});
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || '删除失败,请重试',
showClose: true
});
}
});
}).catch(action => {
if (action === 'cancel') {
this.$message({
type: 'info',
message: '已取消删除操作',
duration: 1000
});
} else {
this.$message({
type: 'info',
message: '操作已关闭',
duration: 1000
});
}
});
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
goFirst() {
this.currentPage = 1;
this.fetchFirmwareList();
},
goPrev() {
if (this.currentPage > 1) {
this.currentPage--;
this.fetchFirmwareList();
}
},
goNext() {
if (this.currentPage < this.pageCount) {
this.currentPage++;
this.fetchFirmwareList();
}
},
goToPage(page) {
this.currentPage = page;
this.fetchFirmwareList();
},
downloadFirmware(firmware) {
if (!firmware || !firmware.id) {
this.$message.error('固件信息不完整');
return;
}
// 先获取下载链接
Api.ota.getDownloadUrl(firmware.id, (res) => {
if (res.data.code === 0) {
const uuid = res.data.data;
const baseUrl = process.env.VUE_APP_API_BASE_URL || '';
window.open(`${window.location.origin}${baseUrl}/otaMag/download/${uuid}`);
} else {
this.$message.error('获取下载链接失败');
}
});
},
formatDate,
formatFileSize,
getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type;
},
},
};
</script>
<style lang="scss" scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background-size: cover;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.search-input {
width: 240px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.content-panel {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.content-area {
flex: 1;
height: 100%;
min-width: 600px;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.params-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 26px;
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.el-button--primary {
background: #5f70f3;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
.custom-pagination {
display: flex;
align-items: center;
gap: 5px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2),
.pagination-btn:nth-child(3) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #d7dce6;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(245, 247, 250, 0.3);
}
}
.pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
&:hover {
background: #6d7cf5 !important;
}
}
}
.total-text {
margin-left: 10px;
color: #606266;
font-size: 14px;
}
.page-size-select {
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
:deep(.transparent-table) {
background: white;
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper {
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
color: black;
font-weight: 600;
height: 40px;
padding: 8px 0;
font-size: 14px;
border-bottom: 1px solid #e4e7ed;
}
.el-table__body tr {
background-color: white;
td {
border-top: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
padding: 8px 0;
height: 40px;
color: #606266;
font-size: 14px;
}
}
.el-table__row:hover>td {
background-color: #f5f7fa !important;
}
&::before {
display: none;
}
}
:deep(.el-table .el-button--text) {
color: #7079aa !important;
}
:deep(.el-table .el-button--text:hover) {
color: #5a64b5 !important;
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
@media (min-width: 1144px) {
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
}
:deep(.transparent-table) {
.el-table__body tr {
td {
padding-top: 16px;
padding-bottom: 16px;
}
&+tr {
margin-top: 10px;
}
}
}
}
</style>
+198 -55
View File
@@ -15,9 +15,15 @@
<div class="content-panel">
<div class="content-area">
<el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table"
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="参数编码" prop="paramCode" align="center"></el-table-column>
<el-table-column label="参数值" prop="paramValue" align="center"
show-overflow-tooltip></el-table-column>
@@ -37,9 +43,14 @@
</el-button>
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteParam($refs.paramsTable.selection)">删除</el-button>
@click="deleteSelectedParams">删除</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
:value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
@@ -64,8 +75,9 @@
<!-- 新增/编辑参数对话框 -->
<param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit"
@cancel="dialogVisible = false" />
<div class="copyright">©2025 xiaozhi-esp32-server</div>
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
@@ -73,15 +85,17 @@
import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue";
import ParamDialog from "@/components/ParamDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
components: { HeaderBar, ParamDialog },
components: { HeaderBar, ParamDialog, VersionFooter },
data() {
return {
searchCode: "",
paramsList: [],
currentPage: 1,
pageSize: 5,
loading: false,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0,
dialogVisible: false,
dialogTitle: "新增参数",
@@ -96,7 +110,9 @@ export default {
},
created() {
this.fetchParams();
},
computed: {
pageCount() {
return Math.ceil(this.total / this.pageSize);
@@ -118,7 +134,13 @@ export default {
},
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchParams();
},
fetchParams() {
this.loading = true;
Api.admin.getParamsList(
{
page: this.currentPage,
@@ -126,11 +148,18 @@ export default {
paramCode: this.searchCode,
},
({ data }) => {
this.loading = false;
if (data.code === 0) {
this.paramsList = data.data.list;
this.paramsList = data.data.list.map(item => ({
...item,
selected: false
}));
this.total = data.data.total;
} else {
this.$message.error(data.msg || '获取参数列表失败');
this.$message.error({
message: data.msg || '获取参数列表失败',
showClose: true
});
}
}
);
@@ -140,12 +169,10 @@ export default {
this.fetchParams();
},
handleSelectAll() {
if (this.isAllSelected) {
this.$refs.paramsTable.clearSelection();
} else {
this.$refs.paramsTable.toggleAllSelection();
}
this.isAllSelected = !this.isAllSelected;
this.paramsList.forEach(row => {
row.selected = this.isAllSelected;
});
},
showAddDialog() {
this.dialogTitle = "新增参数";
@@ -162,37 +189,60 @@ export default {
this.paramForm = { ...row };
this.dialogVisible = true;
},
handleSubmit(form) {
handleSubmit({ form, done }) {
if (form.id) {
// 编辑
Api.admin.updateParam(form, ({ data }) => {
if (data.code === 0) {
this.$message.success("修改成功");
this.$message.success({
message: "修改成功",
showClose: true
});
this.dialogVisible = false;
this.fetchParams();
}
done && done();
});
} else {
// 新增
Api.admin.addParam(form, ({ data }) => {
if (data.code === 0) {
this.$message.success("新增成功");
this.$message.success({
message: "新增成功",
showClose: true
});
this.dialogVisible = false;
this.fetchParams();
}
done && done();
});
}
},
deleteSelectedParams() {
const selectedRows = this.paramsList.filter(row => row.selected);
if (selectedRows.length === 0) {
this.$message.warning({
message: "请先选择需要删除的参数",
showClose: true
});
return;
}
this.deleteParam(selectedRows);
},
deleteParam(row) {
// 处理单个参数或参数数组
const params = Array.isArray(row) ? row : [row];
if (Array.isArray(row) && row.length === 0) {
this.$message.warning("请先选择需要删除的参数");
this.$message.warning({
message: "请先选择需要删除的参数",
showClose: true
});
return;
}
const paramCount = params.length;
this.$confirm(`确定要删除选中的${paramCount}个参数吗?`, '警告', {
confirmButtonText: '确定',
@@ -202,7 +252,10 @@ export default {
}).then(() => {
const ids = params.map(param => param.id);
if (ids.some(id => isNaN(id))) {
this.$message.error('存在无效的参数ID');
this.$message.error({
message: '存在无效的参数ID',
showClose: true
});
return;
}
@@ -212,7 +265,7 @@ export default {
message: `成功删除${paramCount}个参数`,
showClose: true
});
this.fetchParams(); // 刷新参数列表
this.fetchParams();
} else {
this.$message.error({
message: data.msg || '删除失败,请重试',
@@ -278,15 +331,20 @@ export default {
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: 600px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
@@ -331,14 +389,28 @@ export default {
flex: 1;
height: 100%;
min-width: 600px;
overflow-x: auto;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.params-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
@@ -346,6 +418,7 @@ export default {
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
@@ -382,29 +455,19 @@ export default {
}
}
.copyright {
text-align: center;
color: #979db1;
font-size: 12px;
font-weight: 400;
margin-top: auto;
padding: 30px 0 20px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
}
.custom-pagination {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
gap: 10px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2) {
.pagination-btn:nth-last-child(2),
.pagination-btn:nth-child(3) {
min-width: 60px;
height: 32px;
padding: 0 12px;
@@ -426,7 +489,7 @@ export default {
}
}
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) {
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
@@ -462,6 +525,20 @@ export default {
:deep(.transparent-table) {
background: white;
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper {
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
@@ -482,19 +559,6 @@ export default {
}
}
:deep(.custom-selection-header) {
.el-checkbox {
display: none !important;
}
&::after {
content: "选择";
display: inline-block;
color: black;
font-weight: bold;
padding-bottom: 18px;
}
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
@@ -550,4 +614,83 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
}
.page-size-select {
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
:deep(.el-table) {
.el-table__body-wrapper {
transition: height 0.3s ease;
}
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style>
+154 -62
View File
@@ -15,9 +15,14 @@
<div class="content-panel">
<div class="content-area">
<el-card class="user-card" shadow="never">
<el-table ref="userTable" :data="userList" class="transparent-table"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
<el-table ref="userTable" :data="userList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
<el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column>
@@ -42,7 +47,7 @@
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }}
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button size="mini" type="success" icon="el-icon-circle-check" @click="batchEnable">启用</el-button>
<el-button size="mini" type="warning" @click="batchDisable"><i
@@ -50,6 +55,11 @@
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">删除</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
@@ -70,28 +80,32 @@
</div>
</div>
<div class="copyright">©2025 xiaozhi-esp32-server</div>
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import ViewPasswordDialog from "@/components/ViewPasswordDialog.vue";
export default {
components: { HeaderBar, ViewPasswordDialog },
components: { HeaderBar, ViewPasswordDialog, VersionFooter },
data() {
return {
showViewPassword: false,
currentPassword: "",
searchPhone: "",
userList: [],
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1,
pageSize: 5,
pageSize: 10,
total: 0,
isAllSelected: false
isAllSelected: false,
loading: false,
};
},
created() {
@@ -118,7 +132,14 @@ export default {
},
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchUsers();
},
fetchUsers() {
this.loading = true;
Api.admin.getUserList(
{
page: this.currentPage,
@@ -126,8 +147,12 @@ export default {
mobile: this.searchPhone,
},
({ data }) => {
this.loading = false; // 结束加载
if (data.code === 0) {
this.userList = data.data.list
this.userList = data.data.list.map(item => ({
...item,
selected: false
}));
this.total = data.data.total;
}
}
@@ -138,15 +163,13 @@ export default {
this.fetchUsers();
},
handleSelectAll() {
if (this.isAllSelected) {
this.$refs.userTable.clearSelection();
} else {
this.$refs.userTable.toggleAllSelection();
}
this.isAllSelected = !this.isAllSelected;
this.userList.forEach(row => {
row.selected = this.isAllSelected;
});
},
batchDelete() {
const selectedUsers = this.$refs.userTable.selection;
const selectedUsers = this.userList.filter(user => user.selected);
if (selectedUsers.length === 0) {
this.$message.warning("请先选择需要删除的用户");
return;
@@ -211,19 +234,11 @@ export default {
});
},
batchEnable() {
const selectedUsers = this.$refs.userTable.selection;
if (selectedUsers.length === 0) {
this.$message.warning("请先选择需要启用的用户");
return;
}
const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 1);
},
batchDisable() {
const selectedUsers = this.$refs.userTable.selection;
if (selectedUsers.length === 0) {
this.$message.warning("请先选择需要禁用的用户");
return;
}
const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 0);
},
resetPassword(row) {
@@ -267,12 +282,6 @@ export default {
})
.catch(() => { });
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
goFirst() {
this.currentPage = 1;
this.fetchUsers();
@@ -344,15 +353,20 @@ export default {
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: 600px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
@@ -399,12 +413,26 @@ export default {
min-width: 600px;
overflow-x: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.user-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
@@ -465,28 +493,18 @@ export default {
color: black;
}
.copyright {
text-align: center;
color: #979db1;
font-size: 12px;
font-weight: 400;
margin-top: auto;
padding: 30px 0 20px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
}
.custom-pagination {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-child(3),
.pagination-btn:nth-last-child(2) {
min-width: 60px;
height: 32px;
@@ -509,7 +527,7 @@ export default {
}
}
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) {
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-child(3)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
@@ -545,6 +563,20 @@ export default {
:deep(.transparent-table) {
background: white;
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper {
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
@@ -573,19 +605,6 @@ export default {
color: #5a64b5 !important;
}
:deep(.custom-selection-header) {
.el-checkbox {
display: none !important;
}
&::after {
content: "选择";
display: inline-block;
color: black;
font-weight: bold;
padding-bottom: 18px;
}
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
@@ -622,4 +641,77 @@ export default {
}
}
}
.page-size-select {
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style>
+118 -17
View File
@@ -19,27 +19,49 @@
<div class="hi-hint">
Hello, Let's have a wonderful day!
</div>
<div class="add-device-btn" @click="showAddDialog">
<div class="left-add">
<div class="add-device-btn">
<div class="left-add" @click="showAddDialog">
添加智能体
</div>
<div style="width: 23px;height: 13px;background: #5778ff;margin-left: -10px;" />
<div class="right-add">
<i class="el-icon-right" style="font-size: 20px;color: #fff;" />
<i class="el-icon-right" @click="showAddDialog" style="font-size: 20px;color: #fff;" />
</div>
</div>
</div>
</div>
<div class="device-list-container">
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig"
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" />
<template v-if="isLoading">
<div
v-for="i in skeletonCount"
:key="'skeleton-'+i"
class="skeleton-item"
>
<div class="skeleton-image"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
<div class="skeleton-line-short"></div>
</div>
</div>
</template>
<template v-else>
<DeviceItem
v-for="(item, index) in devices"
:key="index"
:device="item"
@configure="goToRoleConfig"
@deviceManage="handleDeviceManage"
@delete="handleDeleteAgent"
/>
</template>
</div>
</div>
<div class="copyright">
©2025 xiaozhi-esp32-server
</div>
<AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" />
</el-main>
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
@@ -49,17 +71,20 @@ import Api from '@/apis/api';
import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue';
import DeviceItem from '@/components/DeviceItem.vue';
import HeaderBar from '@/components/HeaderBar.vue';
import VersionFooter from '@/components/VersionFooter.vue';
export default {
name: 'HomePage',
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar },
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar, VersionFooter },
data() {
return {
addDeviceDialogVisible: false,
devices: [],
originalDevices: [],
isSearching: false,
searchRegex: null
searchRegex: null,
isLoading: true,
skeletonCount: localStorage.getItem('skeletonCount') || 8,
}
},
@@ -108,12 +133,26 @@ export default {
},
// 获取智能体列表
fetchAgentList() {
this.isLoading = true;
Api.agent.getAgentList(({ data }) => {
this.originalDevices = data.data.map(item => ({
...item,
agentId: item.id // 字段映射
}));
this.handleSearchReset(); // 重置搜索状态
if (data?.data) {
this.originalDevices = data.data.map(item => ({
...item,
agentId: item.id
}));
// 动态设置骨架屏数量(可选)
this.skeletonCount = Math.min(
Math.max(this.originalDevices.length, 3), // 最少3个
10 // 最多10个
);
this.handleSearchReset();
}
this.isLoading = false;
}, (error) => {
console.error('Failed to fetch agent list:', error);
this.isLoading = false;
});
},
// 删除智能体
@@ -198,7 +237,7 @@ export default {
.hi-hint {
font-weight: 400;
font-size: 10px;
font-size: 12px;
text-align: left;
color: #818cae;
margin-left: 75px;
@@ -219,7 +258,7 @@ export default {
border-radius: 17px;
background: #5778ff;
color: #fff;
font-size: 10px;
font-size: 14px;
font-weight: 500;
text-align: center;
line-height: 34px;
@@ -260,4 +299,66 @@ export default {
text-align: center;
/* 居中显示 */
}
/* 骨架屏动画 */
@keyframes shimmer {
100% { transform: translateX(100%); }
}
.skeleton-item {
background: #fff;
border-radius: 8px;
padding: 20px;
height: 120px;
position: relative;
overflow: hidden;
margin-bottom: 20px;
}
.skeleton-image {
width: 80px;
height: 80px;
background: #f0f2f5;
border-radius: 4px;
float: left;
position: relative;
overflow: hidden;
}
.skeleton-content {
margin-left: 100px;
}
.skeleton-line {
height: 16px;
background: #f0f2f5;
border-radius: 4px;
margin-bottom: 12px;
width: 70%;
position: relative;
overflow: hidden;
}
.skeleton-line-short {
height: 12px;
background: #f0f2f5;
border-radius: 4px;
width: 50%;
}
.skeleton-item::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255,255,255,0),
rgba(255,255,255,0.3),
rgba(255,255,255,0)
);
animation: shimmer 1.5s infinite;
}
</style>

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