mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 07:33:53 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
783f17e9e8 | ||
|
|
9e305c2840 | ||
|
|
78fa5d3110 | ||
|
|
13c0a966ec | ||
|
|
d987619946 | ||
|
|
8fb9498fe6 | ||
|
|
b8f64a6693 | ||
|
|
e31bb047bf | ||
|
|
6897b813a6 | ||
|
|
83f86406b5 | ||
|
|
dde237ff0d | ||
|
|
6a4887e252 | ||
|
|
d945b94910 | ||
|
|
bc7dcf791d | ||
|
|
66c71fe3b7 | ||
|
|
16a7aeec3b | ||
|
|
94605345e5 | ||
|
|
ec9fa32c81 | ||
|
|
264aa15281 | ||
|
|
180a6ff824 | ||
|
|
5904ee1571 | ||
|
|
bc99341f85 | ||
|
|
8b9174b67e | ||
|
|
6c04a6cf69 | ||
|
|
14a63c4b97 | ||
|
|
098d13a34b | ||
|
|
488f247744 | ||
|
|
c74dbf03cd | ||
|
|
1f836c3235 | ||
|
|
bd2e2e77d5 | ||
|
|
6282ef14e8 | ||
|
|
558f23688f | ||
|
|
541f2de599 | ||
|
|
68116254cd | ||
|
|
77ff4599ea | ||
|
|
652f5a3247 | ||
|
|
9b6e57b143 | ||
|
|
bdc19256bf | ||
|
|
de5eaf4467 | ||
|
|
ca884833d1 | ||
|
|
9fc164a69d | ||
|
|
0da2da83a5 | ||
|
|
bfdfa44edd | ||
|
|
ed1cfe5eb6 | ||
|
|
b696f3f5db | ||
|
|
7a0cf5ef9a | ||
|
|
d1b7cb6a36 | ||
|
|
b21d699472 | ||
|
|
029fadd778 | ||
|
|
215d50235b | ||
|
|
000e676397 | ||
|
|
208c045d3d | ||
|
|
ee20f59a8e | ||
|
|
925cf3dabb | ||
|
|
0049d0e926 | ||
|
|
56959808a9 | ||
|
|
8667a9680e | ||
|
|
8c7d129089 | ||
|
|
a3a9b98a1d | ||
|
|
f2f66579cb | ||
|
|
1354cf2a87 | ||
|
|
15f5592429 | ||
|
|
dc69094f00 | ||
|
|
8ad3783a36 | ||
|
|
15d5dd8e92 | ||
|
|
b07edb977b | ||
|
|
1509fc2039 | ||
|
|
2877bb8faa | ||
|
|
a83410cb49 | ||
|
|
4ff9f2235b | ||
|
|
1bc611f097 | ||
|
|
4e0a3941ff | ||
|
|
4e885dac3e | ||
|
|
eafd131a22 | ||
|
|
289ce3716b | ||
|
|
2242070bdc | ||
|
|
ee83cea11c | ||
|
|
6e571a43d8 | ||
|
|
466a0245a9 | ||
|
|
d9ce5e4ce7 | ||
|
|
6b0dd291c2 | ||
|
|
4ff77d0d7e | ||
|
|
e00da5d8d2 | ||
|
|
1a525a59ab | ||
|
|
d5581c4236 | ||
|
|
4bf950574c | ||
|
|
0b9e434c4b | ||
|
|
c4b1466643 | ||
|
|
488842029b | ||
|
|
1b0ce59371 | ||
|
|
97bea89204 | ||
|
|
0892dfc601 | ||
|
|
20d66ff401 | ||
|
|
e2fda532d1 | ||
|
|
68035af7b3 | ||
|
|
ee25555f0e | ||
|
|
e528510f5c | ||
|
|
afa0527b4c | ||
|
|
dd7d23498f | ||
|
|
eaaea2b24c | ||
|
|
58d0aefae1 | ||
|
|
11c9915a70 | ||
|
|
6cf6b0d645 | ||
|
|
1980a39b73 | ||
|
|
8d3fbeeb22 | ||
|
|
2678644c2c | ||
|
|
25da9fe119 | ||
|
|
03d89d22ec | ||
|
|
100cd25ffc | ||
|
|
6284a74a6e | ||
|
|
978cd8ba8e | ||
|
|
c76f28824c | ||
|
|
703ecfae40 | ||
|
|
b15c59aa48 | ||
|
|
98a4ce701c | ||
|
|
2fc0de8f4e | ||
|
|
66fd551d68 | ||
|
|
6e4e9a002b | ||
|
|
91278b0b3d | ||
|
|
ff9b3ed1a2 | ||
|
|
0c855d4ba8 | ||
|
|
9176e9d255 | ||
|
|
2bab917b3e | ||
|
|
b80086a349 | ||
|
|
8a4fca9bd5 | ||
|
|
13288a6b3a | ||
|
|
57ed552f43 | ||
|
|
5a4d65986d | ||
|
|
427b3bb774 | ||
|
|
dcb6d0feab |
+7
-1
@@ -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
|
||||
|
||||
@@ -145,42 +145,24 @@ server:
|
||||
|
||||
---
|
||||
|
||||
## 部署方式 🚀
|
||||
## 部署文档
|
||||
|
||||
[](./docs/Deployment.md)
|
||||
本项目提供两种部署方式,请根据您的具体需求选择:
|
||||
|
||||
### 部署文档
|
||||
#### 🚀 部署方式选择
|
||||
|
||||
本项目支持`最简化`安装和`全模块`安装。请根据具体需求选择安装方式。
|
||||
| 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 |
|
||||
|---------|------|---------|---------|---------|
|
||||
| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库 | [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/deploy1.png)
|
||||
|
||||
`全模块`适合想体验完整功能,过程复杂。[请参考-全模块安装架构图](./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 +170,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 +267,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" />
|
||||
|
||||
+4
-1
@@ -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`文件,配置本项目必须的一个配置。
|
||||
|
||||
|
||||
+36
-68
@@ -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
|
||||
```
|
||||
|
||||
执行完后,再执行以下命令,查看日志信息。
|
||||
@@ -150,17 +134,16 @@ 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、注意,把`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
|
||||
```
|
||||
|
||||
@@ -202,24 +185,6 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
||||
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
|
||||
|
||||
|
||||
## 6. 版本升级操作
|
||||
|
||||
如果后期想升级版本,先备份你的模型密钥。
|
||||
|
||||
进入`xiaozhi-server`目录
|
||||
|
||||
5.1、执行以下命令
|
||||
|
||||
```
|
||||
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开始部署
|
||||
|
||||
# 方式二:本地源码运行全模块
|
||||
|
||||
## 1.安装MySQL数据库
|
||||
@@ -388,6 +353,9 @@ pip install -r requirements.txt
|
||||
|
||||
`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`文件。此刻你的配置文件内容应该是这样的:
|
||||
|
||||
```
|
||||
|
||||
+12
-3
@@ -4,7 +4,16 @@
|
||||
先按照这个教程配置项目环境[《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`文件。
|
||||
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
|
||||
|
||||
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
|
||||
|
||||
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
|
||||
|
||||
从这里下载虾哥[xiaozhi-esp32项目源码](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/`
|
||||
@@ -42,7 +51,7 @@ config WEBSOCKET_URL
|
||||
如果你是全模块部署本项目,就修改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/`,就把内容改成这个。
|
||||
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。
|
||||
|
||||
修改前:
|
||||
```
|
||||
@@ -56,7 +65,7 @@ config OTA_VERSION_URL
|
||||
```
|
||||
config OTA_VERSION_URL
|
||||
string "OTA Version URL"
|
||||
default "http://192.168.1.25:8000/xiaozhi/ota/"
|
||||
default "http://192.168.1.25:8002/xiaozhi/ota/"
|
||||
help
|
||||
The application will access this URL to check for updates.
|
||||
```
|
||||
|
||||
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 |
@@ -84,6 +84,21 @@ public interface Constant {
|
||||
*/
|
||||
String SERVER_SECRET = "server.secret";
|
||||
|
||||
/**
|
||||
* websocket地址
|
||||
*/
|
||||
String SERVER_WEBSOCKET = "server.websocket";
|
||||
|
||||
/**
|
||||
* 是否允许用户注册
|
||||
*/
|
||||
String SERVER_ALLOW_USER_REGISTER = "server.allow_user_register";
|
||||
|
||||
/**
|
||||
* 下发六位验证码时显示的控制面板地址
|
||||
*/
|
||||
String SERVER_FRONTED_URL = "server.fronted_url";
|
||||
|
||||
/**
|
||||
* 路径分割符
|
||||
*/
|
||||
@@ -153,4 +168,9 @@ public interface Constant {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
public static final String VERSION = "0.3.10";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+25
-23
@@ -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);
|
||||
}
|
||||
|
||||
+25
-4
@@ -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);
|
||||
}
|
||||
|
||||
+36
-9
@@ -15,48 +15,75 @@ 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地址");
|
||||
}
|
||||
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
|
||||
+291
@@ -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;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public interface DeviceService {
|
||||
/**
|
||||
* 检查设备是否激活
|
||||
*/
|
||||
DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId,
|
||||
DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
|
||||
DeviceReportReqDTO deviceReport);
|
||||
|
||||
/**
|
||||
@@ -74,4 +74,12 @@ public interface DeviceService {
|
||||
* @return 设备信息
|
||||
*/
|
||||
DeviceEntity getDeviceByMacAddress(String macAddress);
|
||||
|
||||
/**
|
||||
* 根据设备ID获取激活码
|
||||
*
|
||||
* @param deviceId 设备ID
|
||||
* @return 激活码
|
||||
*/
|
||||
String geCodeByDeviceId(String deviceId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
}
|
||||
+92
-42
@@ -9,7 +9,6 @@ import java.util.TimeZone;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
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;
|
||||
|
||||
@@ -19,6 +18,7 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
@@ -34,8 +34,10 @@ import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
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
|
||||
public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService {
|
||||
|
||||
@@ -43,18 +45,18 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
private final SysUserUtilService sysUserUtilService;
|
||||
|
||||
private final String frontedUrl;
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private final SysParamsService sysParamsService;
|
||||
|
||||
// 添加构造函数来初始化 deviceMapper
|
||||
public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
|
||||
@Value("${app.fronted-url:http://localhost:8001}") String frontedUrl,
|
||||
SysParamsService sysParamsService,
|
||||
RedisTemplate<String, Object> redisTemplate) {
|
||||
this.deviceDao = deviceDao;
|
||||
this.sysUserUtilService = sysUserUtilService;
|
||||
this.frontedUrl = frontedUrl;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.sysParamsService = sysParamsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -120,7 +122,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
}
|
||||
|
||||
@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());
|
||||
@@ -132,46 +134,33 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
|
||||
response.setFirmware(firmware);
|
||||
|
||||
DeviceEntity deviceById = getDeviceById(deviceId);
|
||||
// 添加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 URL is not configured");
|
||||
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 {
|
||||
log.error("WebSocket URL list is empty");
|
||||
websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
|
||||
}
|
||||
}
|
||||
|
||||
response.setWebsocket(websocket);
|
||||
|
||||
DeviceEntity deviceById = getDeviceById(macAddress);
|
||||
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);
|
||||
|
||||
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey);
|
||||
DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
|
||||
|
||||
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
|
||||
String cachedCode = (String) cacheMap.get("activation_code");
|
||||
code.setCode(cachedCode);
|
||||
code.setMessage(frontedUrl + "\n" + cachedCode);
|
||||
} 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);
|
||||
}
|
||||
|
||||
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
|
||||
response.setActivation(code);
|
||||
}
|
||||
|
||||
@@ -258,4 +247,65 @@ 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<Object, Object> cacheMap = redisTemplate.opsForHash().entries(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);
|
||||
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);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.util.Arrays;
|
||||
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("firmware_name", entity.getFirmwareName())
|
||||
.eq("type", entity.getType())
|
||||
.eq("version", entity.getVersion())
|
||||
.ne("id", entity.getId()); // 排除当前记录
|
||||
|
||||
if (baseDao.selectCount(queryWrapper) > 0) {
|
||||
throw new RuntimeException("已存在相同名称、类型和版本的固件,请修改后重试");
|
||||
}
|
||||
|
||||
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("firmware_name", entity.getFirmwareName())
|
||||
.eq("type", entity.getType())
|
||||
.eq("version", entity.getVersion());
|
||||
|
||||
if (baseDao.selectCount(queryWrapper) > 0) {
|
||||
throw new RuntimeException("已存在相同名称、类型和版本的固件,请勿重复添加");
|
||||
}
|
||||
|
||||
return baseDao.insert(entity) > 0;
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ public class ModelController {
|
||||
}
|
||||
// 将其他模型设置为非默认
|
||||
modelConfigService.setDefaultModel(entity.getModelType(), 0);
|
||||
entity.setIsEnabled(1);
|
||||
entity.setIsDefault(1);
|
||||
modelConfigService.updateById(entity);
|
||||
|
||||
|
||||
+1
@@ -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");
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
-3
@@ -2,6 +2,7 @@ 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.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -20,6 +21,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 +29,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 +46,7 @@ import xiaozhi.modules.sys.service.SysParamsService;
|
||||
@AllArgsConstructor
|
||||
public class SysParamsController {
|
||||
private final SysParamsService sysParamsService;
|
||||
private final ConfigService configService;
|
||||
|
||||
@GetMapping("page")
|
||||
@Operation(summary = "分页")
|
||||
@@ -77,7 +82,7 @@ public class SysParamsController {
|
||||
ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class);
|
||||
|
||||
sysParamsService.save(dto);
|
||||
|
||||
configService.getConfig(false);
|
||||
return new Result<Void>();
|
||||
}
|
||||
|
||||
@@ -89,11 +94,43 @@ public class SysParamsController {
|
||||
// 效验数据
|
||||
ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class);
|
||||
|
||||
sysParamsService.update(dto);
|
||||
// 验证WebSocket地址列表
|
||||
validateWebSocketUrls(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)) {
|
||||
// 验证WebSocket地址格式
|
||||
if (!WebSocketValidator.validateUrlFormat(url)) {
|
||||
throw new RenException("WebSocket地址格式不正确: " + url);
|
||||
}
|
||||
|
||||
// 测试WebSocket连接
|
||||
if (!WebSocketValidator.testConnection(url)) {
|
||||
throw new RenException("WebSocket连接测试失败: " + url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
@Operation(summary = "删除")
|
||||
@LogOperation("删除")
|
||||
@@ -103,7 +140,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();
|
||||
}
|
||||
|
||||
+16
@@ -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';
|
||||
+11
-1
@@ -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,19 @@
|
||||
-- 本文件用于初始化固件信息表,无需手动执行,在项目启动时会自动执行
|
||||
-- -------------------------------------------------------
|
||||
-- 初始化固件信息表
|
||||
drop table if exists `ai_ota`;
|
||||
CREATE TABLE `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 COLLATE=utf8mb4_0900_ai_ci COMMENT='固件信息表';
|
||||
@@ -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,6 @@
|
||||
-- 删除server.ip和server.port,改成在xiaozhi-server中配置,方便运行多个websocket
|
||||
delete from `sys_params` where id = 100;
|
||||
delete from `sys_params` where id = 101;
|
||||
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (106, 'server.websocket', 'null', 'string', 1, 'websocket地址,多个用;分隔');
|
||||
|
||||
@@ -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: 202504180758
|
||||
author: jiangkunyin
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202504180758.sql
|
||||
- changeSet:
|
||||
id: 202504221135
|
||||
author: John
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202504221135.sql
|
||||
- changeSet:
|
||||
id: 202504221646
|
||||
author: John
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202504221646.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 +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
|
||||
@@ -1 +1,3 @@
|
||||
VUE_APP_API_BASE_URL=/xiaozhi
|
||||
VUE_APP_API_BASE_URL=/xiaozhi
|
||||
# 是否开启CDN
|
||||
VUE_APP_USE_CDN=false
|
||||
Generated
+3249
-11
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,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 +44,4 @@
|
||||
"*.scss",
|
||||
"*.vue"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
<% } %>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 Worker,CDN资源缓存功能不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,136 @@
|
||||
<template>
|
||||
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
|
||||
<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>
|
||||
<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 class="upload-demo" action="#" :http-request="handleUpload" :before-upload="beforeUpload"
|
||||
:accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true">
|
||||
<el-button size="small" type="primary">点击上传</el-button>
|
||||
<div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk),且不超过100MB</div>
|
||||
</el-upload>
|
||||
</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,
|
||||
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' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
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
|
||||
Api.ota.uploadFirmware(file, (res) => {
|
||||
res = res.data
|
||||
if (res.code === 0) {
|
||||
this.form.firmwarePath = res.data
|
||||
this.form.size = file.size
|
||||
this.$message.success('固件文件上传成功')
|
||||
} else {
|
||||
this.$message.error(res.msg || '文件上传失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-demo {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.el-upload__tip {
|
||||
line-height: 1.2;
|
||||
padding-top: 5px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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 Worker,URL: ${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);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,6 +73,17 @@ const routes = [
|
||||
title: '参数管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/ota-management',
|
||||
name: 'OtaManagement',
|
||||
component: function () {
|
||||
return import('../views/OtaManagement.vue')
|
||||
},
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: 'OTA管理'
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
const router = new VueRouter({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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": "元控·青春" }
|
||||
]
|
||||
|
||||
|
||||
@@ -1,74 +1,104 @@
|
||||
<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"></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 +106,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 +170,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 +287,47 @@ 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;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -220,62 +336,213 @@ 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 +554,142 @@ 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 {
|
||||
:deep(.transparent-table .el-table__header th) {
|
||||
background: white !important;
|
||||
color: black;
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
: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;
|
||||
gap: 8px;
|
||||
padding-left: 26px;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 40vh);
|
||||
}
|
||||
|
||||
.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(.el-table__body-wrapper) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
:deep(.el-table__header-wrapper) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1144px) {
|
||||
.table_bottom {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: #5f70f3;
|
||||
color: white;
|
||||
: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>
|
||||
|
||||
@@ -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,91 @@
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<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 +136,6 @@
|
||||
<TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" />
|
||||
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" />
|
||||
</div>
|
||||
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -151,11 +158,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 +173,6 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
modelTypeText() {
|
||||
const map = {
|
||||
vad: '语言活动检测模型(VAD)',
|
||||
@@ -176,8 +184,6 @@ export default {
|
||||
}
|
||||
return map[this.activeTab] || '模型配置'
|
||||
},
|
||||
|
||||
|
||||
pageCount() {
|
||||
return Math.ceil(this.total / this.pageSize);
|
||||
},
|
||||
@@ -199,6 +205,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 +222,8 @@ export default {
|
||||
},
|
||||
handleMenuSelect(index) {
|
||||
this.activeTab = index;
|
||||
this.currentPage = 1;
|
||||
this.currentPage = 1; // 重置到第一页
|
||||
this.pageSize = 10; // 可选:重置每页条数
|
||||
this.loadData();
|
||||
},
|
||||
handleSearch() {
|
||||
@@ -296,9 +308,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 +322,7 @@ export default {
|
||||
} else {
|
||||
this.$message.error(data.msg || '保存失败');
|
||||
}
|
||||
done && done(); // 调用done回调关闭加载状态
|
||||
}
|
||||
);
|
||||
},
|
||||
@@ -380,6 +394,7 @@ export default {
|
||||
|
||||
// 获取模型配置列表
|
||||
loadData() {
|
||||
this.loading = true; // 开始加载
|
||||
const params = {
|
||||
modelType: this.activeTab,
|
||||
modelName: this.search,
|
||||
@@ -388,6 +403,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 +472,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 +492,6 @@ export default {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.right-operations {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -552,17 +564,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 +591,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 +603,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 +669,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 +684,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 +735,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;
|
||||
}
|
||||
@@ -815,11 +835,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 +863,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 +896,50 @@ 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,643 @@
|
||||
<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"
|
||||
: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="操作" 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" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from "@/apis/api";
|
||||
import FirmwareDialog from "@/components/FirmwareDialog.vue";
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import { FIRMWARE_TYPES } from "@/utils";
|
||||
import { formatDate, formatFileSize } from "@/utils/format";
|
||||
|
||||
export default {
|
||||
components: { HeaderBar, FirmwareDialog },
|
||||
data() {
|
||||
return {
|
||||
searchName: "",
|
||||
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() {
|
||||
const params = {
|
||||
pageNum: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
firmwareName: this.searchName || "",
|
||||
orderField: "create_date",
|
||||
order: "desc"
|
||||
};
|
||||
Api.ota.getOtaList(params, (res) => {
|
||||
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;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
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: 10px;
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.custom-selection-header {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
background-color: transparent;
|
||||
|
||||
.el-table__header-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.el-table__header th {
|
||||
background-color: white !important;
|
||||
color: black;
|
||||
font-weight: 600;
|
||||
height: 40px;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.el-table__body-wrapper {
|
||||
td {
|
||||
background-color: transparent;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
padding: 8px 0;
|
||||
height: 40px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table__row:hover>td {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
</style>
|
||||
@@ -15,9 +15,20 @@
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="params-card" shadow="never">
|
||||
<el-table ref="paramsTable" :data="paramsList" class="transparent-table"
|
||||
:header-cell-class-name="headerCellClassName">
|
||||
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
|
||||
<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="paramCode" align="center"></el-table-column>
|
||||
<el-table-column label="参数值" prop="paramValue" align="center"
|
||||
show-overflow-tooltip></el-table-column>
|
||||
@@ -37,9 +48,17 @@
|
||||
</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>
|
||||
@@ -65,7 +84,6 @@
|
||||
<param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit"
|
||||
@cancel="dialogVisible = false" />
|
||||
|
||||
<div class="copyright">©2025 xiaozhi-esp32-server</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,7 +99,9 @@ export default {
|
||||
searchCode: "",
|
||||
paramsList: [],
|
||||
currentPage: 1,
|
||||
pageSize: 5,
|
||||
loading: false,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
total: 0,
|
||||
dialogVisible: false,
|
||||
dialogTitle: "新增参数",
|
||||
@@ -96,7 +116,9 @@ export default {
|
||||
},
|
||||
created() {
|
||||
this.fetchParams();
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
pageCount() {
|
||||
return Math.ceil(this.total / this.pageSize);
|
||||
@@ -118,34 +140,45 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handlePageSizeChange(val) {
|
||||
this.pageSize = val;
|
||||
this.currentPage = 1;
|
||||
this.fetchParams();
|
||||
},
|
||||
fetchParams() {
|
||||
Api.admin.getParamsList(
|
||||
{
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
paramCode: this.searchCode,
|
||||
},
|
||||
({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.paramsList = data.data.list;
|
||||
this.total = data.data.total;
|
||||
} else {
|
||||
this.$message.error(data.msg || '获取参数列表失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
this.loading = true;
|
||||
Api.admin.getParamsList(
|
||||
{
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
paramCode: this.searchCode,
|
||||
},
|
||||
({ data }) => {
|
||||
this.loading = false;
|
||||
if (data.code === 0) {
|
||||
this.paramsList = data.data.list.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}));
|
||||
this.total = data.data.total;
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: data.msg || '获取参数列表失败',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
handleSearch() {
|
||||
this.currentPage = 1;
|
||||
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 +195,60 @@ export default {
|
||||
this.paramForm = { ...row };
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
handleSubmit(form) {
|
||||
if (form.id) {
|
||||
// 编辑
|
||||
Api.admin.updateParam(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success("修改成功");
|
||||
this.dialogVisible = false;
|
||||
this.fetchParams();
|
||||
}
|
||||
|
||||
handleSubmit({ form, done }) {
|
||||
if (form.id) {
|
||||
// 编辑
|
||||
Api.admin.updateParam(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message:"修改成功",
|
||||
showClose:true
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
Api.admin.addParam(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success("新增成功");
|
||||
this.dialogVisible = false;
|
||||
this.fetchParams();
|
||||
}
|
||||
this.dialogVisible = false;
|
||||
this.fetchParams();
|
||||
}
|
||||
done && done();
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
Api.admin.addParam(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
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 +258,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 +271,7 @@ export default {
|
||||
message: `成功删除${paramCount}个参数`,
|
||||
showClose: true
|
||||
});
|
||||
this.fetchParams(); // 刷新参数列表
|
||||
this.fetchParams();
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: data.msg || '删除失败,请重试',
|
||||
@@ -278,15 +337,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;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
background: rgba(237, 242, 255, 0.5);
|
||||
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 {
|
||||
@@ -331,14 +395,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;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
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 +424,7 @@ export default {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.ctrl_btn {
|
||||
@@ -382,29 +461,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 +495,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,7 +531,19 @@ 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;
|
||||
color: black;
|
||||
@@ -482,19 +563,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 +618,84 @@ 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>
|
||||
|
||||
@@ -15,9 +15,19 @@
|
||||
<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 +52,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 +60,15 @@
|
||||
<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,7 +89,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="copyright">©2025 xiaozhi-esp32-server</div>
|
||||
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,10 +106,12 @@ export default {
|
||||
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 +138,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 +153,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 +169,13 @@ export default {
|
||||
this.fetchUsers();
|
||||
},
|
||||
handleSelectAll() {
|
||||
if (this.isAllSelected) {
|
||||
this.$refs.userTable.clearSelection();
|
||||
} else {
|
||||
this.$refs.userTable.toggleAllSelection();
|
||||
}
|
||||
this.isAllSelected = !this.isAllSelected;
|
||||
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,20 +240,12 @@ export default {
|
||||
});
|
||||
},
|
||||
batchEnable() {
|
||||
const selectedUsers = this.$refs.userTable.selection;
|
||||
if (selectedUsers.length === 0) {
|
||||
this.$message.warning("请先选择需要启用的用户");
|
||||
return;
|
||||
}
|
||||
this.handleChangeStatus(selectedUsers, 1);
|
||||
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;
|
||||
}
|
||||
this.handleChangeStatus(selectedUsers, 0);
|
||||
const selectedUsers = this.userList.filter(user => user.selected);
|
||||
this.handleChangeStatus(selectedUsers, 0);
|
||||
},
|
||||
resetPassword(row) {
|
||||
this.$confirm("重置后将会生成新密码,是否继续?", "提示", {
|
||||
@@ -267,12 +288,6 @@ export default {
|
||||
})
|
||||
.catch(() => { });
|
||||
},
|
||||
headerCellClassName({ columnIndex }) {
|
||||
if (columnIndex === 0) {
|
||||
return "custom-selection-header";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
goFirst() {
|
||||
this.currentPage = 1;
|
||||
this.fetchUsers();
|
||||
@@ -344,15 +359,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 +419,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 +499,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 +533,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 +569,19 @@ 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 +610,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 +646,75 @@ 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
<div
|
||||
style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;display: flex;justify-content: space-between;margin-top: 20px;">
|
||||
<div style="cursor: pointer;" @click="goToRegister">新用户注册</div>
|
||||
<div v-if="allowUserRegister" style="cursor: pointer;" @click="goToRegister">新用户注册</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-btn" @click="login">登录</div>
|
||||
@@ -51,9 +51,7 @@
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer>
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
<version-footer />
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
@@ -61,11 +59,20 @@
|
||||
|
||||
<script>
|
||||
import Api from '@/apis/api';
|
||||
import VersionFooter from '@/components/VersionFooter.vue';
|
||||
import { getUUID, goToPage, showDanger, showSuccess } from '@/utils';
|
||||
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'login',
|
||||
components: {
|
||||
VersionFooter
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
allowUserRegister: state => state.pubConfig.allowUserRegister
|
||||
})
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeName: "username",
|
||||
@@ -81,6 +88,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.fetchCaptcha();
|
||||
this.$store.dispatch('fetchPubConfig');
|
||||
},
|
||||
methods: {
|
||||
fetchCaptcha() {
|
||||
@@ -144,7 +152,7 @@ export default {
|
||||
|
||||
goToRegister() {
|
||||
goToPage('/register')
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<div class="welcome" @keyup.enter="register">
|
||||
<el-container style="height: 100%;">
|
||||
<!-- 保持相同的头部 -->
|
||||
<el-header>
|
||||
@@ -72,9 +72,7 @@
|
||||
|
||||
<!-- 保持相同的页脚 -->
|
||||
<el-footer>
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
<version-footer />
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
@@ -82,10 +80,20 @@
|
||||
|
||||
<script>
|
||||
import Api from '@/apis/api';
|
||||
import VersionFooter from '@/components/VersionFooter.vue';
|
||||
import { getUUID, goToPage, showDanger, showSuccess } from '@/utils';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'register',
|
||||
components: {
|
||||
VersionFooter
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
allowUserRegister: state => state.pubConfig.allowUserRegister
|
||||
})
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
@@ -99,6 +107,14 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('fetchPubConfig').then(() => {
|
||||
if (!this.allowUserRegister) {
|
||||
showDanger('当前不允许普通用户注册');
|
||||
setTimeout(() => {
|
||||
goToPage('/login');
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
this.fetchCaptcha();
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,90 +1,129 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<HeaderBar />
|
||||
<el-main style="padding: 16px;display: flex;flex-direction: column;">
|
||||
<div style="border-radius: 16px;background: #fafcfe; border: 1px solid #e8f0ff;">
|
||||
<div
|
||||
style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;">
|
||||
<div
|
||||
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;">
|
||||
<img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;" />
|
||||
</div>
|
||||
{{ form.agentName }}
|
||||
</div>
|
||||
<div style="height: 1px;background: #e8f0ff;" />
|
||||
<el-form ref="form" :model="form" label-width="72px">
|
||||
<div style="padding: 16px 24px;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px;">
|
||||
<div>
|
||||
<el-form-item label="助手昵称:">
|
||||
<div class="input-46" style="width: 100%;">
|
||||
<el-input v-model="form.agentName" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色模版:">
|
||||
<div style="display: flex;gap: 8px;flex-wrap: wrap;">
|
||||
<div v-for="(template, index) in templates" :key="`template-${index}`" class="template-item"
|
||||
:class="{ 'template-loading': loadingTemplate }" @click="selectTemplate(template)">
|
||||
{{ template.agentName }}
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色介绍:">
|
||||
<div class="textarea-box">
|
||||
<el-input type="textarea" rows="5" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
|
||||
maxlength="2000" show-word-limit />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="语言编码:">
|
||||
<div class="input-46" style="width: 100%;">
|
||||
<el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="交互语种:">
|
||||
<div class="input-46" style="width: 100%;">
|
||||
<el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div>
|
||||
<el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
|
||||
class="model-item">
|
||||
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="select-field">
|
||||
<el-option v-for="(item, optionIndex) in modelOptions[model.type]"
|
||||
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色音色:">
|
||||
<div style="display: flex;gap: 8px;align-items: center;">
|
||||
<div class="input-46" style="width: 100%;">
|
||||
<el-select v-model="form.ttsVoiceId" placeholder="请选择" style="width: 100%;">
|
||||
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
|
||||
:value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<HeaderBar/>
|
||||
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">角色配置</h2>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="config-card" shadow="never">
|
||||
<div class="config-header">
|
||||
<div class="header-icon">
|
||||
<img loading="lazy" src="@/assets/home/setting-user.png" alt="">
|
||||
</div>
|
||||
<span class="header-title">{{ form.agentName }}</span>
|
||||
<button class="custom-close-btn" @click="goToHome">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<div style="display: flex;padding: 16px;gap: 8px;align-items: center;">
|
||||
<div class="save-btn" @click="saveConfig">
|
||||
保存配置
|
||||
</div>
|
||||
<div class="reset-btn" @click="resetConfig">
|
||||
重制
|
||||
</div>
|
||||
<div class="clear-text">
|
||||
<img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;" />
|
||||
保存配置后,需要重启设备,新的配置才会生效。
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<el-form ref="form" :model="form" label-width="72px">
|
||||
<div class="form-content">
|
||||
<div class="form-grid">
|
||||
<div class="form-column">
|
||||
<el-form-item label="助手昵称:">
|
||||
<el-input v-model="form.agentName" class="form-input"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色模版:">
|
||||
<div class="template-container">
|
||||
<div
|
||||
v-for="(template, index) in templates"
|
||||
:key="`template-${index}`"
|
||||
class="template-item"
|
||||
:class="{ 'template-loading': loadingTemplate }"
|
||||
@click="selectTemplate(template)"
|
||||
>
|
||||
{{ template.agentName }}
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色介绍:">
|
||||
<el-input
|
||||
type="textarea"
|
||||
rows="5"
|
||||
resize="none"
|
||||
placeholder="请输入内容"
|
||||
v-model="form.systemPrompt"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
class="form-textarea"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="语言编码:">
|
||||
<el-input
|
||||
v-model="form.langCode"
|
||||
placeholder="请输入语言编码,如:zh_CN"
|
||||
maxlength="10"
|
||||
show-word-limit
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="交互语种:">
|
||||
<el-input
|
||||
v-model="form.language"
|
||||
placeholder="请输入交互语种,如:中文"
|
||||
maxlength="10"
|
||||
show-word-limit
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
|
||||
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
|
||||
<div class="hint-text">
|
||||
<img loading="lazy" src="@/assets/home/red-info.png" alt="">
|
||||
<span>保存配置后,需要重启设备,新的配置才会生效。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-column">
|
||||
<el-form-item
|
||||
v-for="(model, index) in models"
|
||||
:key="`model-${index}`"
|
||||
:label="model.label"
|
||||
class="model-item"
|
||||
>
|
||||
<el-select
|
||||
v-model="form.model[model.key]"
|
||||
filterable
|
||||
placeholder="请选择"
|
||||
class="form-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="(item, optionIndex) in modelOptions[model.type]"
|
||||
:key="`option-${index}-${optionIndex}`"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色音色:">
|
||||
<el-select
|
||||
v-model="form.ttsVoiceId"
|
||||
placeholder="请选择"
|
||||
class="form-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="(item, index) in voiceOptions"
|
||||
:key="`voice-${index}`"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
</el-main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -92,10 +131,9 @@
|
||||
import Api from '@/apis/api';
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
|
||||
|
||||
export default {
|
||||
name: 'RoleConfigPage',
|
||||
components: { HeaderBar },
|
||||
components: {HeaderBar},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
@@ -116,12 +154,12 @@ export default {
|
||||
}
|
||||
},
|
||||
models: [
|
||||
{ label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD' },
|
||||
{ label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR' },
|
||||
{ label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM' },
|
||||
{ label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent' },
|
||||
{ label: '记忆(Memory)', key: 'memModelId', type: 'Memory' },
|
||||
{ label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS' },
|
||||
{label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD'},
|
||||
{label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR'},
|
||||
{label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM'},
|
||||
{label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent'},
|
||||
{label: '记忆(Memory)', key: 'memModelId', type: 'Memory'},
|
||||
{label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS'},
|
||||
],
|
||||
modelOptions: {},
|
||||
templates: [],
|
||||
@@ -130,6 +168,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToHome() {
|
||||
this.$router.push('/home');
|
||||
},
|
||||
saveConfig() {
|
||||
const configData = {
|
||||
agentCode: this.form.agentCode,
|
||||
@@ -146,7 +187,7 @@ export default {
|
||||
language: this.form.language,
|
||||
sort: this.form.sort
|
||||
};
|
||||
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
|
||||
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({data}) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message: '配置保存成功',
|
||||
@@ -166,7 +207,6 @@ export default {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
// 重置表单
|
||||
this.form = {
|
||||
agentCode: "",
|
||||
agentName: "",
|
||||
@@ -189,10 +229,10 @@ export default {
|
||||
showClose: true
|
||||
})
|
||||
}).catch(() => {
|
||||
})
|
||||
});
|
||||
},
|
||||
fetchTemplates() {
|
||||
Api.agent.getAgentTemplate(({ data }) => {
|
||||
Api.agent.getAgentTemplate(({data}) => {
|
||||
if (data.code === 0) {
|
||||
this.templates = data.data;
|
||||
} else {
|
||||
@@ -237,7 +277,7 @@ export default {
|
||||
};
|
||||
},
|
||||
fetchAgentConfig(agentId) {
|
||||
Api.agent.getDeviceConfig(agentId, ({ data }) => {
|
||||
Api.agent.getDeviceConfig(agentId, ({data}) => {
|
||||
if (data.code === 0) {
|
||||
this.form = {
|
||||
...this.form,
|
||||
@@ -257,9 +297,8 @@ export default {
|
||||
});
|
||||
},
|
||||
fetchModelOptions() {
|
||||
// 为每个模型类型获取选项
|
||||
this.models.forEach(model => {
|
||||
Api.model.getModelNames(model.type, '', ({ data }) => {
|
||||
Api.model.getModelNames(model.type, '', ({data}) => {
|
||||
if (data.code === 0) {
|
||||
this.$set(this.modelOptions, model.type, data.data.map(item => ({
|
||||
value: item.id,
|
||||
@@ -276,7 +315,7 @@ export default {
|
||||
this.voiceOptions = [];
|
||||
return;
|
||||
}
|
||||
Api.model.getModelVoices(modelId, '', ({ data }) => {
|
||||
Api.model.getModelVoices(modelId, '', ({data}) => {
|
||||
if (data.code === 0 && data.data) {
|
||||
this.voiceOptions = data.data.map(voice => ({
|
||||
value: voice.id,
|
||||
@@ -291,7 +330,6 @@ export default {
|
||||
watch: {
|
||||
'form.model.ttsModelId': {
|
||||
handler(newVal, oldVal) {
|
||||
console.log('TTS模型变化:', newVal);
|
||||
if (oldVal && newVal !== oldVal) {
|
||||
this.form.ttsVoiceId = '';
|
||||
this.fetchVoiceOptions(newVal);
|
||||
@@ -327,60 +365,132 @@ 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;
|
||||
/* 兼容老版本WebKit浏览器 */
|
||||
-o-background-size: cover;
|
||||
/* 兼容老版本Opera浏览器 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-form-item ::v-deep .el-form-item__label {
|
||||
font-size: 10px !important;
|
||||
color: #3d4566 !important;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
padding-bottom: 2px;
|
||||
.operation-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.select-field {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
border: 1px solid #e4e6ef;
|
||||
background: #f6f8fb;
|
||||
border-radius: 8px;
|
||||
height: 36px !important;
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.audio-box {
|
||||
.main-wrapper {
|
||||
margin: 5px 22px;
|
||||
border-radius: 15px;
|
||||
height: calc(100vh - 24vh);
|
||||
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;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
height: 37px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e4e6ef;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 48px;
|
||||
height: 19px;
|
||||
background: #fd8383;
|
||||
border-radius: 10px;
|
||||
line-height: 19px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
.content-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
overflow: auto;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.clear-text {
|
||||
color: #979db1;
|
||||
font-size: 11px;
|
||||
.config-card {
|
||||
background: white;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 0 0 5px 0;
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: #3d4566;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 37px;
|
||||
height: 37px;
|
||||
background: #5778ff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-icon img {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e8f0ff;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
@@ -401,47 +511,91 @@ export default {
|
||||
background-color: #d0d8ff;
|
||||
}
|
||||
|
||||
.prompt-bottom {
|
||||
margin-bottom: 4px;
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-46 {
|
||||
border: 1px solid #e4e6ef;
|
||||
background: #f6f8fb;
|
||||
border-radius: 8px;
|
||||
height: 36px !important;
|
||||
.action-bar {
|
||||
.el-button.save-btn {
|
||||
background: #5778ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
padding: 10px 20px;
|
||||
width: 100px;
|
||||
height: 35px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.el-button.reset-btn {
|
||||
background: #e6ebff;
|
||||
color: #5778ff;
|
||||
border: 1px solid #adbdff;
|
||||
border-radius: 18px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.save-btn,
|
||||
.reset-btn {
|
||||
width: 112px;
|
||||
height: 37px;
|
||||
border-radius: 18px;
|
||||
line-height: 37px;
|
||||
box-sizing: border-box;
|
||||
.hint-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #979db1;
|
||||
font-size: 11px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.hint-text img {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
::v-deep .el-form-item__label {
|
||||
font-size: 10px !important;
|
||||
color: #3d4566 !important;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
::v-deep .el-textarea .el-input__count {
|
||||
color: #909399;
|
||||
background: none;
|
||||
position: absolute;
|
||||
font-size: 12px;
|
||||
bottom: -10%;
|
||||
right: 3%;
|
||||
}
|
||||
|
||||
.custom-close-btn {
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #cfcfcf;
|
||||
background: none;
|
||||
font-size: 30px;
|
||||
font-weight: lighter;
|
||||
color: #cfcfcf;
|
||||
cursor: pointer;
|
||||
font-size: 11px
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
border-radius: 18px;
|
||||
background: #5778ff;
|
||||
color: #fff;
|
||||
.custom-close-btn:hover {
|
||||
color: #409EFF;
|
||||
border-color: #409EFF;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
border: 1px solid #adbdff;
|
||||
background: #e6ebff;
|
||||
color: #5778ff;
|
||||
}
|
||||
|
||||
.textarea-box {
|
||||
border: 1px solid #e4e6ef;
|
||||
border-radius: 8px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,400 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1 class="title">XiaoZhi ESP32 Server 测试助手</h1>
|
||||
<div class="chat-container" ref="chatContainer">
|
||||
<div v-for="(message, index) in messages" :key="index"
|
||||
:class="['message', message.role === 'user' ? 'user' : 'assistant']">
|
||||
<span>{{ message.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button @click="toggleRecording" :disabled="wsStatus !== 'connected'" :class="{ recording: isRecording }">
|
||||
{{ isRecording ? '停止录音' : '开始录音' }}
|
||||
</button>
|
||||
<p>WebSocket: {{ wsStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OpusDecoder } from 'opus-decoder';
|
||||
import Recorder from 'opus-recorder';
|
||||
|
||||
export default {
|
||||
name: 'TestPage',
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
wsStatus: 'disconnected',
|
||||
isRecording: false,
|
||||
ws: null,
|
||||
recorder: null,
|
||||
stream: null,
|
||||
audioContext: null,
|
||||
sourceNode: null,
|
||||
audioDecoder: null,
|
||||
audioBufferQueue: [], // 当前句子的音频缓冲区
|
||||
playbackQueue: [], // 播放队列,存储待播放的句子
|
||||
isPlaying: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
connectWebSocket() {
|
||||
this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.onopen = () => {
|
||||
this.wsStatus = 'connected';
|
||||
console.log('WebSocket 连接成功');
|
||||
this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
|
||||
this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
|
||||
};
|
||||
this.ws.onmessage = async (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data);
|
||||
console.log('收到文本消息:', msg);
|
||||
|
||||
if (msg.type === 'stt') {
|
||||
this.messages.push({ role: 'user', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
} else if (msg.type === 'llm') {
|
||||
this.messages.push({ role: 'assistant', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
} else if (msg.type === 'tts') {
|
||||
if (msg.state === 'sentence_start') {
|
||||
this.audioBufferQueue = [];
|
||||
const lastMessage = this.messages[this.messages.length - 1];
|
||||
if (!lastMessage || lastMessage.content !== msg.text) {
|
||||
this.messages.push({ role: 'assistant', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
}
|
||||
} else if (msg.state === 'sentence_end') {
|
||||
if (this.audioBufferQueue.length > 0) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
} else if (msg.state === 'stop') {
|
||||
console.log('TTS 任务结束');
|
||||
if (this.audioBufferQueue.length > 0) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'hello') {
|
||||
console.log('收到服务器初始化消息:', msg);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log('收到音频帧,大小:', event.data.byteLength, '字节');
|
||||
const opusFrame = new Uint8Array(event.data);
|
||||
const frameHead = Array.from(opusFrame.slice(0, 8))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
console.log('音频帧前8字节:', frameHead);
|
||||
|
||||
try {
|
||||
const decoded = this.audioDecoder.decodeFrame(opusFrame);
|
||||
console.log('解码结果:', decoded);
|
||||
if (decoded && decoded.channelData && decoded.channelData[0]) {
|
||||
const pcmData = decoded.channelData[0];
|
||||
if (pcmData.length > 0) {
|
||||
this.audioBufferQueue.push(pcmData);
|
||||
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
|
||||
if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
} else {
|
||||
console.warn('解码成功,但 PCM 数据长度为 0');
|
||||
}
|
||||
} else {
|
||||
console.warn('解码结果无效:', decoded);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Opus 解码错误:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error);
|
||||
this.wsStatus = 'error';
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.wsStatus = 'disconnected';
|
||||
console.log('WebSocket 断开');
|
||||
setTimeout(this.connectWebSocket, 1000);
|
||||
};
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.chatContainer) {
|
||||
this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
},
|
||||
async playNextInQueue() {
|
||||
if (this.isPlaying || this.playbackQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.isPlaying = true;
|
||||
|
||||
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
}
|
||||
|
||||
const pcmBuffers = this.playbackQueue.shift();
|
||||
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
|
||||
if (totalLength === 0) {
|
||||
console.error('音频缓冲区总长度为 0,无法播放');
|
||||
this.isPlaying = false;
|
||||
this.playNextInQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedPcm = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const pcm of pcmBuffers) {
|
||||
mergedPcm.set(pcm, offset);
|
||||
offset += pcm.length;
|
||||
}
|
||||
|
||||
const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
|
||||
audioBuffer.getChannelData(0).set(mergedPcm);
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(this.audioContext.destination);
|
||||
source.onended = () => {
|
||||
this.isPlaying = false;
|
||||
console.log('音频播放结束');
|
||||
this.playNextInQueue();
|
||||
};
|
||||
source.start();
|
||||
console.log('开始播放音频,总长度:', totalLength);
|
||||
},
|
||||
stripOggContainer(data) {
|
||||
let arrayBuffer;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
arrayBuffer = data;
|
||||
} else if (data.buffer instanceof ArrayBuffer) {
|
||||
arrayBuffer = data.buffer;
|
||||
} else {
|
||||
console.error('输入数据类型不支持:', data);
|
||||
return [data];
|
||||
}
|
||||
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
const frames = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < arrayBuffer.byteLength) {
|
||||
if (dataView.getUint32(offset) === 0x4F676753) { // 'OggS'
|
||||
const segmentTableLength = dataView.getUint8(offset + 26);
|
||||
const segmentTableOffset = offset + 27;
|
||||
let segmentOffset = segmentTableOffset + segmentTableLength;
|
||||
|
||||
for (let i = 0; i < segmentTableLength; i++) {
|
||||
const segmentLength = dataView.getUint8(segmentTableOffset + i);
|
||||
const segmentData = arrayBuffer.slice(segmentOffset, segmentOffset + segmentLength);
|
||||
segmentOffset += segmentLength;
|
||||
|
||||
const header = new TextDecoder().decode(segmentData.slice(0, 8));
|
||||
if (header === 'OpusHead' || header === 'OpusTags') {
|
||||
console.log(`跳过 ${header},大小: ${segmentLength} 字节`);
|
||||
} else if (segmentLength >= 50 && segmentLength <= 300) {
|
||||
frames.push(segmentData);
|
||||
}
|
||||
}
|
||||
offset = segmentOffset;
|
||||
} else {
|
||||
const remainingLength = arrayBuffer.byteLength - offset;
|
||||
if (remainingLength >= 50 && remainingLength <= 300) {
|
||||
frames.push(arrayBuffer.slice(offset));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (frames.length === 0) {
|
||||
console.warn('未找到有效裸 Opus 帧,返回原始数据');
|
||||
return [arrayBuffer];
|
||||
}
|
||||
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
|
||||
return frames;
|
||||
},
|
||||
async initRecorder() {
|
||||
console.log('开始初始化录音');
|
||||
try {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log('获取麦克风权限成功,stream:', this.stream);
|
||||
|
||||
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
}
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
|
||||
|
||||
this.recorder = new Recorder({
|
||||
encoderPath: '/encoderWorker.min.js',
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
frameSize: 960,
|
||||
encoderApplication: 2048,
|
||||
encoderFrameSize: 60,
|
||||
encoderBitRate: 24000,
|
||||
monitorGain: 0,
|
||||
sourceNode: this.sourceNode,
|
||||
ogg: false,
|
||||
streamPages: false,
|
||||
maxFramesPerPage: 1,
|
||||
});
|
||||
|
||||
this.recorder.ondataavailable = (data) => {
|
||||
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
|
||||
const frames = this.stripOggContainer(data);
|
||||
frames.forEach((frame, index) => {
|
||||
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
console.log(`帧 ${index} 大小: ${frame.byteLength} 字节,前8字节: ${frameHead}`);
|
||||
|
||||
if (frame.byteLength < 50 || frame.byteLength > 300) {
|
||||
console.warn(`帧 ${index} 大小异常: ${frame.byteLength} 字节,预期 50-300 字节`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(frame);
|
||||
console.log('发送裸 Opus 帧:', frame.byteLength);
|
||||
} else {
|
||||
console.warn('WebSocket 未连接,跳过发送');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.recorder.onstart = () => {
|
||||
console.log('录音已启动');
|
||||
};
|
||||
|
||||
this.recorder.onstop = () => {
|
||||
console.log('录音停止');
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
this.sourceNode = null;
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
console.log('Recorder 初始化成功');
|
||||
} catch (err) {
|
||||
console.error('初始化录音失败:', err);
|
||||
alert('无法访问麦克风或录音初始化失败,请检查权限');
|
||||
}
|
||||
},
|
||||
async toggleRecording() {
|
||||
console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
|
||||
if (!this.recorder) {
|
||||
await this.initRecorder();
|
||||
if (!this.recorder) {
|
||||
console.error('recorder 初始化失败');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.isRecording) {
|
||||
console.log('停止录音');
|
||||
this.recorder.stop();
|
||||
this.isRecording = false;
|
||||
} else {
|
||||
try {
|
||||
console.log('开始录音');
|
||||
await this.initRecorder();
|
||||
await this.recorder.start();
|
||||
console.log('录音开始后,状态:', this.recorder.state);
|
||||
this.isRecording = true;
|
||||
} catch (err) {
|
||||
console.error('录音启动失败:', err);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
console.log('组件挂载,初始化 WebSocket');
|
||||
this.connectWebSocket();
|
||||
},
|
||||
destroyed() {
|
||||
if (this.ws) this.ws.close();
|
||||
if (this.stream) this.stream.getTracks().forEach(track => track.stop());
|
||||
if (this.recorder) this.recorder.stop();
|
||||
if (this.audioContext) this.audioContext.close();
|
||||
if (this.audioDecoder) this.audioDecoder.destroy();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 10px 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.user {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
background: #e9ecef;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.controls {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.recording {
|
||||
background: #dc3545;
|
||||
}
|
||||
</style>
|
||||
@@ -6,15 +6,47 @@ const TerserPlugin = require('terser-webpack-plugin');
|
||||
const CompressionPlugin = require('compression-webpack-plugin')
|
||||
// BundleAnalyzerPlugin 用于分析打包后的文件
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
// WorkboxPlugin 用于生成Service Worker
|
||||
const { InjectManifest } = require('workbox-webpack-plugin');
|
||||
// 引入 path 模块
|
||||
const path = require('path');
|
||||
|
||||
const path = require('path')
|
||||
|
||||
function resolve(dir) {
|
||||
return path.join(__dirname, dir)
|
||||
}
|
||||
|
||||
// 确保加载 .env 文件
|
||||
dotenv.config();
|
||||
|
||||
// 定义CDN资源列表,确保Service Worker也能访问
|
||||
const cdnResources = {
|
||||
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'
|
||||
],
|
||||
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'
|
||||
]
|
||||
};
|
||||
|
||||
// 判断是否使用CDN
|
||||
const useCDN = process.env.VUE_APP_USE_CDN === 'true';
|
||||
|
||||
module.exports = defineConfig({
|
||||
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
|
||||
devServer: {
|
||||
port: 8001, // 指定端口为 8001
|
||||
proxy: {
|
||||
'/xiaozhi': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true
|
||||
}
|
||||
},
|
||||
client: {
|
||||
overlay: false, // 不显示 webpack 错误覆盖层
|
||||
@@ -25,21 +57,9 @@ module.exports = defineConfig({
|
||||
// 修改 HTML 插件配置,动态插入 CDN 链接
|
||||
config.plugin('html')
|
||||
.tap(args => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
args[0].cdn = {
|
||||
css: [
|
||||
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/theme-chalk/index.css',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
|
||||
],
|
||||
js: [
|
||||
'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/vue-router@3.6.5/dist/vue-router.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/index.js',
|
||||
'https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/dist/opus-decoder.min.js'
|
||||
]
|
||||
};
|
||||
// 根据配置决定是否使用CDN
|
||||
if (process.env.NODE_ENV === 'production' && useCDN) {
|
||||
args[0].cdn = cdnResources;
|
||||
}
|
||||
return args;
|
||||
});
|
||||
@@ -97,14 +117,38 @@ module.exports = defineConfig({
|
||||
minRatio: 0.8
|
||||
})
|
||||
);
|
||||
config.externals = {
|
||||
'vue': 'Vue',
|
||||
'vue-router': 'VueRouter',
|
||||
'vuex': 'Vuex',
|
||||
'element-ui': 'ELEMENT',
|
||||
'axios': 'axios',
|
||||
'opus-decoder': 'OpusDecoder'
|
||||
};
|
||||
|
||||
// 根据是否使用CDN来决定是否添加Service Worker
|
||||
config.plugins.push(
|
||||
new InjectManifest({
|
||||
swSrc: path.resolve(__dirname, 'src/service-worker.js'),
|
||||
swDest: 'service-worker.js',
|
||||
exclude: [/\.map$/, /asset-manifest\.json$/],
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
|
||||
// 自定义Service Worker注入点
|
||||
injectionPoint: 'self.__WB_MANIFEST',
|
||||
// 添加额外信息传递给Service Worker
|
||||
additionalManifestEntries: useCDN ?
|
||||
[{ url: 'cdn-mode', revision: 'enabled' }] :
|
||||
[{ url: 'cdn-mode', revision: 'disabled' }]
|
||||
})
|
||||
);
|
||||
|
||||
// 如果使用CDN,则配置externals排除依赖包
|
||||
if (useCDN) {
|
||||
config.externals = {
|
||||
'vue': 'Vue',
|
||||
'vue-router': 'VueRouter',
|
||||
'vuex': 'Vuex',
|
||||
'element-ui': 'ELEMENT',
|
||||
'axios': 'axios',
|
||||
'opus-decoder': 'OpusDecoder'
|
||||
};
|
||||
} else {
|
||||
// 确保不使用CDN时不设置externals,让webpack打包所有依赖
|
||||
config.externals = {};
|
||||
}
|
||||
|
||||
if (process.env.ANALYZE === 'true') { // 通过环境变量控制
|
||||
config.plugins.push(
|
||||
new BundleAnalyzerPlugin({
|
||||
@@ -124,13 +168,13 @@ module.exports = defineConfig({
|
||||
config: [__filename] // 每次配置文件修改时缓存失效
|
||||
}
|
||||
};
|
||||
config.resolve.alias = {
|
||||
'@': path.resolve(__dirname, 'src'), // 让 '@' 代表 'src' 目录
|
||||
'@assets': path.resolve(__dirname, 'src/assets'), // 设置 '@assets' 为 'src/assets' 目录
|
||||
'@components': path.resolve(__dirname, 'src/components'), // 设置 '@components' 为 'src/components' 目录
|
||||
'@views': path.resolve(__dirname, 'src/views'), // 设置 '@views' 为 'src/views' 目录
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 将CDN资源信息暴露给service-worker.js
|
||||
pwa: {
|
||||
workboxOptions: {
|
||||
skipWaiting: true,
|
||||
clientsClaim: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -147,7 +147,7 @@ selected_module:
|
||||
Memory: nomem
|
||||
# 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
|
||||
# 不想开通意图识别,就设置成:nointent
|
||||
# 意图识别可使用intent_llm,如果你的LLM是DifyLLM或CozeLLM,建议使用这个。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
|
||||
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
|
||||
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
|
||||
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028
|
||||
Intent: function_call
|
||||
@@ -163,7 +163,7 @@ Intent:
|
||||
type: intent_llm
|
||||
# 配备意图识别独立的思考模型
|
||||
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
|
||||
# 如果你的selected_module.LLM选择了DifyLLM或CozeLLM,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
|
||||
# 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
|
||||
llm: ChatGLMLLM
|
||||
function_call:
|
||||
# 不需要动type
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user