Compare commits

..
114 Commits
Author SHA1 Message Date
欣南科技andGitHub 31aeb63f89 Merge pull request #2305 from xinnan-tech/his_download
add:首页智能体添加搜索记录
2025-10-01 02:53:09 +08:00
hrz 0175a7a1d4 add:首页智能体添加搜索记录 2025-10-01 02:52:32 +08:00
欣南科技andGitHub 1417ee017e Merge pull request #2304 from xinnan-tech/his_download
update:下载智能体的聊天记录
2025-10-01 01:33:01 +08:00
hrz 5b4d79902f update:下载智能体的聊天记录 2025-10-01 01:24:13 +08:00
hrzandGitHub ed07793c47 Merge pull request #2288 from xinnan-tech/sm2_test
登录、注册页面添加非对称加密
2025-09-30 11:12:50 +08:00
hrz 2ed2e9c391 update:优化移动端按钮 2025-09-30 11:10:48 +08:00
3030332422 2b9fed2609 update:新增SM2解密工具类,优化登录注册密码找回功能 2025-09-30 09:07:06 +08:00
3030332422 848ce65d13 update:web端重置密码页面加密 2025-09-29 17:57:53 +08:00
hrzandGitHub f0512ce1cc Merge pull request #2302 from xinnan-tech/CSS_update
调整底框位置
2025-09-29 17:57:24 +08:00
LiJinHui 313fad1f81 样式调整 2025-09-29 17:56:19 +08:00
3030332422 ff26a84988 fix:完善重置密码页面多语言支持 2025-09-29 16:51:23 +08:00
3030332422 f7ee54598e fix:修复移动端创建智能体时显示问题 2025-09-29 16:09:28 +08:00
LiJinHui 7a62462c1b 调整底框位置 2025-09-29 15:18:46 +08:00
3030332422 a4433ebe68 fix:修复移动端注册页面的一些bug 2025-09-29 14:21:41 +08:00
3030332422 48feea55fe update:移动端注册页面加密 2025-09-29 10:29:28 +08:00
3030332422 6e5f708f5a update:移动端登录页面加密 2025-09-28 22:40:05 +08:00
hrzandGitHub f2ed645f9c Merge pull request #2290 from xinnan-tech/jia_mi_miyao
模型秘钥加密
2025-09-28 18:32:53 +08:00
hrz d47664e670 update:优化输入框提示 2025-09-28 18:25:29 +08:00
LiJinHui 8f3229ddda 调整 input onfocus 判断 2025-09-28 17:37:06 +08:00
LiJinHui 4f2b7524aa 改用精准匹配生成提示词 2025-09-28 17:20:09 +08:00
LiJinHui 4e99e176e9 删除多余样式 2025-09-28 17:05:17 +08:00
LiJinHui cfc7047967 页面优化 2025-09-28 16:52:28 +08:00
LiJinHui 776b5fb3b4 修复加密的bug 2025-09-28 15:27:46 +08:00
3030332422 f16d42d2b5 Merge branch 'sm2_test' of https://github.com/xinnan-tech/xiaozhi-esp32-server into sm2_test 2025-09-28 14:18:44 +08:00
3030332422 344d183b6f fix:修复web端手机号码注册时遇到的问题 2025-09-28 14:06:13 +08:00
LiJinHui feb668c96c 还原Python的端口 2025-09-28 10:28:01 +08:00
LiJinHui f345568675 修复秘钥污染的bug 2025-09-28 09:32:54 +08:00
hrz 11207f793d update:登录类型按钮居中显示 2025-09-26 23:56:40 +08:00
hrz ce09eb48c4 update:共钥信息从旧接口获取,去除getServerPublicKey方法 2025-09-26 23:39:27 +08:00
hrzandGitHub 6925732f72 Merge pull request #2285 from myifeng/prompt-template-args
提示词模板支持更多参数
2025-09-26 19:07:41 +08:00
hrzandGitHub dd9d08c8c3 Merge pull request #2291 from xinnan-tech/performance
update:更新测试工具以测试讯飞流式ASR、TTS首词响应时间
2025-09-26 19:06:28 +08:00
hrzandGitHub 1443c883f1 Merge pull request #2292 from xinnan-tech/mobile-i18n
updata:移动端添加语言切换功能
2025-09-26 19:05:21 +08:00
hrzandGitHub 30676e2170 Merge branch 'main' into sm2_test 2025-09-26 19:03:23 +08:00
rainv123 e41e6d4284 updata:删除无用功能 2025-09-26 17:10:47 +08:00
rainv123 1a7b1d24fa updata:移动端添加语言切换功能 2025-09-26 17:03:17 +08:00
rainv123 99443d1c9f updata:移动端添加语言切换功能 2025-09-26 14:54:56 +08:00
rainv123 a84b8d818e updata:移动端添加语言切换功能 2025-09-26 14:53:16 +08:00
LiJinHui ef4abf08f0 格式调整 2025-09-26 14:44:20 +08:00
rainv123 dfcac51312 updata:移动端添加语言切换功能 2025-09-26 14:33:06 +08:00
3030332422 c963a2de18 fix:修复验证图形验证码 2025-09-26 12:58:28 +08:00
3030332422 9ec8ecf7f4 update:SM2非对称加密的优化 2025-09-26 11:10:42 +08:00
FAN-yeB 5d77c96d76 update:更新测试工具以测试讯飞流式ASR、TTS首词响应时间 2025-09-26 11:05:00 +08:00
LiJinHui a8932f3743 秘钥加密 2025-09-26 10:04:43 +08:00
欣南科技andGitHub d022dd385a Merge pull request #2286 from xinnan-tech/Data_display_update
Data display update
2025-09-25 16:12:42 +08:00
hrz b2dfbb7973 update:优化判断 2025-09-25 16:12:02 +08:00
LiJinHui 7fee922796 更新设备数量显示(实时) 2025-09-25 15:56:51 +08:00
3030332422 9b6acf1ffc fix:修复SM2非对称加密的各种问题 2025-09-25 15:50:32 +08:00
hrzandGitHub a0e7cb56dc Merge pull request #2284 from xinnan-tech/mqtt_doc
update:更新mqtt网关集成文档
2025-09-25 14:54:51 +08:00
hrz f8447033b9 update:优化 2025-09-25 14:54:05 +08:00
hrz bb07015feb update:抽离公共教程到最前面 2025-09-25 14:51:34 +08:00
Chingfeng Li 111d945a9a 提示词模板支持更多参数 2025-09-25 14:35:01 +08:00
FAN-yeB d1587cc645 update:更新mqtt网关集成文档 2025-09-25 14:31:23 +08:00
欣南科技andGitHub e50e21c240 Merge pull request #2279 from xinnan-tech/update_doc
update:更新文档
2025-09-25 12:26:14 +08:00
hrz 9bbcf7f31a update:更新文档 2025-09-25 12:25:36 +08:00
hrzandGitHub 4f29e375c3 Merge pull request #2277 from xinnan-tech/py_fix_tts
fix: Markdown调整
2025-09-25 11:05:12 +08:00
Sakura-RanChen e15c784bdb fix: 文档链接 2025-09-25 10:57:12 +08:00
Sakura-RanChen e77a3b79cd fix: Markdown调整 2025-09-25 10:17:56 +08:00
3030332422 7ac5113fef update:非对称加密注册功能 2025-09-25 09:15:41 +08:00
hrzandGitHub ae58537979 Merge pull request #2275 from xinnan-tech/py_indexTTS
update: 更新文档
2025-09-24 17:01:02 +08:00
Sakura-RanChen ebf10828f0 update: 更新文档 2025-09-24 16:35:26 +08:00
3030332422 33bad7ed40 update:前端SM2非对称加密登录功能 2025-09-23 20:22:12 +08:00
hrzandGitHub af9ed0cd86 Merge pull request #2266 from xinnan-tech/py_mqtt
update:增加单模块mqtt-gateway支持
2025-09-23 11:51:41 +08:00
FAN-yeB 14a0829190 更新ota下发mqtt协议逻辑 2025-09-23 11:37:28 +08:00
FAN-yeB ca400d8ea7 Revert "更改下发配置"
This reverts commit 8891a96731.
2025-09-23 11:29:05 +08:00
hrzandGitHub d28750dae0 Merge pull request #2264 from xinnan-tech/py_xunfei_tts
update:添加讯飞语音识别、大模型、语音合成供应器
2025-09-23 11:06:00 +08:00
hrzandGitHub ef81a17311 Merge pull request #2273 from xinnan-tech/i18n-fix
fix:修改用户管理界面用户的密码重置、禁用、删除的逻辑
2025-09-23 11:05:17 +08:00
FAN-yeB 8891a96731 更改下发配置 2025-09-23 10:45:25 +08:00
rainv123 49e69ecad4 fix:修改用户管理界面用户的密码重置、禁用、删除的逻辑 2025-09-23 10:16:17 +08:00
rainv123 1db2483557 Merge branch 'main' of https://github.com/xinnan-tech/xiaozhi-esp32-server 2025-09-23 10:08:55 +08:00
rainv123 7067050006 Revert "fix:修改用户管理界面的用户密码重置、禁用、删除的逻辑"
This reverts commit 9f303d840a.
2025-09-23 10:03:43 +08:00
FAN-yeBandGitHub 55b455216c Merge pull request #2267 from xinnan-tech/i18n-fix
i18n-fix
2025-09-23 09:58:36 +08:00
3030332422 ffba2f4fa3 update:添加SM2国密加密的登录接口 2025-09-23 09:58:27 +08:00
rainv123 9f303d840a fix:修改用户管理界面的用户密码重置、禁用、删除的逻辑 2025-09-23 09:49:13 +08:00
FAN-yeB 86b4e61c1c 添加讯飞星火大模型的配置示例 2025-09-23 09:06:27 +08:00
欣南科技andGitHub 3232b36e15 Merge pull request #2271 from xinnan-tech/fix-docker-web
update:优化编译
2025-09-22 21:35:04 +08:00
hrz 83329cf9db update:优化编译 2025-09-22 21:34:09 +08:00
rainv123 4608c81ea6 updata:补充国际化键名 2025-09-22 15:04:49 +08:00
rainv123 41679f4e1d updata:调整文本顺序 2025-09-22 14:55:22 +08:00
FAN-yeB 559699ec19 update:增加单模块mqtt-gateway支持 2025-09-22 14:18:03 +08:00
hrzandGitHub f785219a43 Merge pull request #2265 from xinnan-tech/LJH
update:修改排序
2025-09-22 11:24:41 +08:00
FAN-yeB bf2b6d7554 还原错误更改 2025-09-22 10:10:46 +08:00
FAN-yeB 9a6348c71a 更新 202509220958.sql 2025-09-22 10:01:30 +08:00
FAN-yeB 646f4cbfe9 更新讯飞星火认知大模型配置的说明文档 2025-09-22 10:00:29 +08:00
LiJinHui d4c6b6840e 增加排序更新逻辑 2025-09-22 09:52:21 +08:00
LiJinHui 758aa1c78c 修改排序的逻辑 2025-09-22 09:41:18 +08:00
FAN-yeB ec2b52ccac 优化讯飞asr参数 2025-09-22 09:34:05 +08:00
FAN-yeB 38e1eb0c18 更新 202509191732.sql 2025-09-22 09:25:43 +08:00
hrzandGitHub 00e8478f61 Merge pull request #2251 from xinnan-tech/LJH
update:新增调整角色模版的功能,添加新功能的国际化
2025-09-21 18:10:05 +08:00
hrzandGitHub 0c556b1424 Merge branch 'main' into LJH 2025-09-21 18:09:48 +08:00
hrz 9987555a72 Bump to v0.8.3 2025-09-21 18:07:48 +08:00
hrz ebb4ba5595 update:调整功能顺序 2025-09-21 18:04:56 +08:00
LiJinHui 7e338b28df 删除多余定义 2025-09-21 16:44:18 +08:00
LiJinHui c4b3e30c5d 删除多的定义 2025-09-21 16:41:53 +08:00
LiJinHui 4d283c3504 去掉调试代码 2025-09-21 01:51:35 +08:00
LiJinHui ffc1541343 删除多余代码 2025-09-21 00:25:23 +08:00
LiJinHui 588d75763c 删除多余样式 2025-09-20 23:43:58 +08:00
LiJinHui 9e218281da 调整代码 2025-09-20 17:30:00 +08:00
欣南科技andGitHub 6fa58c7948 Merge pull request #2257 from xinnan-tech/update_doc
update:优化图片
2025-09-20 08:13:51 +08:00
hrz 7fe40579b9 update:优化图片 2025-09-20 08:13:04 +08:00
欣南科技andGitHub 7e2782ce22 Merge pull request #2256 from xinnan-tech/update_doc
update:架构图增加mqtt
2025-09-20 08:04:30 +08:00
hrz 1fbe8dab77 update:架构图增加mqtt 2025-09-20 08:04:00 +08:00
LiJinHui 0aec742bd3 格式统一 2025-09-19 18:33:54 +08:00
LiJinHui a6f5d868dd 格式调整 2025-09-19 18:18:18 +08:00
FAN-yeB 966d8a8a84 update:xunfei asr 2025-09-19 17:54:52 +08:00
LiJinHui 611d1a65e7 Update index.js
权限调整
2025-09-19 17:53:43 +08:00
LiJinHui b23e31bb4b Delete DefaultRoleDialog.vue
删除多余文件
2025-09-19 17:41:11 +08:00
Sakura-RanChen bec1c7b88f update: 增加讯飞双流式TTS 2025-09-19 17:29:09 +08:00
LiJinHui 5d407b9227 调整模版排序的漏洞 2025-09-19 17:18:24 +08:00
LiJinHui 608abb7dc3 调整页面 2025-09-19 15:41:28 +08:00
LiJinHui 5fcc7592c9 优化代码,调整权限设置( 非管理员打开提示拒绝访问并自动跳转到首页),修复bug; 2025-09-19 15:34:43 +08:00
LiJinHui 09d5d37867 完善导航栏的语言切换 2025-09-18 16:54:00 +08:00
LiJinHui 6a8c4ccbf8 添加国际化的功能,调整页面 2025-09-18 16:43:52 +08:00
LiJinHui a199e47701 页面布局调整 2025-09-18 09:35:03 +08:00
LiJinHui c331b8322c 新增调整角色模版的功能 2025-09-17 15:38:23 +08:00
99 changed files with 8169 additions and 1469 deletions
+9 -3
View File
@@ -18,9 +18,15 @@ FROM bellsoft/liberica-runtime-container:jre-21-glibc
# 安装Nginx和字体库 # 安装Nginx和字体库
RUN apk update && \ RUN apk update && \
apk add --no-cache nginx bash fontconfig ttf-dejavu && \ apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ \
apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ msttcorefonts-installer || true && \ nginx \
rm -rf /var/cache/apk/* bash \
fontconfig \
ttf-dejavu \
msttcorefonts-installer \
&& ACCEPT_EULA=Y apk add --no-cache msttcorefonts-installer \
&& fc-cache -f -v \
&& rm -rf /var/cache/apk/*
# 更新字体缓存 # 更新字体缓存
RUN (printf 'YES\n' | update-ms-fonts || true) && fc-cache -f -v RUN (printf 'YES\n' | update-ms-fonts || true) && fc-cache -f -v
+11 -16
View File
@@ -63,6 +63,13 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology
</picture> </picture>
</a> </a>
</td> </td>
<td>
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
<picture>
<img alt="MQTT指令下发" src="docs/images/demo4.png" />
</picture>
</a>
</td>
<td> <td>
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank"> <a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
<picture> <picture>
@@ -84,13 +91,6 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology
</picture> </picture>
</a> </a>
</td> </td>
<td>
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank">
<picture>
<img alt="成本最低配置" src="docs/images/demo4.png" />
</picture>
</a>
</td>
</tr> </tr>
<tr> <tr>
<td> <td>
@@ -241,7 +241,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
![请参考-全模块安装架构图](docs/images/deploy2.png) ![请参考-全模块安装架构图](docs/images/deploy2.png)
| 功能模块 | 描述 | | 功能模块 | 描述 |
|:---:|:---| |:---:|:---|
| 核心架构 | 基于MQTT+UDP网关、WebSocket、HTTP服务器,提供完整的控制台管理和认证系统 | | 核心架构 | 基于[MQTT+UDP网关](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md)、WebSocket、HTTP服务器,提供完整的控制台管理和认证系统 |
| 语音交互 | 支持流式ASR(语音识别)、流式TTS(语音合成)、VAD(语音活动检测),支持多语言识别和语音处理 | | 语音交互 | 支持流式ASR(语音识别)、流式TTS(语音合成)、VAD(语音活动检测),支持多语言识别和语音处理 |
| 声纹识别 | 支持多用户声纹注册、管理和识别,与ASR并行处理,实时识别说话人身份并传递给LLM进行个性化回应 | | 声纹识别 | 支持多用户声纹注册、管理和识别,与ASR并行处理,实时识别说话人身份并传递给LLM进行个性化回应 |
| 智能对话 | 支持多种LLM(大语言模型),实现智能对话 | | 智能对话 | 支持多种LLM(大语言模型),实现智能对话 |
@@ -249,7 +249,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
| 意图识别 | 支持LLM意图识别、Function Call函数调用,提供插件化意图处理机制 | | 意图识别 | 支持LLM意图识别、Function Call函数调用,提供插件化意图处理机制 |
| 记忆系统 | 支持本地短期记忆、mem0ai接口记忆,具备记忆总结功能 | | 记忆系统 | 支持本地短期记忆、mem0ai接口记忆,具备记忆总结功能 |
| 工具调用 | 支持客户端IOT协议、客户MCP协议、服务端MCP协议、MCP接入点协议、自定义工具函数 | | 工具调用 | 支持客户端IOT协议、客户MCP协议、服务端MCP协议、MCP接入点协议、自定义工具函数 |
| 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理 | | 指令下发 | 依托MQTT协议,支持从智控台将MCP指令下发到ESP32设备 |
| 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理;界面支持中文简体、中文繁体、英文显示 |
| 测试工具 | 提供性能测试工具、视觉模型测试工具和音频交互测试工具 | | 测试工具 | 提供性能测试工具、视觉模型测试工具和音频交互测试工具 |
| 部署支持 | 支持Docker部署和本地部署,提供完整的配置文件管理 | | 部署支持 | 支持Docker部署和本地部署,提供完整的配置文件管理 |
| 插件系统 | 支持功能插件扩展、自定义插件开发和插件热加载 | | 插件系统 | 支持功能插件扩展、自定义插件开发和插件热加载 |
@@ -263,13 +264,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
--- ---
## 产品生态 👬 ## 产品生态 👬
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的优秀项目 小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的[优秀项目](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE)
| 项目名称 | 项目地址 | 项目描述 |
|:---------------------|:--------|:--------|
| 小智安卓客户端 | [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/>它包括前后端的服务,旨在为用户提供一个完整的后端服务解决方案。 |
--- ---
+13 -12
View File
@@ -62,6 +62,13 @@ Want to see the usage effects? Click the videos below 🎥
</picture> </picture>
</a> </a>
</td> </td>
<td>
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
<picture>
<img alt="MQTT command issuance" src="docs/images/demo4.png" />
</picture>
</a>
</td>
<td> <td>
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank"> <a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
<picture> <picture>
@@ -83,13 +90,6 @@ Want to see the usage effects? Click the videos below 🎥
</picture> </picture>
</a> </a>
</td> </td>
<td>
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank">
<picture>
<img alt="Lowest cost configuration" src="docs/images/demo4.png" />
</picture>
</a>
</td>
</tr> </tr>
<tr> <tr>
<td> <td>
@@ -237,15 +237,16 @@ This project provides the following testing tools to help you verify the system
![请参考-全模块安装架构图](docs/images/deploy2.png) ![请参考-全模块安装架构图](docs/images/deploy2.png)
| Feature Module | Description | | Feature Module | Description |
|:---:|:---| |:---:|:---|
| Core Architecture | Based on WebSocket and HTTP servers, provides complete console management and authentication system | | Core Architecture | Based on [MQTT+UDP gateway](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md), WebSocket and HTTP servers, provides complete console management and authentication system |
| Voice Interaction | Supports streaming ASR(speech recognition), streaming TTS(speech synthesis), VAD(voice activity detection), supports multi-language recognition and voice processing | | Voice Interaction | Supports streaming ASR(speech recognition), streaming TTS(speech synthesis), VAD(voice activity detection), supports multi-language recognition and voice processing |
| Voiceprint Recognition | Supports multi-user voiceprint registration, management, and recognition, processes in parallel with ASR, real-time speaker identity recognition and passes to LLM for personalized responses | | Voiceprint Recognition | Supports multi-user voiceprint registration, management, and recognition, processes in parallel with ASR, real-time speaker identity recognition and passes to LLM for personalized responses |
| Intelligent Dialogue | Supports multiple LLM(large language models), implements intelligent dialogue | | Intelligent Dialogue | Supports multiple LLM(large language models), implements intelligent dialogue |
| Visual Perception | Supports multiple VLLM(vision large models), implements multimodal interaction | | Visual Perception | Supports multiple VLLM(vision large models), implements multimodal interaction |
| Intent Recognition | Supports LLM intent recognition, Function Call function calling, provides plugin-based intent processing mechanism | | Intent Recognition | Supports LLM intent recognition, Function Call function calling, provides plugin-based intent processing mechanism |
| Memory System | Supports local short-term memory, mem0ai interface memory, with memory summarization functionality | | Memory System | Supports local short-term memory, mem0ai interface memory, with memory summarization functionality |
| Command Delivery | Supports MCP command delivery to ESP32 devices via MQTT protocol from Smart Console |
| Tool Calling | Supports client IOT protocol, client MCP protocol, server MCP protocol, MCP endpoint protocol, custom tool functions | | Tool Calling | Supports client IOT protocol, client MCP protocol, server MCP protocol, MCP endpoint protocol, custom tool functions |
| Management Backend | Provides Web management interface, supports user management, system configuration, and device management | | Management Backend | Provides Web management interface, supports user management, system configuration and device management; Supports Simplified Chinese, Traditional Chinese and English display |
| Testing Tools | Provides performance testing tools, vision model testing tools, and audio interaction testing tools | | Testing Tools | Provides performance testing tools, vision model testing tools, and audio interaction testing tools |
| Deployment Support | Supports Docker deployment and local deployment, provides complete configuration file management | | Deployment Support | Supports Docker deployment and local deployment, provides complete configuration file management |
| Plugin System | Supports functional plugin extensions, custom plugin development, and plugin hot-loading | | Plugin System | Supports functional plugin extensions, custom plugin development, and plugin hot-loading |
@@ -259,7 +260,7 @@ If you are a software developer, here is an [Open Letter to Developers](docs/con
--- ---
## Product Ecosystem 👬 ## Product Ecosystem 👬
Xiaozhi is an ecosystem. When using this product, you can also check out other excellent projects in this ecosystem Xiaozhi is an ecosystem. When using this product, you can also check out other [excellent projects](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) in this ecosystem
| Project Name | Project Address | Project Description | | Project Name | Project Address | Project Description |
|:---------------------|:--------|:--------| |:---------------------|:--------|:--------|
@@ -280,7 +281,7 @@ Xiaozhi is an ecosystem. When using this product, you can also check out other e
| FastGPT interface calls | FastGPT | - | | FastGPT interface calls | FastGPT | - |
| Coze interface calls | Coze | - | | Coze interface calls | Coze | - |
In fact, any LLM that supports OpenAI interface calls can be integrated and used. In fact, any LLM that supports OpenAI interface calls can be integrated and used, including Xinference and HomeAssistant interfaces.
--- ---
@@ -298,7 +299,7 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
| Usage Method | Supported Platforms | Free Platforms | | Usage Method | Supported Platforms | Free Platforms |
|:---:|:---:|:---:| |:---:|:---:|:---:|
| Interface calls | EdgeTTS, Volcano Engine Doubao TTS, Tencent Cloud, Alibaba Cloud TTS, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS, Lingxi Streaming TTS | Lingxi Streaming TTS, EdgeTTS, CosyVoiceSiliconflow(partial) | | Interface calls | EdgeTTS, Volcano Engine Doubao TTS, Tencent Cloud, Alibaba Cloud TTS, AliYun Stream TTS, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS, Lingxi Streaming TTS, MinimaxTTS | Lingxi Streaming TTS, EdgeTTS, CosyVoiceSiliconflow(partial) |
| Local services | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS | | Local services | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS |
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 260 KiB

+37 -14
View File
@@ -1,9 +1,12 @@
# IndexStreamTTS 使用指南 # IndexStreamTTS 使用指南
## 环境准备 ## 环境准备
### 1. 克隆项目 (这里使用的为VLLM版本) ### 1. 克隆项目 注意这里使用的为VLLM1.0的Releases版本)
```bash ```bash
git clone https://github.com/Ksuriuri/index-tts-vllm.git https://github.com/Ksuriuri/index-tts-vllm/releases/tag/IndexTTS-vLLM-1.0
```
进入解压后的目录
```bash
cd index-tts-vllm cd index-tts-vllm
``` ```
@@ -13,7 +16,7 @@ conda create -n index-tts-vllm python=3.12
conda activate index-tts-vllm conda activate index-tts-vllm
``` ```
### 3. 安装PyTorch ### 3. 安装PyTorch 需要版本为2.8.0(最新版)
#### 查看显卡最高支持的版本和实际安装的版本 #### 查看显卡最高支持的版本和实际安装的版本
```bash ```bash
nvidia-smi nvidia-smi
@@ -27,12 +30,11 @@ CUDA Version: 12.8
```bash ```bash
Cuda compilation tools, release 12.8, V12.8.89 Cuda compilation tools, release 12.8, V12.8.89
``` ```
#### 那么对应的安装命令 (请注意不要横跨大版本!!!) #### 那么对应的安装命令pytorch默认给的是12.8的驱动版本)
```bash ```bash
pip install torch==2.7.0 torchvision==0.22.0 torchaudio==2.7.0 --index-url https://download.pytorch.org/whl/cu128 pip install torch torchvision
``` ```
优先建议安装 pytorch 2.7.0(对应 vllm 0.9.0),具体安装指令请参考:[pytorch 官网](https://pytorch.org/get-started/locally/]\) 需要 pytorch 版本 2.8.0(对应 vllm 0.10.2),具体安装指令请参考:[pytorch 官网](https://pytorch.org/get-started/locally/)
若显卡不支持,请安装 pytorch 2.5.1(对应 vllm 0.7.3),并将 requirements.txt 中 vllm==0.9.0 修改为 vllm==0.7.3
### 4. 安装依赖 ### 4. 安装依赖
```bash ```bash
@@ -54,7 +56,7 @@ git lfs install
``` ```
创建模型目录,并拉取模型 创建模型目录,并拉取模型
```bash ```bash
mkkdir model_dir mkdir model_dir
cd model_dir cd model_dir
git clone https://www.modelscope.cn/IndexTeam/IndexTTS-1.5.git git clone https://www.modelscope.cn/IndexTeam/IndexTTS-1.5.git
``` ```
@@ -71,6 +73,9 @@ bash convert_hf_format.sh model_dir/IndexTTS-1.5
### 6. 更改接口适配一下项目 ### 6. 更改接口适配一下项目
接口返回数据与项目不适配需要调整一下,使其直接返回音频数据 接口返回数据与项目不适配需要调整一下,使其直接返回音频数据
```bash
vi api_server.py
```
```bash ```bash
@app.post("/tts", responses={ @app.post("/tts", responses={
200: {"content": {"application/octet-stream": {}}}, 200: {"content": {"application/octet-stream": {}}},
@@ -104,7 +109,7 @@ async def tts_api(request: Request):
vi start_api.sh vi start_api.sh
``` ```
### 将下面内容粘贴进去并按:输入wq保存 ### 将下面内容粘贴进去并按:输入wq保存
#### 脚本中的/home/system/indexTTS/index-tts-vllm/model_dir/IndexTTS-1.5 请自行修改为实际路径 #### 脚本中的/home/system/index-tts-vllm/model_dir/IndexTTS-1.5 请自行修改为实际路径
```bash ```bash
# 激活conda环境 # 激活conda环境
conda activate index-tts-vllm conda activate index-tts-vllm
@@ -129,17 +134,35 @@ else
echo "已终止进程 $PID_VLLM" echo "已终止进程 $PID_VLLM"
fi fi
# 查找占用VLLM::EngineCore进程
GPU_PIDS=$(ps aux | grep -E "VLLM|EngineCore" | grep -v grep | awk '{print $2}')
# 检查是否找到进程号
if [ -z "$GPU_PIDS" ]; then
echo "没有找到VLLM相关进程"
else
echo "找到VLLM相关进程,进程号为: $GPU_PIDS"
# 先尝试普通kill,等待2秒
kill $GPU_PIDS
sleep 2
# 检查进程是否还在
if ps -p $GPU_PIDS > /dev/null; then
echo "进程仍在运行,强制终止..."
kill -9 $GPU_PIDS
fi
echo "已终止进程 $GPU_PIDS"
fi
# 创建tmp目录(如果不存在) # 创建tmp目录(如果不存在)
mkdir -p tmp mkdir -p tmp
# 后台运行api_server.py,日志重定向到tmp/server.log # 后台运行api_server.py,日志重定向到tmp/server.log
export VLLM_USE_V1=0 nohup python api_server.py --model_dir /home/system/index-tts-vllm/model_dir/IndexTTS-1.5 --port 11996 > tmp/server.log 2>&1 &
nohup python api_server.py --model_dir /home/system/indexTTS/index-tts-vllm/model_dir/IndexTTS-1.5 --port 11996 > tmp/server.log 2>&1 &
echo "api_server.py 已在后台运行,日志请查看 tmp/server.log" echo "api_server.py 已在后台运行,日志请查看 tmp/server.log"
``` ```
给脚本执行权限并运行脚本 给脚本执行权限并运行脚本
```bash ```bash
chmod +x tmp chmod +x start_api.sh
./start_api.sh ./start_api.sh
``` ```
日志会在tmp/server.log中输出,可以通过以下命令查看日志情况 日志会在tmp/server.log中输出,可以通过以下命令查看日志情况
@@ -148,7 +171,7 @@ tail -f tmp/server.log
``` ```
## 音色配置 ## 音色配置
index-tts-vllm支持通过配置文件注册自定义音色,支持单音色和混合音色配置。 index-tts-vllm支持通过配置文件注册自定义音色,支持单音色和混合音色配置。
在项目根目录下的assets/speaker.json文件中配置自定义音色 在项目根目录下的assets/speaker.json文件中配置自定义音色
### 配置格式说明 ### 配置格式说明
```bash ```bash
{ {
@@ -161,5 +184,5 @@ index-tts-vllm支持通过配置文件注册自定义音色,支持单音色和
] ]
} }
``` ```
### 注意 ### 注意 (需重启服务进行注册)
添加后需在智控台中添加相应的说话人(单模块则更换相应的voice) 添加后需在智控台中添加相应的说话人(单模块则更换相应的voice)
+50 -7
View File
@@ -1,11 +1,12 @@
# MQTT 网关部署教程 # MQTT 网关部署教程
`xiaozhi-esp32-server`项目,可结合虾哥开源的[xiaozhi-mqtt-gateway](https://github.com/78/xiaozhi-mqtt-gateway) 项目进行简单改造,即可实现小智硬件MQTT+UDP连接。 `xiaozhi-esp32-server`项目,可结合虾哥开源的[xiaozhi-mqtt-gateway](https://github.com/78/xiaozhi-mqtt-gateway) 项目进行简单改造,即可实现小智硬件MQTT+UDP连接。
本教程分为三部分,你可以根据你是全模块部署还是单模块部署,选择对应的部分接入MQTT网关:
- 第一部分:部署MQTT网关
- 第二部分:全模块运行实现小智硬件MQTT+UDP连接
- 第三部分:单模块运行xiaozhi-server实现小智硬件MQTT+UDP连接
## 准备工作 ## 准备阶段
查看你智控台首页底部的版本号,确认你的智控台版本是否是`0.7.7`及以上版本。如果不是,需要升级智控台。
准备好你的`xiaozhi-server``mqtt-websocket`连接地址。在你原来的`websocket地址`基础上,添加`?from=mqtt_gateway`字符,就可以得到`mqtt-websocket`连接地址 准备好你的`xiaozhi-server``mqtt-websocket`连接地址。在你原来的`websocket地址`基础上,添加`?from=mqtt_gateway`字符,就可以得到`mqtt-websocket`连接地址
1、如果你是源码部署,你的`mqtt-websocket`地址是: 1、如果你是源码部署,你的`mqtt-websocket`地址是:
@@ -18,7 +19,16 @@ ws://127.0.0.1:8000/xiaozhi/v1?from=mqtt_gateway
ws://你宿主机局域网IP:8000/xiaozhi/v1?from=mqtt_gateway ws://你宿主机局域网IP:8000/xiaozhi/v1?from=mqtt_gateway
``` ```
## 第一步 部署MQTT网关 ## 重要提示
如果你是服务器部署,需要确保服务器`1883``8884``8007`端口都对外开放。`8884`选择的协议类型是`UDP`,其他是`TCP`
如果你是服务器部署,需要确保服务器`1883``8884``8007`端口都对外开放。`8884`选择的协议类型是`UDP`,其他是`TCP`
如果你是服务器部署,需要确保服务器`1883``8884``8007`端口都对外开放。`8884`选择的协议类型是`UDP`,其他是`TCP`
## 第一部分:部署MQTT网关
1. 克隆[改造后的xiaozhi-mqtt-gateway项目](https://github.com/xinnan-tech/xiaozhi-mqtt-gateway.git) 1. 克隆[改造后的xiaozhi-mqtt-gateway项目](https://github.com/xinnan-tech/xiaozhi-mqtt-gateway.git)
```bash ```bash
@@ -95,7 +105,9 @@ pm2 logs xz-mqtt
pm2 restart xz-mqtt pm2 restart xz-mqtt
``` ```
## 第二步 配置智控台 ## 第二部分:全模块运行实现小智硬件MQTT+UDP连接
查看你智控台首页底部的版本号,确认你的智控台版本是否是`0.7.7`及以上版本。如果不是,需要升级智控台。
1. 在智控台顶部,点击`参数管理`,搜索`server.mqtt_gateway`,点击编辑,填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`MQTT_PORT`。类似这样 1. 在智控台顶部,点击`参数管理`,搜索`server.mqtt_gateway`,点击编辑,填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`MQTT_PORT`。类似这样
``` ```
@@ -127,7 +139,38 @@ curl 'http://localhost:8002/xiaozhi/ota/' \
{"server_time":{"timestamp":1757567894012,"timeZone":"Asia/Shanghai","timezone_offset":480},"activation":{"code":"460609","message":"http://xiaozhi.server.com\n460609","challenge":"11:22:33:44:55:66"},"firmware":{"version":"1.0.1","url":"http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL"},"websocket":{"url":"ws://192.168.4.23:8000/xiaozhi/v1/"},"mqtt":{"endpoint":"192.168.0.7:1883","client_id":"GID_default@@@11_22_33_44_55_66@@@7b94d69a-9808-4c59-9c9b-704333b38aff","username":"eyJpcCI6IjA6MDowOjA6MDowOjA6MSJ9","password":"Y8XP9xcUhVIN9OmbCHT9ETBiYNE3l3Z07Wk46wV9PE8=","publish_topic":"device-server","subscribe_topic":"devices/p2p/11_22_33_44_55_66"}} {"server_time":{"timestamp":1757567894012,"timeZone":"Asia/Shanghai","timezone_offset":480},"activation":{"code":"460609","message":"http://xiaozhi.server.com\n460609","challenge":"11:22:33:44:55:66"},"firmware":{"version":"1.0.1","url":"http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL"},"websocket":{"url":"ws://192.168.4.23:8000/xiaozhi/v1/"},"mqtt":{"endpoint":"192.168.0.7:1883","client_id":"GID_default@@@11_22_33_44_55_66@@@7b94d69a-9808-4c59-9c9b-704333b38aff","username":"eyJpcCI6IjA6MDowOjA6MDowOjA6MSJ9","password":"Y8XP9xcUhVIN9OmbCHT9ETBiYNE3l3Z07Wk46wV9PE8=","publish_topic":"device-server","subscribe_topic":"devices/p2p/11_22_33_44_55_66"}}
``` ```
## 第三步 重启小智设备 由于MQTT信息是需要靠OTA地址下发的,因此只有你保证能正常连接服务器的OTA地址,重启唤醒即可。
唤醒后留意mqtt-gateway的日志,确认是否有连接成功的日志。
```
pm2 logs xz-mqtt
```
## 第三部分:全模块运行实现小智硬件MQTT+UDP连接
打开你的`data/.config.yaml`文件,在`server`下找到`mqtt_gateway`填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`MQTT_PORT`。类似这样
```
192.168.0.7:1883
```
在`server`下找到`mqtt_signature_key`填入你在`.env`文件中设置的`MQTT_SIGNATURE_KEY`。
在`server`下找到`udp_gateway`填入你在`.env`文件中设置的`PUBLIC_IP`+`:`+`UDP_PORT`。类似这样
```
192.168.0.7:8884
```
上面的配置完成后,你可以使用curl命令,验证你的ota地址是否会下发mqtt配置,把下面的`http://localhost:8002/xiaozhi/ota/`改成你的ota地址
```
curl 'http://localhost:8002/xiaozhi/ota/' \
-H 'Device-Id: 11:22:33:44:55:66' \
--data-raw $'{\n "application": {\n "version": "1.0.1",\n "elf_sha256": "1"\n },\n "board": {\n "mac": "11:22:33:44:55:66"\n }\n}'
```
如果返回的内容包含`mqtt`相关的配置,说明配置成功。类似这样
```
{"server_time":{"timestamp":1758781561083,"timeZone":"GMT+08:00","timezone_offset":480},"activation":{"code":"527111","message":"http://xiaozhi.server.com\n527111","challenge":"11:22:33:44:55:66"},"firmware":{"version":"1.0.1","url":"http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL"},"websocket":{"url":"ws://192.168.1.15:8000/xiaozhi/v1/"},"mqtt":{"endpoint":"192.168.1.15:1883","client_id":"GID_default@@@11_22_33_44_55_66@@@11_22_33_44_55_66","username":"eyJpcCI6IjE5Mi4xNjguMS4xNSJ9","password":"fjAYs49zTJecWqJ3jBt+kqxVn/x7vkXRAc85ak/va7Y=","publish_topic":"device-server","subscribe_topic":"devices/p2p/11_22_33_44_55_66"}}
```
由于MQTT信息是需要靠OTA地址下发的,因此只有你保证能正常连接服务器的OTA地址,重启唤醒即可。 由于MQTT信息是需要靠OTA地址下发的,因此只有你保证能正常连接服务器的OTA地址,重启唤醒即可。
唤醒后留意mqtt-gateway的日志,确认是否有连接成功的日志。 唤醒后留意mqtt-gateway的日志,确认是否有连接成功的日志。
+16 -6
View File
@@ -1,5 +1,9 @@
# PaddleSpeechTTS集成xiaozhi服务 # PaddleSpeechTTS集成xiaozhi服务
## 重点说明
- 优点:本地离线部署、速度快
- 缺点:截止2025年9月25日,默认的模型是中文模型,不支持英文转语音。如果含英文会发不出声音,如需同时支持中英文需要自己训练。
## 一、基础环境要求 ## 一、基础环境要求
操作系统:Windows / Linux / WSL 2 操作系统:Windows / Linux / WSL 2
@@ -16,15 +20,21 @@ git clone https://github.com/PaddlePaddle/PaddleSpeech.git
``` ```
### 2.建立虚拟环境 ### 2.建立虚拟环境
```bash ```bash
#请根据Paddle官方支持的python版本建立环境 ```https://www.paddlepaddle.org.cn/install```
conda create -n paddle_env python=3.10 -y conda create -n paddle_env python=3.10 -y
conda activate paddle_env conda activate paddle_env
``` ```
### 3.进入paddlespeech目录 ### 3.安装paddle
因CPU架构、GPU架构不同,请根据Paddle官方支持的python版本建立环境
```
https://www.paddlepaddle.org.cn/install
```
### 4.进入paddlespeech目录
```bash ```bash
cd PaddleSpeech cd PaddleSpeech
``` ```
### 4.安装paddlespeech ### 5.安装paddlespeech
```bash ```bash
pip install pytest-runner -i https://pypi.tuna.tsinghua.edu.cn/simple pip install pytest-runner -i https://pypi.tuna.tsinghua.edu.cn/simple
@@ -32,17 +42,17 @@ pip install pytest-runner -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install paddlepaddle -i https://mirror.baidu.com/pypi/simple pip install paddlepaddle -i https://mirror.baidu.com/pypi/simple
pip install paddlespeech -i https://pypi.tuna.tsinghua.edu.cn/simple pip install paddlespeech -i https://pypi.tuna.tsinghua.edu.cn/simple
``` ```
### 5.使用命令自动下载语音模型 ### 6.使用命令自动下载语音模型
```bash ```bash
paddlespeech tts --input "你好,这是一次测试" paddlespeech tts --input "你好,这是一次测试"
``` ```
此步骤会自动下载模型缓存至本地 .paddlespeech/models 目录 此步骤会自动下载模型缓存至本地 .paddlespeech/models 目录
### 6.修改tts_online_application.yaml配置 ### 7.修改tts_online_application.yaml配置
参考目录 ```"PaddleSpeech\demos\streaming_tts_server\conf\tts_online_application.yaml"``` 参考目录 ```"PaddleSpeech\demos\streaming_tts_server\conf\tts_online_application.yaml"```
选择```tts_online_application.yaml```文件用编辑器打开,设置```protocol```为```websocket``` 选择```tts_online_application.yaml```文件用编辑器打开,设置```protocol```为```websocket```
### 7.启动服务 ### 8.启动服务
```yaml ```yaml
paddlespeech_server start --config_file ./demos/streaming_tts_server/conf/tts_online_application.yaml paddlespeech_server start --config_file ./demos/streaming_tts_server/conf/tts_online_application.yaml
#官方默认启动命令: #官方默认启动命令:
+6
View File
@@ -199,6 +199,12 @@
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId> <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version> <version>${knife4j.version}</version>
</dependency> </dependency>
<!-- BouncyCastle SM2加密 -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78</version>
</dependency>
<!-- springdoc --> <!-- springdoc -->
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>
@@ -86,6 +86,16 @@ public interface Constant {
*/ */
String SERVER_SECRET = "server.secret"; String SERVER_SECRET = "server.secret";
/**
* SM2公钥
*/
String SM2_PUBLIC_KEY = "server.public_key";
/**
* SM2私钥
*/
String SM2_PRIVATE_KEY = "server.private_key";
/** /**
* websocket地址 * websocket地址
*/ */
@@ -247,7 +257,7 @@ public interface Constant {
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.8.2"; public static final String VERSION = "0.8.3";
/** /**
* 无效固件URL * 无效固件URL
@@ -161,7 +161,18 @@ public interface ErrorCode {
int MQTT_SECRET_LENGTH_INSECURE = 10123; // mqtt密钥长度不安全 int MQTT_SECRET_LENGTH_INSECURE = 10123; // mqtt密钥长度不安全
int MQTT_SECRET_CHARACTER_INSECURE = 10124; // mqtt密钥必须同时包含大小写字母 int MQTT_SECRET_CHARACTER_INSECURE = 10124; // mqtt密钥必须同时包含大小写字母
int MQTT_SECRET_WEAK_PASSWORD = 10125; // mqtt密钥包含弱密码 int MQTT_SECRET_WEAK_PASSWORD = 10125; // mqtt密钥包含弱密码
// 字典相关错误码
int DICT_LABEL_DUPLICATE = 10128; // 字典标签重复 int DICT_LABEL_DUPLICATE = 10128; // 字典标签重复
int SM2_KEY_NOT_CONFIGURED = 10129; // SM2密钥未配置
int SM2_DECRYPT_ERROR = 10130; // SM2解密失败
int MODEL_TYPE_PROVIDE_CODE_NOT_NULL = 10131; // modelType和provideCode不能为空
// 聊天记录相关错误码
int CHAT_HISTORY_NO_PERMISSION = 10132; // 没有权限查看该智能体的聊天记录
int CHAT_HISTORY_SESSION_ID_NOT_NULL = 10133; // 会话ID不能为空
int CHAT_HISTORY_AGENT_ID_NOT_NULL = 10134; // 智能体ID不能为空
int CHAT_HISTORY_DOWNLOAD_FAILED = 10135; // 聊天记录下载失败
int DOWNLOAD_LINK_EXPIRED = 10136; // 下载链接已过期或无效
int DOWNLOAD_LINK_INVALID = 10137; // 下载链接无效
int CHAT_ROLE_USER = 10138; // 用户角色
int CHAT_ROLE_AGENT = 10139; // 智能体角色
} }
@@ -139,4 +139,11 @@ public class RedisKeys {
return "sms:Validate:Code:" + phone + ":today_count"; return "sms:Validate:Code:" + phone + ":today_count";
} }
/**
* 聊天记录UUID映射的Key
*/
public static String getChatHistoryKey(String uuid) {
return "agent:chat:history:" + uuid;
}
} }
@@ -0,0 +1,130 @@
package xiaozhi.common.utils;
import org.bouncycastle.asn1.gm.GMNamedCurves;
import org.bouncycastle.asn1.x9.X9ECParameters;
import org.bouncycastle.crypto.engines.SM2Engine;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
import org.bouncycastle.crypto.params.ParametersWithRandom;
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey;
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.math.ec.ECPoint;
import org.bouncycastle.util.encoders.Hex;
import java.math.BigInteger;
import java.security.*;
import java.security.spec.ECGenParameterSpec;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* SM2加密工具类(采用十六进制格式,与chancheng-archive-service项目保持一致)
*/
public class SM2Utils {
/**
* 公钥常量
*/
public static final String KEY_PUBLIC_KEY = "publicKey";
/**
* 私钥返回值常量
*/
public static final String KEY_PRIVATE_KEY = "privateKey";
static {
Security.addProvider(new BouncyCastleProvider());
}
/**
* SM2加密算法
*
* @param publicKey 十六进制公钥
* @param data 明文数据
* @return 十六进制密文
*/
public static String encrypt(String publicKey, String data) {
try {
// 获取一条SM2曲线参数
X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
// 构造ECC算法参数,曲线方程、椭圆曲线G点、大整数N
ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
//提取公钥点
ECPoint pukPoint = sm2ECParameters.getCurve().decodePoint(Hex.decode(publicKey));
// 公钥前面的02或者03表示是压缩公钥,04表示未压缩公钥, 04的时候,可以去掉前面的04
ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(pukPoint, domainParameters);
SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2);
// 设置sm2为加密模式
sm2Engine.init(true, new ParametersWithRandom(publicKeyParameters, new SecureRandom()));
byte[] in = data.getBytes(StandardCharsets.UTF_8);
byte[] arrayOfBytes = sm2Engine.processBlock(in, 0, in.length);
return Hex.toHexString(arrayOfBytes);
} catch (Exception e) {
throw new RuntimeException("SM2加密失败", e);
}
}
/**
* SM2解密算法
*
* @param privateKey 十六进制私钥
* @param cipherData 十六进制密文数据
* @return 明文
*/
public static String decrypt(String privateKey, String cipherData) {
try {
// 使用BC库加解密时密文以04开头,传入的密文前面没有04则补上
if (!cipherData.startsWith("04")) {
cipherData = "04" + cipherData;
}
byte[] cipherDataByte = Hex.decode(cipherData);
BigInteger privateKeyD = new BigInteger(privateKey, 16);
//获取一条SM2曲线参数
X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
//构造domain参数
ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
ECPrivateKeyParameters privateKeyParameters = new ECPrivateKeyParameters(privateKeyD, domainParameters);
SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2);
// 设置sm2为解密模式
sm2Engine.init(false, privateKeyParameters);
byte[] arrayOfBytes = sm2Engine.processBlock(cipherDataByte, 0, cipherDataByte.length);
return new String(arrayOfBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("SM2解密失败", e);
}
}
/**
* 生成密钥对
*/
public static Map<String, String> createKey() {
try {
ECGenParameterSpec sm2Spec = new ECGenParameterSpec("sm2p256v1");
// 获取一个椭圆曲线类型的密钥对生成器
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", new BouncyCastleProvider());
// 使用SM2参数初始化生成器
kpg.initialize(sm2Spec);
// 获取密钥对
KeyPair keyPair = kpg.generateKeyPair();
PublicKey publicKey = keyPair.getPublic();
BCECPublicKey p = (BCECPublicKey) publicKey;
PrivateKey privateKey = keyPair.getPrivate();
BCECPrivateKey s = (BCECPrivateKey) privateKey;
Map<String, String> result = new HashMap<>();
result.put(KEY_PUBLIC_KEY, Hex.toHexString(p.getQ().getEncoded(false)));
result.put(KEY_PRIVATE_KEY, Hex.toHexString(s.getD().toByteArray()));
return result;
} catch (Exception e) {
throw new RuntimeException("生成SM2密钥对失败", e);
}
}
}
@@ -0,0 +1,164 @@
package xiaozhi.common.utils;
import cn.hutool.json.JSONObject;
import org.apache.commons.lang3.StringUtils;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.HashMap;
import java.util.Set;
/**
* 敏感数据处理工具类
*/
public class SensitiveDataUtils {
// 敏感字段列表
private static final Set<String> SENSITIVE_FIELDS = new HashSet<>(Arrays.asList(
"api_key", "personal_access_token", "access_token", "token",
"secret", "access_key_secret", "secret_key"));
/**
* 检查字段是否为敏感字段
*/
public static boolean isSensitiveField(String fieldName) {
return StringUtils.isNotBlank(fieldName) && SENSITIVE_FIELDS.contains(fieldName.toLowerCase());
}
/**
* 隐藏字符串中间部分
*/
public static String maskMiddle(String value) {
if (StringUtils.isBlank(value)) {
return value;
}
int length = value.length();
if (length <= 8) {
// 短字符串保留前2后2
return value.substring(0, 2) + "****" + value.substring(length - 2);
} else {
// 长字符串保留前4后4
int maskLength = length - 8;
StringBuilder maskBuilder = new StringBuilder();
for (int i = 0; i < maskLength; i++) {
maskBuilder.append('*');
}
return value.substring(0, 4) + maskBuilder.toString() + value.substring(length - 4);
}
}
/**
* 判断字符串是否是被掩码处理过的值
*/
public static boolean isMaskedValue(String value) {
if (StringUtils.isBlank(value)) {
return false;
}
// 掩码值至少包含4个连续的*
return value.contains("****");
}
/**
* 处理JSONObject中的敏感字段
*/
public static JSONObject maskSensitiveFields(JSONObject jsonObject) {
if (jsonObject == null) {
return null;
}
JSONObject result = new JSONObject();
for (String key : jsonObject.keySet()) {
Object value = jsonObject.get(key);
if (SENSITIVE_FIELDS.contains(key.toLowerCase()) && value instanceof String) {
result.put(key, maskMiddle((String) value));
} else if (value instanceof JSONObject) {
result.put(key, maskSensitiveFields((JSONObject) value));
} else {
result.put(key, value);
}
}
return result;
}
/**
* 比较两个JSONObject的敏感字段是否相同
* 特别针对api_key等敏感字段进行单独比较
*/
public static boolean isSensitiveDataEqual(JSONObject original, JSONObject updated) {
if (original == null && updated == null) {
return true;
}
if (original == null || updated == null) {
return false;
}
// 提取并比较特定敏感字段
return compareSpecificSensitiveFields(original, updated, "api_key") &&
compareSpecificSensitiveFields(original, updated, "personal_access_token") &&
compareSpecificSensitiveFields(original, updated, "access_token") &&
compareSpecificSensitiveFields(original, updated, "token") &&
compareSpecificSensitiveFields(original, updated, "secret") &&
compareSpecificSensitiveFields(original, updated, "access_key_secret") &&
compareSpecificSensitiveFields(original, updated, "secret_key");
}
/**
* 比较两个JSON对象中特定敏感字段是否相同
* 遍历整个JSON对象树,查找并比较指定敏感字段
*/
private static boolean compareSpecificSensitiveFields(JSONObject original, JSONObject updated, String fieldName) {
// 提取原始对象中的指定敏感字段
Map<String, String> originalFields = new HashMap<>();
extractSpecificSensitiveField(original, originalFields, fieldName, "");
// 提取更新对象中的指定敏感字段
Map<String, String> updatedFields = new HashMap<>();
extractSpecificSensitiveField(updated, updatedFields, fieldName, "");
// 如果字段数量不同,说明有增删
if (originalFields.size() != updatedFields.size()) {
return false;
}
// 比较每个字段的值
for (Map.Entry<String, String> entry : originalFields.entrySet()) {
String key = entry.getKey();
String originalValue = entry.getValue();
String updatedValue = updatedFields.get(key);
if (updatedValue == null || !updatedValue.equals(originalValue)) {
return false;
}
}
return true;
}
/**
* 递归提取JSON对象中指定名称的敏感字段
*/
private static void extractSpecificSensitiveField(JSONObject jsonObject, Map<String, String> fieldsMap,
String targetFieldName, String parentPath) {
if (jsonObject == null) {
return;
}
for (String key : jsonObject.keySet()) {
String fullPath = parentPath.isEmpty() ? key : parentPath + "." + key;
Object value = jsonObject.get(key);
if (value instanceof JSONObject) {
// 递归处理嵌套JSON对象
extractSpecificSensitiveField((JSONObject) value, fieldsMap, targetFieldName, fullPath);
} else if (value instanceof String && key.equalsIgnoreCase(targetFieldName)) {
// 找到目标敏感字段,保存其路径和值
fieldsMap.put(fullPath, (String) value);
}
}
}
}
@@ -0,0 +1,61 @@
package xiaozhi.common.utils;
import org.apache.commons.lang3.StringUtils;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.modules.security.service.CaptchaService;
import xiaozhi.modules.sys.service.SysParamsService;
/**
* SM2解密和验证码验证工具类
* 封装了重复的SM2解密、验证码提取和验证逻辑
*/
public class Sm2DecryptUtil {
/**
* 验证码长度
*/
private static final int CAPTCHA_LENGTH = 5;
/**
* 解密SM2加密内容,提取验证码并验证
* @param encryptedPassword SM2加密的密码字符串
* @param captchaId 验证码ID
* @param captchaService 验证码服务
* @param sysParamsService 系统参数服务
* @return 解密后的实际密码
*/
public static String decryptAndValidateCaptcha(String encryptedPassword, String captchaId,
CaptchaService captchaService, SysParamsService sysParamsService) {
// 获取SM2私钥
String privateKeyStr = sysParamsService.getValue(Constant.SM2_PRIVATE_KEY, true);
if (StringUtils.isBlank(privateKeyStr)) {
throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED);
}
// 使用SM2私钥解密密码
String decryptedContent;
try {
decryptedContent = SM2Utils.decrypt(privateKeyStr, encryptedPassword);
} catch (Exception e) {
throw new RenException(ErrorCode.SM2_DECRYPT_ERROR);
}
// 分离验证码和密码:前5位是验证码,后面是密码
if (decryptedContent.length() > CAPTCHA_LENGTH) {
String embeddedCaptcha = decryptedContent.substring(0, CAPTCHA_LENGTH);
String actualPassword = decryptedContent.substring(CAPTCHA_LENGTH);
// 验证嵌入的验证码是否正确
boolean embeddedCaptchaValid = captchaService.validate(captchaId, embeddedCaptcha, true);
if (!embeddedCaptchaValid) {
throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR);
}
return actualPassword;
} else {
throw new RenException(ErrorCode.SM2_DECRYPT_ERROR);
}
}
}
@@ -1,5 +1,21 @@
package xiaozhi.modules.agent.controller; package xiaozhi.modules.agent.controller;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -7,18 +23,37 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.DateUtils;
import xiaozhi.common.utils.MessageUtils;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO; import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService; import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
import xiaozhi.modules.security.user.SecurityUser;
@Tag(name = "智能体聊天历史管理") @Tag(name = "智能体聊天历史管理")
@RequiredArgsConstructor @RequiredArgsConstructor
@RestController @RestController
@RequestMapping("/agent/chat-history") @RequestMapping("/agent/chat-history")
public class AgentChatHistoryController { public class AgentChatHistoryController {
private static final Logger logger = LoggerFactory.getLogger(AgentChatHistoryController.class);
private final AgentChatHistoryBizService agentChatHistoryBizService; private final AgentChatHistoryBizService agentChatHistoryBizService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentService agentService;
private final RedisUtils redisUtils;
/** /**
* 小智服务聊天上报请求 * 小智服务聊天上报请求
@@ -33,4 +68,182 @@ public class AgentChatHistoryController {
Boolean result = agentChatHistoryBizService.report(request); Boolean result = agentChatHistoryBizService.report(request);
return new Result<Boolean>().ok(result); return new Result<Boolean>().ok(result);
} }
/**
* 获取聊天记录下载链接
*
* @param agentId 智能体ID
* @param sessionId 会话ID
* @return UUID作为下载标识
*/
@Operation(summary = "获取聊天记录下载链接")
@RequiresPermissions("sys:role:normal")
@PostMapping("/getDownloadUrl/{agentId}/{sessionId}")
public Result<String> getDownloadUrl(@PathVariable("agentId") String agentId,
@PathVariable("sessionId") String sessionId) {
// 获取当前用户
UserDetail user = SecurityUser.getUser();
// 检查权限
if (!agentService.checkAgentPermission(agentId, user.getId())) {
throw new RenException(ErrorCode.CHAT_HISTORY_NO_PERMISSION);
}
// 生成UUID
String uuid = UUID.randomUUID().toString();
// 存储agentId和sessionId到Redis,格式为agentId:sessionId
redisUtils.set(RedisKeys.getChatHistoryKey(uuid), agentId + ":" + sessionId);
return new Result<String>().ok(uuid);
}
/**
* 下载本会话聊天记录
*
* @param uuid 下载标识
* @param response HTTP响应
*/
@Operation(summary = "下载本会话聊天记录")
@GetMapping("/download/{uuid}/current")
public void downloadCurrentSession(@PathVariable("uuid") String uuid,
HttpServletResponse response) {
// 从Redis获取agentId和sessionId
String agentSessionInfo = (String) redisUtils.get(RedisKeys.getChatHistoryKey(uuid));
if (StringUtils.isBlank(agentSessionInfo)) {
throw new RenException(ErrorCode.DOWNLOAD_LINK_EXPIRED);
}
try {
// 解析agentId和sessionId
String[] parts = agentSessionInfo.split(":");
if (parts.length != 2) {
throw new RenException(ErrorCode.DOWNLOAD_LINK_INVALID);
}
String agentId = parts[0];
String sessionId = parts[1];
// 执行下载
downloadChatHistory(agentId, List.of(sessionId), response);
} finally {
// 下载完成后删除UUID,防止盗刷
redisUtils.delete(RedisKeys.getChatHistoryKey(uuid));
}
}
/**
* 下载本会话及前20条会话聊天记录
*
* @param uuid 下载标识
* @param response HTTP响应
*/
@Operation(summary = "下载本会话及前20条会话聊天记录")
@GetMapping("/download/{uuid}/previous")
public void downloadCurrentSessionWithPrevious(@PathVariable("uuid") String uuid,
HttpServletResponse response) {
// 从Redis获取agentId和sessionId
String agentSessionInfo = (String) redisUtils.get(RedisKeys.getChatHistoryKey(uuid));
if (StringUtils.isBlank(agentSessionInfo)) {
throw new RenException("下载链接已过期或无效");
}
try {
// 解析agentId和sessionId
String[] parts = agentSessionInfo.split(":");
if (parts.length != 2) {
throw new RenException("下载链接无效");
}
String agentId = parts[0];
String sessionId = parts[1];
// 获取所有会话列表
Map<String, Object> params = Map.of(
"agentId", agentId,
Constant.PAGE, 1,
Constant.LIMIT, 1000 // 获取足够多的会话
);
PageData<AgentChatSessionDTO> sessionPage = agentChatHistoryService.getSessionListByAgentId(params);
List<AgentChatSessionDTO> allSessions = sessionPage.getList();
// 查找当前会话在列表中的位置
int currentIndex = -1;
for (int i = 0; i < allSessions.size(); i++) {
if (allSessions.get(i).getSessionId().equals(sessionId)) {
currentIndex = i;
break;
}
}
// 如果找到了当前会话,收集当前会话及前20条会话ID
List<String> sessionIdsToDownload = new ArrayList<>();
if (currentIndex != -1) {
// 从当前会话开始,向后(数组后面)取最多20条会话(包括当前会话)
int endIndex = Math.min(allSessions.size() - 1, currentIndex + 20); // 确保不越界
for (int i = currentIndex; i <= endIndex; i++) {
sessionIdsToDownload.add(allSessions.get(i).getSessionId());
}
}
// 如果没有找到当前会话,至少下载当前会话
if (sessionIdsToDownload.isEmpty()) {
sessionIdsToDownload.add(sessionId);
}
downloadChatHistory(agentId, sessionIdsToDownload, response);
} finally {
// 下载完成后删除UUID,防止盗刷
redisUtils.delete(RedisKeys.getChatHistoryKey(uuid));
}
}
/**
* 下载指定会话的聊天记录
*
* @param agentId 智能体ID
* @param sessionIds 会话ID列表
* @param response HTTP响应
*/
private void downloadChatHistory(String agentId, List<String> sessionIds, HttpServletResponse response) {
try {
// 设置响应头
response.setContentType("text/plain;charset=UTF-8");
String fileName = URLEncoder.encode("history.txt", StandardCharsets.UTF_8.toString());
response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
// 获取聊天记录并写入响应流
try (OutputStream out = response.getOutputStream()) {
// 为每个会话生成聊天记录
for (String sessionId : sessionIds) {
// 获取该会话的所有聊天记录
List<AgentChatHistoryDTO> chatHistoryList = agentChatHistoryService
.getChatHistoryBySessionId(agentId, sessionId);
// 从聊天记录中获取第一条消息的创建时间作为会话时间
if (!chatHistoryList.isEmpty()) {
Date firstMessageTime = chatHistoryList.get(0).getCreatedAt();
String sessionTimeStr = DateUtils.format(firstMessageTime, DateUtils.DATE_TIME_PATTERN);
out.write((sessionTimeStr + "\n").getBytes(StandardCharsets.UTF_8));
}
for (AgentChatHistoryDTO message : chatHistoryList) {
String role = message.getChatType() == 1 ? MessageUtils.getMessage(ErrorCode.CHAT_ROLE_USER)
: MessageUtils.getMessage(ErrorCode.CHAT_ROLE_AGENT);
String direction = message.getChatType() == 1 ? ">>" : "<<";
Date messageTime = message.getCreatedAt();
String messageTimeStr = DateUtils.format(messageTime, DateUtils.DATE_TIME_PATTERN);
String content = message.getContent();
String line = "[" + role + "]-[" + messageTimeStr + "]" + direction + ":" + content + "\n";
out.write(line.getBytes(StandardCharsets.UTF_8));
}
// 会话之间添加空行分隔
if (sessionIds.indexOf(sessionId) < sessionIds.size() - 1) {
out.write("\n".getBytes(StandardCharsets.UTF_8));
}
}
out.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
}
} }
@@ -0,0 +1,158 @@
package xiaozhi.modules.agent.controller;
import java.util.List;
import java.util.Map;
import org.apache.shiro.authz.annotation.RequiresPermissions;
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 com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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 jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.ResultUtils;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentTemplateVO;
@Tag(name = "智能体模板管理")
@AllArgsConstructor
@RestController
@RequestMapping("/agent/template")
public class AgentTemplateController {
private final AgentTemplateService agentTemplateService;
@GetMapping("/page")
@Operation(summary = "获取模板分页列表")
@RequiresPermissions("sys:role:superAdmin")
@Parameters({
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true),
@Parameter(name = "agentName", description = "模板名称,模糊查询")
})
public Result<PageData<AgentTemplateVO>> getAgentTemplatesPage(
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
// 创建分页对象
int page = Integer.parseInt(params.getOrDefault(Constant.PAGE, "1").toString());
int limit = Integer.parseInt(params.getOrDefault(Constant.LIMIT, "10").toString());
Page<AgentTemplateEntity> pageInfo = new Page<>(page, limit);
// 创建查询条件
QueryWrapper<AgentTemplateEntity> wrapper = new QueryWrapper<>();
String agentName = (String) params.get("agentName");
if (agentName != null && !agentName.isEmpty()) {
wrapper.like("agent_name", agentName);
}
wrapper.orderByAsc("sort");
// 执行分页查询
IPage<AgentTemplateEntity> pageResult = agentTemplateService.page(pageInfo, wrapper);
// 使用ConvertUtils转换为VO列表
List<AgentTemplateVO> voList = ConvertUtils.sourceToTarget(pageResult.getRecords(), AgentTemplateVO.class);
// 修复:使用构造函数创建PageData对象,而不是无参构造+setter
PageData<AgentTemplateVO> pageData = new PageData<>(voList, pageResult.getTotal());
return new Result<PageData<AgentTemplateVO>>().ok(pageData);
}
@GetMapping("/{id}")
@Operation(summary = "获取模板详情")
@RequiresPermissions("sys:role:superAdmin")
public Result<AgentTemplateVO> getAgentTemplateById(@PathVariable("id") String id) {
AgentTemplateEntity template = agentTemplateService.getById(id);
if (template == null) {
return ResultUtils.error("模板不存在");
}
// 使用ConvertUtils转换为VO
AgentTemplateVO vo = ConvertUtils.sourceToTarget(template, AgentTemplateVO.class);
return ResultUtils.success(vo);
}
@PostMapping
@Operation(summary = "创建模板")
@RequiresPermissions("sys:role:superAdmin")
public Result<AgentTemplateEntity> createAgentTemplate(@Valid @RequestBody AgentTemplateEntity template) {
// 设置排序值为下一个可用的序号
template.setSort(agentTemplateService.getNextAvailableSort());
boolean saved = agentTemplateService.save(template);
if (saved) {
return ResultUtils.success(template);
} else {
return ResultUtils.error("创建模板失败");
}
}
@PutMapping
@Operation(summary = "更新模板")
@RequiresPermissions("sys:role:superAdmin")
public Result<AgentTemplateEntity> updateAgentTemplate(@Valid @RequestBody AgentTemplateEntity template) {
boolean updated = agentTemplateService.updateById(template);
if (updated) {
return ResultUtils.success(template);
} else {
return ResultUtils.error("更新模板失败");
}
}
@DeleteMapping("/{id}")
@Operation(summary = "删除模板")
@RequiresPermissions("sys:role:superAdmin")
public Result<String> deleteAgentTemplate(@PathVariable("id") String id) {
// 先查询要删除的模板信息,获取其排序值
AgentTemplateEntity template = agentTemplateService.getById(id);
if (template == null) {
return ResultUtils.error("模板不存在");
}
Integer deletedSort = template.getSort();
// 执行删除操作
boolean deleted = agentTemplateService.removeById(id);
if (deleted) {
// 删除成功后,重新排序剩余模板
agentTemplateService.reorderTemplatesAfterDelete(deletedSort);
return ResultUtils.success("删除模板成功");
} else {
return ResultUtils.error("删除模板失败");
}
}
// 添加新的批量删除方法,使用不同的URL
@PostMapping("/batch-remove")
@Operation(summary = "批量删除模板")
@RequiresPermissions("sys:role:superAdmin")
public Result<String> batchRemoveAgentTemplates(@RequestBody List<String> ids) {
boolean deleted = agentTemplateService.removeByIds(ids);
if (deleted) {
return ResultUtils.success("批量删除成功");
} else {
return ResultUtils.error("批量删除模板失败");
}
}
}
@@ -25,4 +25,18 @@ public interface AgentTemplateService extends IService<AgentTemplateEntity> {
* @param modelId 模型ID * @param modelId 模型ID
*/ */
void updateDefaultTemplateModelId(String modelType, String modelId); void updateDefaultTemplateModelId(String modelType, String modelId);
/**
* 删除模板后重新排序剩余模板
*
* @param deletedSort 被删除模板的排序值
*/
void reorderTemplatesAfterDelete(Integer deletedSort);
/**
* 获取下一个可用的排序序号(寻找最小的未使用序号)
*
* @return 下一个可用的排序序号
*/
Integer getNextAvailableSort();
} }
@@ -19,8 +19,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
@@ -183,6 +183,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
@Override @Override
public boolean checkAgentPermission(String agentId, Long userId) { public boolean checkAgentPermission(String agentId, Long userId) {
if (SecurityUser.getUser() == null || SecurityUser.getUser().getId() == null) {
return false;
}
// 获取智能体信息 // 获取智能体信息
AgentEntity agent = getAgentById(agentId); AgentEntity agent = getAgentById(agentId);
if (agent == null) { if (agent == null) {
@@ -10,6 +10,11 @@ import xiaozhi.modules.agent.dao.AgentTemplateDao;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.service.AgentTemplateService;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
/** /**
* @author chenerlei * @author chenerlei
* @description 针对表【ai_agent_template(智能体配置模板表)】的数据库操作Service实现 * @description 针对表【ai_agent_template(智能体配置模板表)】的数据库操作Service实现
@@ -69,4 +74,48 @@ public class AgentTemplateServiceImpl extends ServiceImpl<AgentTemplateDao, Agen
wrapper.ge("sort", 0); wrapper.ge("sort", 0);
update(wrapper); update(wrapper);
} }
@Override
public void reorderTemplatesAfterDelete(Integer deletedSort) {
if (deletedSort == null) {
return;
}
// 查询所有排序值大于被删除模板的记录
UpdateWrapper<AgentTemplateEntity> updateWrapper = new UpdateWrapper<>();
updateWrapper.gt("sort", deletedSort)
.setSql("sort = sort - 1");
// 执行批量更新,将这些记录的排序值减1
this.update(updateWrapper);
}
@Override
public Integer getNextAvailableSort() {
// 查询所有已存在的排序值并按升序排序
List<Integer> sortValues = baseMapper.selectList(new QueryWrapper<AgentTemplateEntity>())
.stream()
.map(AgentTemplateEntity::getSort)
.filter(Objects::nonNull)
.sorted()
.collect(Collectors.toList());
// 如果没有排序值,返回1
if (sortValues.isEmpty()) {
return 1;
}
// 寻找最小的未使用序号
int expectedSort = 1;
for (Integer sort : sortValues) {
if (sort > expectedSort) {
// 找到空缺的序号
return expectedSort;
}
expectedSort = sort + 1;
}
// 如果没有空缺,返回最大序号+1
return expectedSort;
}
} }
@@ -70,7 +70,7 @@ public class ConfigServiceImpl implements ConfigService {
// 查询默认智能体 // 查询默认智能体
AgentTemplateEntity agent = agentTemplateService.getDefaultTemplate(); AgentTemplateEntity agent = agentTemplateService.getDefaultTemplate();
if (agent == null) { if (agent == null) {
throw new RenException(ErrorCode.DEFAULT_AGENT_NOT_FOUND); throw new RenException("默认智能体未找到");
} }
// 构建模块配置 // 构建模块配置
@@ -113,7 +113,7 @@ public class ConfigServiceImpl implements ConfigService {
// 获取智能体信息 // 获取智能体信息
AgentEntity agent = agentService.getAgentById(device.getAgentId()); AgentEntity agent = agentService.getAgentById(device.getAgentId());
if (agent == null) { if (agent == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND); throw new RenException("智能体未找到");
} }
// 获取音色信息 // 获取音色信息
String voice = null; String voice = null;
@@ -350,7 +350,7 @@ public class ConfigServiceImpl implements ConfigService {
* @param result 结果Map * @param result 结果Map
*/ */
private void buildModuleConfig( private void buildModuleConfig(
String assistantName, String assistantName,
String prompt, String prompt,
String summaryMemory, String summaryMemory,
String voice, String voice,
@@ -366,17 +366,18 @@ public class ConfigServiceImpl implements ConfigService {
Map<String, Object> result, Map<String, Object> result,
boolean isCache) { boolean isCache) {
Map<String, String> selectedModule = new HashMap<>(); Map<String, String> selectedModule = new HashMap<>();
String[] modelTypes = { "VAD", "ASR", "TTS", "Memory", "Intent", "LLM", "VLLM" }; String[] modelTypes = { "VAD", "ASR", "TTS", "Memory", "Intent", "LLM", "VLLM" };
String[] modelIds = { vadModelId, asrModelId, ttsModelId, memModelId, intentModelId, llmModelId, vllmModelId }; String[] modelIds = { vadModelId, asrModelId, ttsModelId, memModelId, intentModelId, llmModelId, vllmModelId };
String intentLLMModelId = null; String intentLLMModelId = null;
String memLocalShortLLMModelId = null; String memLocalShortLLMModelId = null;
for (int i = 0; i < modelIds.length; i++) { for (int i = 0; i < modelIds.length; i++) {
if (modelIds[i] == null) { if (modelIds[i] == null) {
continue; continue;
} }
ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache); // 关键:第三个参数传false,确保获取原始密钥
ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache, false);
if (model == null) { if (model == null) {
continue; continue;
} }
@@ -424,14 +425,16 @@ public class ConfigServiceImpl implements ConfigService {
if ("LLM".equals(modelTypes[i])) { if ("LLM".equals(modelTypes[i])) {
if (StringUtils.isNotBlank(intentLLMModelId)) { if (StringUtils.isNotBlank(intentLLMModelId)) {
if (!typeConfig.containsKey(intentLLMModelId)) { if (!typeConfig.containsKey(intentLLMModelId)) {
ModelConfigEntity intentLLM = modelConfigService.getModelById(intentLLMModelId, isCache); // 修改这里:添加isMaskSensitive=false参数
ModelConfigEntity intentLLM = modelConfigService.getModelById(intentLLMModelId, isCache, false);
typeConfig.put(intentLLM.getId(), intentLLM.getConfigJson()); typeConfig.put(intentLLM.getId(), intentLLM.getConfigJson());
} }
} }
if (StringUtils.isNotBlank(memLocalShortLLMModelId)) { if (StringUtils.isNotBlank(memLocalShortLLMModelId)) {
if (!typeConfig.containsKey(memLocalShortLLMModelId)) { if (!typeConfig.containsKey(memLocalShortLLMModelId)) {
// 修改这里:添加isMaskSensitive=false参数
ModelConfigEntity memLocalShortLLM = modelConfigService ModelConfigEntity memLocalShortLLM = modelConfigService
.getModelById(memLocalShortLLMModelId, isCache); .getModelById(memLocalShortLLMModelId, isCache, false);
typeConfig.put(memLocalShortLLM.getId(), memLocalShortLLM.getConfigJson()); typeConfig.put(memLocalShortLLM.getId(), memLocalShortLLM.getConfigJson());
} }
} }
@@ -134,6 +134,10 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
// 清理redis缓存 // 清理redis缓存
redisUtils.delete(cacheDeviceKey); redisUtils.delete(cacheDeviceKey);
redisUtils.delete(deviceKey); redisUtils.delete(deviceKey);
// 添加:清除智能体设备数量缓存
redisUtils.delete(RedisKeys.getAgentDeviceCountById(agentId));
return true; return true;
} }
@@ -225,6 +229,16 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
@Override @Override
public void unbindDevice(Long userId, String deviceId) { public void unbindDevice(Long userId, String deviceId) {
// 先查询设备信息,获取agentId
DeviceEntity device = baseDao.selectById(deviceId);
if (device == null) {
return;
}
if (StringUtils.isNotBlank(device.getAgentId())) {
// 清除智能体设备数量缓存
redisUtils.delete(RedisKeys.getAgentDeviceCountById(device.getAgentId()));
}
UpdateWrapper<DeviceEntity> wrapper = new UpdateWrapper<>(); UpdateWrapper<DeviceEntity> wrapper = new UpdateWrapper<>();
wrapper.eq("user_id", userId); wrapper.eq("user_id", userId);
wrapper.eq("id", deviceId); wrapper.eq("id", deviceId);
@@ -459,6 +473,9 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
entity.setUpdater(userId); entity.setUpdater(userId);
entity.setAutoUpdate(1); entity.setAutoUpdate(1);
baseDao.insert(entity); baseDao.insert(entity);
// 添加:清除智能体设备数量缓存
redisUtils.delete(RedisKeys.getAgentDeviceCountById(dto.getAgentId()));
} }
/** /**
@@ -35,17 +35,27 @@ public interface ModelConfigService extends BaseService<ModelConfigEntity> {
/** /**
* 根据ID获取模型配置 * 根据ID获取模型配置
* *
* @param id 模型ID * @param id 模型ID
* @param isCache 是否缓存 * @param isCache 是否缓存
* @param isMaskSensitive 是否掩码敏感信息
* @return 模型配置实体
*/
ModelConfigEntity getModelById(String id, boolean isCache, boolean isMaskSensitive);
/**
* 根据ID获取模型配置(默认掩码敏感信息)
*
* @param id 模型ID
* @param isCache 是否缓存
* @return 模型配置实体 * @return 模型配置实体
*/ */
ModelConfigEntity getModelById(String id, boolean isCache); ModelConfigEntity getModelById(String id, boolean isCache);
/** /**
* 设置默认模型 * 设置默认模型
* *
* @param modelType 模型类型 * @param modelType 模型类型
* @param isDefault 是否默认 * @param isDefault 是否默认1:是,0:否)
*/ */
void setDefaultModel(String modelType, int isDefault); void setDefaultModel(String modelType, int isDefault);
} }
@@ -1,5 +1,6 @@
package xiaozhi.modules.model.service.impl; package xiaozhi.modules.model.service.impl;
import java.io.Serializable;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -11,8 +12,11 @@ import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.json.JSONObject;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
@@ -22,6 +26,7 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.SensitiveDataUtils;
import xiaozhi.modules.agent.dao.AgentDao; import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.model.dao.ModelConfigDao; import xiaozhi.modules.model.dao.ModelConfigDao;
@@ -63,7 +68,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
.eq("is_enabled", 1) .eq("is_enabled", 1)
.like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%") .like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%")
.select("id", "model_name", "config_json")); .select("id", "model_name", "config_json"));
// 处理获取到的内容
return entities.stream().map(item -> { return entities.stream().map(item -> {
LlmModelBasicInfoDTO dto = new LlmModelBasicInfoDTO(); LlmModelBasicInfoDTO dto = new LlmModelBasicInfoDTO();
dto.setId(item.getId()); dto.setId(item.getId());
@@ -76,90 +81,400 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
@Override @Override
public PageData<ModelConfigDTO> getPageList(String modelType, String modelName, String page, String limit) { public PageData<ModelConfigDTO> getPageList(String modelType, String modelName, String page, String limit) {
Map<String, Object> params = new HashMap<String, Object>(); Map<String, Object> params = new HashMap<>();
params.put(Constant.PAGE, page); params.put(Constant.PAGE, page);
params.put(Constant.LIMIT, limit); params.put(Constant.LIMIT, limit);
long curPage = Long.parseLong(page);
long pageSize = Long.parseLong(limit);
Page<ModelConfigEntity> pageInfo = new Page<>(curPage, pageSize);
// 添加排序规则:先按is_enabled降序,再按sort升序
pageInfo.addOrder(OrderItem.desc("is_enabled"));
pageInfo.addOrder(OrderItem.asc("sort"));
IPage<ModelConfigEntity> modelConfigEntityIPage = modelConfigDao.selectPage( IPage<ModelConfigEntity> modelConfigEntityIPage = modelConfigDao.selectPage(
getPage(params, "sort", true), pageInfo,
new QueryWrapper<ModelConfigEntity>() new QueryWrapper<ModelConfigEntity>()
.eq("model_type", modelType) .eq("model_type", modelType)
.like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%")); .like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%"));
return getPageData(modelConfigEntityIPage, ModelConfigDTO.class); return getPageData(modelConfigEntityIPage, ModelConfigDTO.class);
} }
@Override @Override
public ModelConfigDTO add(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) { public ModelConfigDTO edit(String modelType, String provideCode, String id, ModelConfigBodyDTO modelConfigBodyDTO) {
// 先验证有没有供应器 // 1. 参数验证
if (StringUtils.isBlank(modelType) || StringUtils.isBlank(provideCode)) { validateEditParameters(modelType, provideCode, id, modelConfigBodyDTO);
throw new RenException("modelType和provideCode不能为空");
}
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
if (CollectionUtil.isEmpty(providerList)) {
throw new RenException(ErrorCode.MODEL_PROVIDER_NOT_EXIST);
}
// 再保存供应器提供的模型 // 2. 验证模型提供者
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class); validateModelProvider(modelType, provideCode);
modelConfigEntity.setModelType(modelType);
modelConfigEntity.setIsDefault(0); // 3. 获取原始配置(不经过敏感数据处理)
modelConfigDao.insert(modelConfigEntity); ModelConfigEntity originalEntity = getOriginalConfigFromDb(id);
return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class);
// 4. 验证LLM配置
validateLlmConfiguration(modelConfigBodyDTO);
// 5. 准备更新实体并处理敏感数据
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, modelType, id);
// 6. 执行数据库更新
modelConfigDao.updateById(modelConfigEntity);
// 7. 清除缓存
clearModelCache(id);
// 8. 返回处理后的数据(包含敏感数据掩码)
return buildResponseDTO(modelConfigEntity);
} }
@Override @Override
public ModelConfigDTO edit(String modelType, String provideCode, String id, ModelConfigBodyDTO modelConfigBodyDTO) { public ModelConfigDTO add(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) {
// 先验证有没有供应器 validateAddParameters(modelType, provideCode, modelConfigBodyDTO);
if (StringUtils.isBlank(modelType) || StringUtils.isBlank(provideCode)) {
throw new RenException("modelType和provideCode不能为空");
}
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
if (CollectionUtil.isEmpty(providerList)) {
throw new RenException(ErrorCode.MODEL_PROVIDER_NOT_EXIST);
}
if (modelConfigBodyDTO.getConfigJson().containsKey("llm")) {
String llm = modelConfigBodyDTO.getConfigJson().get("llm").toString();
ModelConfigEntity modelConfigEntity = modelConfigDao.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getId, llm));
String selectModelType = (modelConfigEntity == null || modelConfigEntity.getModelType() == null) ? null
: modelConfigEntity.getModelType().toUpperCase();
if (modelConfigEntity == null || !"LLM".equals(selectModelType)) {
throw new RenException(ErrorCode.LLM_NOT_EXIST);
}
String type = modelConfigEntity.getConfigJson().get("type").toString();
// 如果查询大语言模型是openai或者ollama,意图识别选参数都可以
if (!"openai".equals(type) && !"ollama".equals(type)) {
throw new RenException(ErrorCode.INVALID_LLM_TYPE);
}
}
// 再更新供应器提供的模型 validateModelProvider(modelType, provideCode);
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
modelConfigEntity.setId(id); ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, modelType);
modelConfigEntity.setModelType(modelType);
modelConfigDao.updateById(modelConfigEntity); modelConfigDao.insert(modelConfigEntity);
// 清除缓存
redisUtils.delete(RedisKeys.getModelConfigById(modelConfigEntity.getId())); return buildResponseDTO(modelConfigEntity);
return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class);
} }
@Override @Override
public void delete(String id) { public void delete(String id) {
// 查看是否是默认 if (StringUtils.isBlank(id)) {
throw new RenException(ErrorCode.IDENTIFIER_NOT_NULL);
}
ModelConfigEntity modelConfig = modelConfigDao.selectById(id); ModelConfigEntity modelConfig = modelConfigDao.selectById(id);
if (modelConfig != null && modelConfig.getIsDefault() == 1) { if (modelConfig != null && modelConfig.getIsDefault() == 1) {
throw new RenException(ErrorCode.DEFAULT_MODEL_DELETE_ERROR); throw new RenException(ErrorCode.DEFAULT_MODEL_DELETE_ERROR);
} }
// 验证是否有引用
checkAgentReference(id); checkAgentReference(id);
checkIntentConfigReference(id); checkIntentConfigReference(id);
modelConfigDao.deleteById(id); modelConfigDao.deleteById(id);
clearModelCache(id);
}
@Override
public String getModelNameById(String id) {
if (StringUtils.isBlank(id)) {
return null;
}
String cacheKey = RedisKeys.getModelNameById(id);
String cachedName = (String) redisUtils.get(cacheKey);
if (StringUtils.isNotBlank(cachedName)) {
return cachedName;
}
ModelConfigEntity entity = modelConfigDao.selectById(id);
if (entity != null) {
String modelName = entity.getModelName();
if (StringUtils.isNotBlank(modelName)) {
redisUtils.set(cacheKey, modelName);
}
return modelName;
}
return null;
}
@Override
public ModelConfigEntity selectById(Serializable id) {
ModelConfigEntity entity = super.selectById(id);
if (entity != null && entity.getConfigJson() != null) {
entity.setConfigJson(maskSensitiveFields(entity.getConfigJson()));
}
return entity;
}
@Override
protected <D> PageData<D> getPageData(IPage<?> page, Class<D> target) {
List<?> records = page.getRecords();
if (records != null && !records.isEmpty()) {
for (Object record : records) {
if (record instanceof ModelConfigEntity) {
ModelConfigEntity entity = (ModelConfigEntity) record;
if (entity.getConfigJson() != null) {
entity.setConfigJson(maskSensitiveFields(entity.getConfigJson()));
}
}
}
}
return super.getPageData(page, target);
}
@Override
public ModelConfigEntity getModelById(String id, boolean isCache) {
return getModelById(id, isCache, true);
}
@Override
public ModelConfigEntity getModelById(String id, boolean isCache, boolean isMaskSensitive) {
if (StringUtils.isBlank(id)) {
return null;
}
ModelConfigEntity entity = null;
String cacheKey = RedisKeys.getModelConfigById(id);
if (isCache) {
// 从缓存获取
entity = (ModelConfigEntity) redisUtils.get(cacheKey);
if (entity != null) {
// 修改:根据isMaskSensitive参数重新处理敏感信息
if (!isMaskSensitive && entity.getConfigJson() != null) {
// 如果需要获取原始配置,但缓存的是掩码后的配置,则从数据库重新获取
ModelConfigEntity originalEntity = modelConfigDao.selectById(id);
if (originalEntity != null) {
entity.setConfigJson(originalEntity.getConfigJson());
}
}
return entity;
}
}
// 从数据库获取数据
entity = modelConfigDao.selectById(id);
if (entity != null) {
// 根据isMaskSensitive参数决定是否掩码敏感信息
if (isMaskSensitive && entity.getConfigJson() != null) {
entity.setConfigJson(maskSensitiveFields(entity.getConfigJson()));
}
if (isCache) {
redisUtils.set(cacheKey, entity);
}
}
return entity;
}
/**
* 验证编辑参数
*/
private void validateEditParameters(String modelType, String provideCode, String id,
ModelConfigBodyDTO modelConfigBodyDTO) {
if (StringUtils.isBlank(modelType) || StringUtils.isBlank(provideCode)) {
throw new RenException(ErrorCode.MODEL_TYPE_PROVIDE_CODE_NOT_NULL);
}
if (StringUtils.isBlank(id)) {
throw new RenException(ErrorCode.IDENTIFIER_NOT_NULL);
}
if (modelConfigBodyDTO == null) {
throw new RenException(ErrorCode.PARAMS_GET_ERROR);
}
}
/**
* 验证添加参数
*/
private void validateAddParameters(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) {
if (StringUtils.isBlank(modelType) || StringUtils.isBlank(provideCode)) {
throw new RenException(ErrorCode.MODEL_TYPE_PROVIDE_CODE_NOT_NULL);
}
if (modelConfigBodyDTO == null) {
throw new RenException(ErrorCode.PARAMS_GET_ERROR);
}
}
/**
* 设置默认模型
*/
@Override
public void setDefaultModel(String modelType, int isDefault) {
// 参数验证
if (StringUtils.isBlank(modelType)) {
throw new RenException(ErrorCode.MODEL_TYPE_PROVIDE_CODE_NOT_NULL);
}
ModelConfigEntity entity = new ModelConfigEntity();
entity.setIsDefault(isDefault);
modelConfigDao.update(entity, new QueryWrapper<ModelConfigEntity>()
.eq("model_type", modelType));
// 清除相关缓存
clearModelCacheByType(modelType);
}
/**
* 验证模型提供者
*/
private void validateModelProvider(String modelType, String provideCode) {
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
if (CollectionUtil.isEmpty(providerList)) {
throw new RenException(ErrorCode.MODEL_PROVIDER_NOT_EXIST);
}
}
/**
* 从数据库获取原始配置(不经过敏感数据处理)
*/
private ModelConfigEntity getOriginalConfigFromDb(String id) {
ModelConfigEntity originalEntity = modelConfigDao.selectById(id);
if (originalEntity == null) {
throw new RenException(ErrorCode.RESOURCE_NOT_FOUND);
}
return originalEntity;
}
/**
* 验证LLM配置
*/
private void validateLlmConfiguration(ModelConfigBodyDTO modelConfigBodyDTO) {
if (modelConfigBodyDTO.getConfigJson() != null && modelConfigBodyDTO.getConfigJson().containsKey("llm")) {
String llm = modelConfigBodyDTO.getConfigJson().get("llm").toString();
ModelConfigEntity modelConfigEntity = modelConfigDao.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getId, llm));
if (modelConfigEntity == null) {
throw new RenException(ErrorCode.LLM_NOT_EXIST);
}
String modelType = modelConfigEntity.getModelType();
if (modelType == null || !"LLM".equals(modelType.toUpperCase())) {
throw new RenException(ErrorCode.LLM_NOT_EXIST);
}
// 验证LLM类型
JSONObject configJson = modelConfigEntity.getConfigJson();
if (configJson != null && configJson.containsKey("type")) {
String type = configJson.get("type").toString();
if (!"openai".equals(type) && !"ollama".equals(type)) {
throw new RenException(ErrorCode.INVALID_LLM_TYPE);
}
}
}
}
/**
* 准备更新实体,处理敏感数据
*/
private ModelConfigEntity prepareUpdateEntity(ModelConfigBodyDTO modelConfigBodyDTO,
ModelConfigEntity originalEntity,
String modelType,
String id) {
// 1. 复制原始实体,保留所有原始数据(包括敏感信息)
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(originalEntity, ModelConfigEntity.class);
modelConfigEntity.setId(id);
modelConfigEntity.setModelType(modelType);
// 2. 只更新非敏感字段
modelConfigEntity.setModelName(modelConfigBodyDTO.getModelName());
modelConfigEntity.setSort(modelConfigBodyDTO.getSort());
modelConfigEntity.setIsEnabled(modelConfigBodyDTO.getIsEnabled());
// 3. 处理配置JSON,仅更新非敏感字段和明确修改的敏感字段
if (modelConfigBodyDTO.getConfigJson() != null && originalEntity.getConfigJson() != null) {
JSONObject originalJson = originalEntity.getConfigJson();
JSONObject updatedJson = new JSONObject(originalJson); // 基于原始JSON进行修改
// 遍历更新的JSON,只更新非敏感字段或确实被修改的敏感字段
for (String key : modelConfigBodyDTO.getConfigJson().keySet()) {
Object value = modelConfigBodyDTO.getConfigJson().get(key);
// 如果是敏感字段,需要确认是否真的被修改(前端传入的可能是掩码后的值)
if (SensitiveDataUtils.isSensitiveField(key)) {
if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) {
updatedJson.put(key, value);
}
} else if (value instanceof JSONObject) {
// 递归处理嵌套JSON
mergeJson(updatedJson, key, (JSONObject) value);
} else {
// 非敏感字段直接更新
updatedJson.put(key, value);
}
}
modelConfigEntity.setConfigJson(updatedJson);
}
return modelConfigEntity;
}
// 辅助方法:判断值是否是掩码格式
private boolean isMaskedValue(String value) {
if (value == null)
return false;
// 简单判断是否包含掩码的特征(***)
return value.contains("***");
}
// 辅助方法:递归合并JSON,保留原始敏感字段
private void mergeJson(JSONObject original, String key, JSONObject updated) {
if (!original.containsKey(key)) {
original.put(key, new JSONObject());
}
JSONObject originalChild = original.getJSONObject(key);
for (String childKey : updated.keySet()) {
Object childValue = updated.get(childKey);
if (childValue instanceof JSONObject) {
mergeJson(originalChild, childKey, (JSONObject) childValue);
} else {
if (!SensitiveDataUtils.isSensitiveField(childKey) ||
(childValue instanceof String && !isMaskedValue((String) childValue))) {
originalChild.put(childKey, childValue);
}
}
}
}
/**
* 准备新增实体
*/
private ModelConfigEntity prepareAddEntity(ModelConfigBodyDTO modelConfigBodyDTO, String modelType) {
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
modelConfigEntity.setModelType(modelType);
modelConfigEntity.setIsDefault(0);
return modelConfigEntity;
}
/**
* 构建返回的DTO,处理敏感数据
*/
private ModelConfigDTO buildResponseDTO(ModelConfigEntity entity) {
ModelConfigDTO dto = ConvertUtils.sourceToTarget(entity, ModelConfigDTO.class);
if (dto.getConfigJson() != null) {
dto.setConfigJson(maskSensitiveFields(dto.getConfigJson()));
}
return dto;
}
/**
* 处理敏感字段
*/
private JSONObject maskSensitiveFields(JSONObject configJson) {
return SensitiveDataUtils.maskSensitiveFields(configJson);
}
/**
* 清除模型缓存
*/
private void clearModelCache(String id) {
redisUtils.delete(RedisKeys.getModelConfigById(id));
redisUtils.delete(RedisKeys.getModelNameById(id));
}
/**
* 按模型类型清除缓存
*/
private void clearModelCacheByType(String modelType) {
List<ModelConfigEntity> entities = modelConfigDao.selectList(
new QueryWrapper<ModelConfigEntity>().eq("model_type", modelType));
for (ModelConfigEntity entity : entities) {
clearModelCache(entity.getId());
}
} }
/** /**
* 检查智能体配置是否有引用 * 检查智能体配置是否有引用
*
* @param modelId 模型ID
*/ */
private void checkAgentReference(String modelId) { private void checkAgentReference(String modelId) {
List<AgentEntity> agents = agentDao.selectList( List<AgentEntity> agents = agentDao.selectList(
@@ -187,8 +502,6 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
/** /**
* 检查意图识别配置是否有引用 * 检查意图识别配置是否有引用
*
* @param modelId 模型ID
*/ */
private void checkIntentConfigReference(String modelId) { private void checkIntentConfigReference(String modelId) {
ModelConfigEntity modelConfig = modelConfigDao.selectById(modelId); ModelConfigEntity modelConfig = modelConfigDao.selectById(modelId);
@@ -203,54 +516,4 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
} }
} }
} }
@Override
public String getModelNameById(String id) {
if (StringUtils.isBlank(id)) {
return null;
}
String cachedName = (String) redisUtils.get(RedisKeys.getModelNameById(id));
if (StringUtils.isNotBlank(cachedName)) {
return cachedName;
}
ModelConfigEntity entity = modelConfigDao.selectById(id);
if (entity != null) {
String modelName = entity.getModelName();
if (StringUtils.isNotBlank(modelName)) {
redisUtils.set(RedisKeys.getModelNameById(id), modelName);
}
return modelName;
}
return null;
}
@Override
public ModelConfigEntity getModelById(String id, boolean isCache) {
if (StringUtils.isBlank(id)) {
return null;
}
if (isCache) {
ModelConfigEntity cachedConfig = (ModelConfigEntity) redisUtils.get(RedisKeys.getModelConfigById(id));
if (cachedConfig != null) {
return ConvertUtils.sourceToTarget(cachedConfig, ModelConfigEntity.class);
}
}
ModelConfigEntity entity = modelConfigDao.selectById(id);
if (entity != null) {
redisUtils.set(RedisKeys.getModelConfigById(id), entity);
}
return entity;
}
@Override
public void setDefaultModel(String modelType, int isDefault) {
ModelConfigEntity entity = new ModelConfigEntity();
entity.setIsDefault(isDefault);
modelConfigDao.update(entity, new QueryWrapper<ModelConfigEntity>()
.eq("model_type", modelType));
}
} }
@@ -88,6 +88,7 @@ public class ShiroConfig {
// 将config路径使用server服务过滤器 // 将config路径使用server服务过滤器
filterMap.put("/config/**", "server"); filterMap.put("/config/**", "server");
filterMap.put("/agent/chat-history/report", "server"); filterMap.put("/agent/chat-history/report", "server");
filterMap.put("/agent/chat-history/download/**", "anon");
filterMap.put("/agent/saveMemory/**", "server"); filterMap.put("/agent/saveMemory/**", "server");
filterMap.put("/agent/play/**", "anon"); filterMap.put("/agent/play/**", "anon");
filterMap.put("/**", "oauth2"); filterMap.put("/**", "oauth2");
@@ -17,6 +17,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
@@ -31,6 +32,8 @@ import xiaozhi.modules.security.password.PasswordUtils;
import xiaozhi.modules.security.service.CaptchaService; import xiaozhi.modules.security.service.CaptchaService;
import xiaozhi.modules.security.service.SysUserTokenService; import xiaozhi.modules.security.service.SysUserTokenService;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.common.utils.Sm2DecryptUtil;
import org.apache.commons.lang3.StringUtils;
import xiaozhi.modules.sys.dto.PasswordDTO; import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO; import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
@@ -42,6 +45,7 @@ import xiaozhi.modules.sys.vo.SysDictDataItem;
/** /**
* 登录控制层 * 登录控制层
*/ */
@Slf4j
@AllArgsConstructor @AllArgsConstructor
@RestController @RestController
@RequestMapping("/user") @RequestMapping("/user")
@@ -66,10 +70,11 @@ public class LoginController {
@Operation(summary = "短信验证码") @Operation(summary = "短信验证码")
public Result<Void> smsVerification(@RequestBody SmsVerificationDTO dto) { public Result<Void> smsVerification(@RequestBody SmsVerificationDTO dto) {
// 验证图形验证码 // 验证图形验证码
boolean validate = captchaService.validate(dto.getCaptchaId(), dto.getCaptcha(), true); boolean validate = captchaService.validate(dto.getCaptchaId(), dto.getCaptcha(), false);
if (!validate) { if (!validate) {
throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR); throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR);
} }
Boolean isMobileRegister = sysParamsService Boolean isMobileRegister = sysParamsService
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class); .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
if (!isMobileRegister) { if (!isMobileRegister) {
@@ -83,11 +88,14 @@ public class LoginController {
@PostMapping("/login") @PostMapping("/login")
@Operation(summary = "登录") @Operation(summary = "登录")
public Result<TokenDTO> login(@RequestBody LoginDTO login) { public Result<TokenDTO> login(@RequestBody LoginDTO login) {
// 验证是否正确输入验证码 String password = login.getPassword();
boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true);
if (!validate) { // 使用工具类解密并验证验证码
throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR); String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
} password, login.getCaptchaId(), captchaService, sysParamsService);
login.setPassword(actualPassword);
// 按照用户名获取用户 // 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
// 判断用户是否存在 // 判断用户是否存在
@@ -100,6 +108,8 @@ public class LoginController {
} }
return sysUserTokenService.createToken(userDTO.getId()); return sysUserTokenService.createToken(userDTO.getId());
} }
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "注册") @Operation(summary = "注册")
@@ -107,6 +117,15 @@ public class LoginController {
if (!sysUserService.getAllowUserRegister()) { if (!sysUserService.getAllowUserRegister()) {
throw new RenException(ErrorCode.USER_REGISTER_DISABLED); throw new RenException(ErrorCode.USER_REGISTER_DISABLED);
} }
String password = login.getPassword();
// 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, login.getCaptchaId(), captchaService, sysParamsService);
login.setPassword(actualPassword);
// 是否开启手机注册 // 是否开启手机注册
Boolean isMobileRegister = sysParamsService Boolean isMobileRegister = sysParamsService
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class); .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
@@ -122,12 +141,6 @@ public class LoginController {
if (!validate) { if (!validate) {
throw new RenException(ErrorCode.SMS_CODE_ERROR); throw new RenException(ErrorCode.SMS_CODE_ERROR);
} }
} else {
// 验证是否正确输入验证码
validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true);
if (!validate) {
throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR);
}
} }
// 按照用户名获取用户 // 按照用户名获取用户
@@ -190,6 +203,14 @@ public class LoginController {
throw new RenException(ErrorCode.SMS_CODE_ERROR); throw new RenException(ErrorCode.SMS_CODE_ERROR);
} }
String password = dto.getPassword();
// 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, dto.getCaptchaId(), captchaService, sysParamsService);
dto.setPassword(actualPassword);
sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword()); sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword());
return new Result<>(); return new Result<>();
} }
@@ -208,6 +229,13 @@ public class LoginController {
config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true)); config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true));
config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true)); config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true));
config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true)); config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true));
// SM2公钥
String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true);
if (StringUtils.isBlank(publicKey)) {
throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED);
}
config.put("sm2PublicKey", publicKey);
return new Result<Map<String, Object>>().ok(config); return new Result<Map<String, Object>>().ok(config);
} }
@@ -21,10 +21,6 @@ public class LoginDTO implements Serializable {
@NotBlank(message = "{sysuser.password.require}") @NotBlank(message = "{sysuser.password.require}")
private String password; private String password;
@Schema(description = "验证码")
@NotBlank(message = "{sysuser.captcha.require}")
private String captcha;
@Schema(description = "手机验证码") @Schema(description = "手机验证码")
private String mobileCaptcha; private String mobileCaptcha;
@@ -25,6 +25,10 @@ public class RetrievePasswordDTO implements Serializable {
@NotBlank(message = "{sysuser.password.require}") @NotBlank(message = "{sysuser.password.require}")
private String password; private String password;
@Schema(description = "图形验证码ID")
@NotBlank(message = "{sysuser.uuid.require}")
private String captchaId;
} }
@@ -21,6 +21,7 @@ import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.SM2Utils;
import xiaozhi.modules.sys.dao.SysParamsDao; import xiaozhi.modules.sys.dao.SysParamsDao;
import xiaozhi.modules.sys.dto.SysParamsDTO; import xiaozhi.modules.sys.dto.SysParamsDTO;
import xiaozhi.modules.sys.entity.SysParamsEntity; import xiaozhi.modules.sys.entity.SysParamsEntity;
@@ -206,6 +207,31 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
String newSecret = UUID.randomUUID().toString(); String newSecret = UUID.randomUUID().toString();
updateValueByCode(Constant.SERVER_SECRET, newSecret); updateValueByCode(Constant.SERVER_SECRET, newSecret);
} }
// 初始化SM2密钥对
initSM2KeyPair();
}
/**
* 初始化SM2密钥对
*/
private void initSM2KeyPair() {
// 获取SM2公钥
String publicKey = getValue(Constant.SM2_PUBLIC_KEY, false);
// 获取SM2私钥
String privateKey = getValue(Constant.SM2_PRIVATE_KEY, false);
// 如果公钥或私钥为空,则生成新的密钥对
if (StringUtils.isBlank(publicKey) || StringUtils.isBlank(privateKey) ||
"null".equals(publicKey) || "null".equals(privateKey)) {
Map<String, String> keyPair = SM2Utils.createKey();
String newPublicKey = keyPair.get(SM2Utils.KEY_PUBLIC_KEY);
String newPrivateKey = keyPair.get(SM2Utils.KEY_PRIVATE_KEY);
// 更新数据库中的密钥对
updateValueByCode(Constant.SM2_PUBLIC_KEY, newPublicKey);
updateValueByCode(Constant.SM2_PRIVATE_KEY, newPrivateKey);
}
} }
/** /**
@@ -0,0 +1,41 @@
-- 添加讯飞流式TTS供应器
delete from `ai_model_provider` where id = 'SYSTEM_TTS_XunFeiStreamTTS';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_TTS_XunFeiStreamTTS', 'TTS', 'xunfei_stream', '讯飞流式语音合成', '[{"key":"app_id","label":"APP_ID","type":"string"},{"key":"api_secret","label":"API_Secret","type":"string"},{"key":"api_key","label":"API密钥","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"voice","label":"音色","type":"string"},{"key":"format","label":"音频格式","type":"string"},{"key":"sample_rate","label":"采样率","type":"number"},{"key": "volume", "type": "number", "label": "音量"},{"key": "speed", "type": "number", "label": "语速"},{"key": "pitch", "type": "number", "label": "音调"},{"key": "oral_level", "type": "number", "label": "口语化等级"},{"key": "spark_assist", "type": "number", "label": "是否口语化"},{"key": "stop_split", "type": "number", "label": "服务端拆句"},{"key": "remain", "type": "number", "label": "保留书面语"}]', 20, 1, NOW(), 1, NOW());
-- 添加讯飞流式TTS模型配置
delete from `ai_model_config` where id = 'TTS_XunFeiStreamTTS';
INSERT INTO `ai_model_config` VALUES ('TTS_XunFeiStreamTTS', 'TTS', 'XunFeiStreamTTS', '讯飞流式语音合成', 0, 1, '{\"type\": \"xunfei_stream\", \"app_id\": \"\", \"api_secret\": \"\", \"api_key\": \"\", \"output_dir\": \"tmp/\", \"voice\": \"x5_lingxiaoxuan_flow\", \"format\": \"raw\", \"sample_rate\": 24000, \"volume\": 50, \"speed\": 50, \"pitch\": 50, \"oral_level\": \"mid\", \"spark_assist\": 1, \"stop_split\": 0, \"remain\": 0}', NULL, NULL, 23, NULL, NULL, NULL, NULL);
-- 更新讯飞流式TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.xfyun.cn/app/myapp',
`remark` = '讯飞流式TTS说明:
1. 登录讯飞语音技术平台 https://console.xfyun.cn/app/myapp 创建相关应用
2. 选择需要的服务获取api相关配置 https://console.xfyun.cn/services/uts
3. 为需要使用的应用(APPID)购买相关服务 例如:超拟人合成 https://console.xfyun.cn/services/uts
5. 支持实时双流式通信,具有较低的延迟
6. 支持口语化设置和音频参数调整 注意:V5音色不支持相关口语化配置
7. 支持实时调节音量、语速、音调等参数
' WHERE `id` = 'TTS_XunFeiStreamTTS';
-- 添加讯飞流式TTS音色
delete from `ai_tts_voice` where tts_model_id = 'TTS_XunFeiStreamTTS';
-- 基础角色
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0001', 'TTS_XunFeiStreamTTS', '聆小璇', 'x5_lingxiaoxuan_flow', '中文', NULL, NULL, NULL, NULL, 1, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0002', 'TTS_XunFeiStreamTTS', '聆飞逸', 'x5_lingfeiyi_flow', '中文', NULL, NULL, NULL, NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0003', 'TTS_XunFeiStreamTTS', '聆小玥', 'x5_lingxiaoyue_flow', '中文', NULL, NULL, NULL, NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0004', 'TTS_XunFeiStreamTTS', '聆玉昭', 'x5_lingyuzhao_flow', '中文', NULL, NULL, NULL, NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0005', 'TTS_XunFeiStreamTTS', '聆玉言', 'x5_lingyuyan_flow', '中文', NULL, NULL, NULL, NULL, 5, NULL, NULL, NULL, NULL);
-- 需要添加对应的角色音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0006', 'TTS_XunFeiStreamTTS', '聆飞哲', 'x4_lingfeizhe_oral', '中文', NULL, NULL, NULL, NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0007', 'TTS_XunFeiStreamTTS', '聆小璃', 'x4_lingxiaoli_oral', '中文', NULL, NULL, NULL, NULL, 7, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0008', 'TTS_XunFeiStreamTTS', '聆小糖', 'x5_lingxiaotang_flow', '中文', NULL, NULL, NULL, NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0009', 'TTS_XunFeiStreamTTS', '聆小琪', 'x4_lingxiaoqi_oral', '中文', NULL, NULL, NULL, NULL, 9, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0010', 'TTS_XunFeiStreamTTS', '聆佑佑-童年女声', 'x4_lingyouyou_oral', '中文', NULL, NULL, NULL, NULL, 10, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0011', 'TTS_XunFeiStreamTTS', '子津', 'x4_zijin_oral', '天津话', NULL, NULL, NULL, NULL, 11, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0012', 'TTS_XunFeiStreamTTS', '子阳', 'x4_ziyang_oral', '东北话', NULL, NULL, NULL, NULL, 12, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0013', 'TTS_XunFeiStreamTTS', 'Grant', 'x5_EnUs_Grant_flow', '英文', NULL, NULL, NULL, NULL, 13, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_XunFeiStreamTTS_0014', 'TTS_XunFeiStreamTTS', 'Lila', 'x5_EnUs_Lila_flow', '英文', NULL, NULL, NULL, NULL, 14, NULL, NULL, NULL, NULL);
@@ -0,0 +1,26 @@
-- 添加讯飞流式语音识别服务配置
delete from `ai_model_provider` where id = 'SYSTEM_ASR_XunfeiStream';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_XunfeiStream', 'ASR', 'xunfei_stream', '讯飞流式语音识别', '[{"key":"app_id","label":"应用ID","type":"string"},{"key":"api_key","label":"API_KEY","type":"password"},{"key":"api_secret","label":"API_SECRET","type":"password"},{"key":"domain","label":"识别领域","type":"string"},{"key":"language","label":"识别语言","type":"string"},{"key":"accent","label":"方言","type":"string"},{"key":"dwa","label":"动态修正","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 18, 1, NOW(), 1, NOW());
delete from `ai_model_config` where id = 'ASR_XunfeiStream';
INSERT INTO `ai_model_config` VALUES ('ASR_XunfeiStream', 'ASR', '讯飞流式语音识别', '讯飞流式语音识别服务', 0, 1, '{"type": "xunfei_stream", "app_id": "", "api_key": "", "api_secret": "", "domain": "slm", "language": "zh_cn", "accent": "mandarin", "dwa": "wpgs", "output_dir": "tmp/"}', 'https://www.xfyun.cn/doc/spark/spark_zh_iat.html', '支持实时流式语音识别,适用于中文普通话及多种方言识别', 21, NULL, NULL, NULL, NULL);
-- 更新讯飞流式语音识别模型配置的说明文档
UPDATE `ai_model_config` SET
`doc_link` = 'https://www.xfyun.cn/doc/spark/spark_zh_iat.html',
`remark` = '讯飞流式语音识别配置说明:
1. 登录讯飞开放平台 https://www.xfyun.cn/
2. 创建语音识别应用获取APPID、APISecret、APIKey
3. 参数说明:
- app_id: 应用ID,在讯飞开放平台创建应用后获得
- api_key: API密钥,用于接口鉴权
- api_secret: API密钥,用于生成签名
- domain: 识别领域,默认slm(智能化语音转写)
- language: 识别语言,默认zh_cn(中文)
- accent: 方言类型,默认mandarin(普通话),支持cantonese(粤语)等
- dwa: 动态修正,默认wpgs(开启动态修正)
- output_dir: 音频文件输出目录,默认tmp/
4. 支持实时流式识别,适用于实时语音交互场景
5. 支持多种方言和语言识别
' WHERE `id` = 'ASR_XunfeiStream';
@@ -0,0 +1,19 @@
delete from `ai_model_config` where id = 'LLM_XunfeiSparkLLM';
INSERT INTO `ai_model_config` VALUES ('LLM_XunfeiSparkLLM', 'LLM', '讯飞星火认知大模型', '讯飞星火认知大模型', 0, 1, '{"type": "openai", "model_name": "generalv3.5", "base_url": "https://spark-api-open.xf-yun.com/v1", "api_password": "你的api_password", "temperature": 0.5, "max_tokens": 2048, "top_p": 1.0, "frequency_penalty": 0.0}', 'https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html', '讯飞星火认知大模型,支持多轮对话、文本生成等功能', 14, NULL, NULL, NULL, NULL);
-- 更新讯飞星火认知大模型配置的说明文档
UPDATE `ai_model_config` SET
`doc_link` = 'https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html',
`remark` = '讯飞星火认知大模型配置说明:
1. 登录讯飞开放平台 https://www.xfyun.cn/,每一个模型对应每一个api_password,更改模型时需要查看对应模型的api_password
2. 创建星火认知大模型应用获取API Password
3. 参数说明:
- api_password: API Password,在讯飞开放平台创建应用后获得
- model_name: 模型名称,支持generalv3.5、generalv3等版本
- base_url: API地址,默认https://spark-api-open.xf-yun.com/v1
- temperature: 温度参数,控制生成随机性,范围0-1,默认0.5
- max_tokens: 最大输出token数,默认2048
- top_p: 核心采样参数,控制词汇多样性,默认1.0
- frequency_penalty: 频率惩罚,降低重复内容,默认0.0
4. 每一个模型对应每一个api_password,更改模型时需要查看对应模型的api_password。
' WHERE `id` = 'LLM_XunfeiSparkLLM';
@@ -0,0 +1,7 @@
-- 添加SM2国密算法密钥参数
-- 用于服务器端SM2加密解密功能
-- 添加SM2密钥参数
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES
(120, 'server.public_key', '', 'string', 1, '服务器SM2公钥'),
(121, 'server.private_key', '', 'string', 1, '服务器SM2私钥');
@@ -359,3 +359,32 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202509161701.sql path: classpath:db/changelog/202509161701.sql
- changeSet:
id: 202509221530
author: cgd
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202509221530.sql
- changeSet:
id: 202509191545
author: RanChen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202509191545.sql
- changeSet:
id: 202509220926
author: fyb
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202509220926.sql
- changeSet:
id: 202509220958
author: fyb
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202509220958.sql
@@ -134,4 +134,13 @@
10123=您的mqtt密钥长度不安全,字符数需要至少8个字符且必须同时包含大小写字母 10123=您的mqtt密钥长度不安全,字符数需要至少8个字符且必须同时包含大小写字母
10124=您的mqtt密钥长度不安全,mqtt密钥必须同时包含大小写字母 10124=您的mqtt密钥长度不安全,mqtt密钥必须同时包含大小写字母
10125=您的mqtt密钥包含弱密码 10125=您的mqtt密钥包含弱密码
10128=字典标签重复 10128=字典标签重复
10129=modelType和provideCode不能为空
10132=没有权限查看该智能体的聊天记录
10133=会话ID不能为空
10134=智能体ID不能为空
10135=聊天记录下载失败
10136=下载链接已过期或无效
10137=下载链接无效
10138=用户
10139=智体
@@ -134,4 +134,15 @@
10123=Your MQTT secret is not secure, it needs to be at least 8 characters and must contain both uppercase and lowercase letters 10123=Your MQTT secret is not secure, it needs to be at least 8 characters and must contain both uppercase and lowercase letters
10124=Your MQTT secret is not secure, MQTT secret must contain both uppercase and lowercase letters 10124=Your MQTT secret is not secure, MQTT secret must contain both uppercase and lowercase letters
10125=Your MQTT secret contains weak password 10125=Your MQTT secret contains weak password
10128=Dictionary label is duplicated 10128=Dictionary label is duplicated
10129=SM2 key not configured
10130=SM2 decryption failed
10131=modelType and provideCode cannot be empty
10132=No permission to view the chat history of this agent
10133=Session ID cannot be empty
10134=Agent ID cannot be empty
10135=Chat history download failed
10136=Download link expired or invalid
10137=Download link invalid
10138=User
10139=Agent
@@ -134,4 +134,15 @@
10123=您的mqtt密钥长度不安全,字符数需要至少8个字符且必须同时包含大小写字母 10123=您的mqtt密钥长度不安全,字符数需要至少8个字符且必须同时包含大小写字母
10124=您的mqtt密钥长度不安全,mqtt密钥必须同时包含大小写字母 10124=您的mqtt密钥长度不安全,mqtt密钥必须同时包含大小写字母
10125=您的mqtt密钥包含弱密码 10125=您的mqtt密钥包含弱密码
10128=字典标签重复 10128=字典标签重复
10129=SM2密钥未配置
10130=SM2解密失败
10131=modelType和provideCode不能为空
10132=没有权限查看该智能体的聊天记录
10133=会话ID不能为空
10134=智能体ID不能为空
10135=聊天记录下载失败
10136=下载链接已过期或无效
10137=下载链接无效
10138=用户
10139=智体
@@ -134,4 +134,15 @@
10123=您的MQTT密鑰不安全,需至少8個字符且必須同時包含大小寫字母 10123=您的MQTT密鑰不安全,需至少8個字符且必須同時包含大小寫字母
10124=您的MQTT密鑰不安全,MQTT密鑰必須同時包含大小寫字母 10124=您的MQTT密鑰不安全,MQTT密鑰必須同時包含大小寫字母
10125=您的MQTT密鑰包含弱密碼 10125=您的MQTT密鑰包含弱密碼
10128=字典標籤重複 10128=字典標籤重複
10129=SM2密鑰未配置
10130=SM2解密失敗
10131=modelType和provideCode不能為空
10132=沒有權限查看該智能體的聊天記錄
10133=會話ID不能為空
10134=智能體ID不能為空
10135=聊天記錄下載失敗
10136=下載鏈接已過期或無效
10137=下載鏈接無效
10138=用戶
10139=智體
@@ -24,7 +24,6 @@ class loginControllerTest {
LoginDTO loginDTO = new LoginDTO(); LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("手机号码"); loginDTO.setUsername("手机号码");
loginDTO.setPassword("密码"); loginDTO.setPassword("密码");
loginDTO.setCaptcha("123456");
loginController.register(loginDTO); loginController.register(loginDTO);
} }
+1
View File
@@ -100,6 +100,7 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"pinia": "2.0.36", "pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1", "pinia-plugin-persistedstate": "3.2.1",
"sm-crypto": "^0.3.13",
"vue": "^3.4.21", "vue": "^3.4.21",
"wot-design-uni": "^1.9.1", "wot-design-uni": "^1.9.1",
"z-paging": "2.8.7" "z-paging": "2.8.7"
+55
View File
@@ -1,12 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app' import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { watch, onMounted } from 'vue'
import { usePageAuth } from '@/hooks/usePageAuth' import { usePageAuth } from '@/hooks/usePageAuth'
import { useConfigStore } from '@/store' import { useConfigStore } from '@/store'
import { t } from '@/i18n'
import { useLangStore } from '@/store/lang'
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only' import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
usePageAuth() usePageAuth()
const configStore = useConfigStore() const configStore = useConfigStore()
const langStore = useLangStore()
onLaunch(() => { onLaunch(() => {
console.log('App Launch') console.log('App Launch')
@@ -17,7 +21,58 @@ onLaunch(() => {
}) })
onShow(() => { onShow(() => {
console.log('App Show') console.log('App Show')
// 使用setTimeout延迟执行,确保tabBar已经初始化
setTimeout(() => {
updateTabBarText()
}, 100)
}) })
// 动态更新tabBar文本
function updateTabBarText() {
try {
// 设置首页tabBar文本
uni.setTabBarItem({
index: 0,
text: t('tabBar.home'),
success: () => {},
fail: (err) => {
console.log('设置首页tabBar文本失败:', err)
}
})
// 设置配网tabBar文本
uni.setTabBarItem({
index: 1,
text: t('tabBar.deviceConfig'),
success: () => {},
fail: (err) => {
console.log('设置配网tabBar文本失败:', err)
}
})
// 设置系统tabBar文本
uni.setTabBarItem({
index: 2,
text: t('tabBar.settings'),
success: () => {},
fail: (err) => {
console.log('设置系统tabBar文本失败:', err)
}
})
} catch (error) {
console.log('更新tabBar文本时出错:', error)
}
}
// 监听语言切换事件
onMounted(() => {
// 监听语言变化,当语言改变时自动更新tabBar文本
watch(() => langStore.currentLang, () => {
console.log('语言已切换,更新tabBar文本')
// 语言切换后立即更新tabBar文本
updateTabBarText()
})
})
onHide(() => { onHide(() => {
console.log('App Hide') console.log('App Hide')
}) })
+1 -3
View File
@@ -4,7 +4,6 @@ import { http } from '@/http/request/alova'
export interface LoginData { export interface LoginData {
username: string username: string
password: string password: string
captcha: string
captchaId: string captchaId: string
areaCode?: string areaCode?: string
mobile?: string mobile?: string
@@ -68,6 +67,7 @@ export interface PublicConfig {
beianIcpNum: string beianIcpNum: string
beianGaNum: string beianGaNum: string
name: string name: string
sm2PublicKey: string
} }
// 获取用户信息 // 获取用户信息
@@ -94,8 +94,6 @@ export function getPublicConfig() {
export interface RegisterData { export interface RegisterData {
username: string username: string
password: string password: string
confirmPassword: string
captcha: string
captchaId: string captchaId: string
areaCode: string areaCode: string
mobile: string mobile: string
+419
View File
@@ -0,0 +1,419 @@
// English language pack
export default {
// TabBar
'tabBar.home': 'Home',
'tabBar.deviceConfig': 'Network Config',
'tabBar.settings': 'System',
// Settings page title
'settings.title': 'Settings',
// Login page
'login.pageTitle': 'Login',
'login.navigationTitle': 'Login',
'login.fetchConfigError': 'Failed to fetch configuration:',
'login.selectLanguage': 'Select Language',
'login.selectLanguageTip': '中文',
'login.welcomeBack': 'Welcome Back',
'login.pleaseLogin': 'Please log in to your account',
'login.enterUsername': 'Please enter username',
'login.enterPassword': 'Please enter password',
'login.enterCaptcha': 'Please enter verification code',
'login.loginButton': 'Login',
'login.loggingIn': 'Logging in...',
'login.noAccount': 'did not have an account?',
'login.registerNow': 'Register Now',
'login.enterPhone': 'Please enter phone number',
'login.selectCountry': 'Select Country/Region',
'login.confirm': 'Confirm',
'login.serverSetting': 'Server Settings',
'login.requiredUsername': 'Username cannot be empty',
'login.requiredPassword': 'Password cannot be empty',
'login.requiredCaptcha': 'Verification code cannot be empty',
'login.requiredMobile': 'Please enter a valid phone number',
'login.captchaError': 'Graphic verification code error',
// Register page
'register.pageTitle': 'Register',
'register.createAccount': 'Create Account',
'register.enterUsername': 'Please enter username',
'register.enterPassword': 'Please enter password',
'register.confirmPassword': 'Please confirm password',
'register.enterPhone': 'Please enter phone number',
'register.enterCode': 'Please enter verification code',
'register.getCode': 'Get Code',
'register.agreeTerms': 'I have read and agree to the',
'register.terms': 'User Agreement',
'register.privacy': 'Privacy Policy',
'register.registerButton': 'Register',
'register.registering': 'Registering...',
'register.haveAccount': 'Already have an account?',
'register.loginNow': 'Login Now',
'register.selectCountry': 'Select Country/Region',
'register.confirm': 'Confirm',
'register.captchaSendSuccess': 'Verification code sent successfully',
// Home page
'home.pageTitle': 'Home',
'home.createAgent': 'Create Agent',
'home.agentName': 'Agent',
'home.modelInfo': 'Model Info',
'home.lastActive': 'Last Active',
'home.greeting': 'Hi Jarvis',
'home.subtitle': 'Let\'s have',
'home.wonderfulDay': 'a wonderful day!',
'home.emptyState': 'No agents available',
'home.deviceManagement': 'Device Management',
'home.lastConversation': 'Last Conversation:',
'home.delete': 'Delete',
'home.createFirstAgent': 'Click the + button in the lower right corner to create your first agent',
'home.dialogTitle': 'Create Agent',
'home.inputPlaceholder': 'e.g. Customer Service Assistant, Voice Assistant, Knowledge Q&A',
'home.createError': 'Please input agent name',
'home.createNow': 'Create Now',
'home.justNow': 'Just now',
'home.minutesAgo': 'minutes ago',
'home.hoursAgo': 'hours ago',
'home.daysAgo': 'days ago',
// Agent page
'agent.pageTitle': 'Agent',
'agent.roleConfig': 'Role Configuration',
'agent.deviceManagement': 'Device Management',
'agent.chatHistory': 'Chat History',
'agent.voiceprintManagement': 'Voiceprint Management',
'agent.editTitle': 'Edit Agent',
'agent.toolsTitle': 'Edit Features',
'agent.voiceActivityDetection': 'Voice Activity Detection',
'agent.speechRecognition': 'Speech Recognition',
'agent.largeLanguageModel': 'Large Language Model',
'agent.save': 'Save',
'agent.cancel': 'Cancel',
// Agent Edit Page
'agent.basicInfo': 'Basic Information',
'agent.agentName': 'Agent Name',
'agent.inputAgentName': 'Please input agent name',
'agent.roleMode': 'Role Mode',
'agent.roleDescription': 'Role Description',
'agent.inputRoleDescription': 'Please input role description',
'agent.modelConfig': 'Model Configuration',
'agent.vad': 'Voice Activity Detection',
'agent.asr': 'Speech Recognition',
'agent.llm': 'Large Language Model',
'agent.vllm': 'Vision Language Model',
'agent.intent': 'Intent Recognition',
'agent.memory': 'Memory',
'agent.voiceSettings': 'Voice Settings',
'agent.tts': 'Text-to-Speech',
'agent.voiceprint': 'Agent Voice',
'agent.plugins': 'Plugins',
'agent.editFunctions': 'Edit Functions',
'agent.historyMemory': 'History Memory',
'agent.memoryContent': 'Memory content',
'agent.saving': 'Saving...',
'agent.saveSuccess': 'Save successful',
'agent.saveFail': 'Save failed',
'agent.loadFail': 'Load failed',
'agent.pleaseInputAgentName': 'Please input agent name',
'agent.pleaseInputRoleDescription': 'Please input role description',
'agent.pleaseSelect': 'Please select',
// Chat History Page
'chatHistory.getChatSessions': 'Get chat session list',
'chatHistory.noSelectedAgent': 'No agent selected',
'chatHistory.getChatSessionsFailed': 'Failed to get chat session list:',
'chatHistory.unknownTime': 'Unknown time',
'chatHistory.justNow': 'Just now',
'chatHistory.minutesAgo': '{minutes} minutes ago',
'chatHistory.hoursAgo': '{hours} hours ago',
'chatHistory.daysAgo': '{days} days ago',
'chatHistory.conversationRecord': 'Conversation Record',
'chatHistory.totalChats': '{count} chats in total',
'chatHistory.loading': 'Loading...',
'chatHistory.noMoreData': 'No more data',
'chatHistory.noChatRecords': 'No chat records',
'chatRecordsDescription': 'Conversation records with agents will be displayed here',
// Chat History Detail Page
'chatHistory.pageTitle': 'Chat Detail',
'chatHistory.assistantName': 'Intelligent Assistant',
'chatHistory.userName': 'User',
'chatHistory.aiAssistantName': 'AI Assistant',
'chatHistory.loadFailed': 'Failed to load chat history',
'chatHistory.parameterError': 'Page parameter error',
'chatHistory.invalidAudioId': 'Invalid audio ID',
'chatHistory.audioPlayFailed': 'Audio playback failed',
'chatHistory.playAudioFailed': 'Failed to play audio',
// Device management page
'device.pageTitle': 'Device Management',
'device.noDevices': 'No devices available',
'device.macAddress': 'MAC Address',
'device.firmwareVersion': 'Firmware Version',
'device.lastConnected': 'Last Conversation',
'device.otaUpdate': 'OTA Update',
'device.unbind': 'Unbind',
'device.confirmUnbind': 'Sure',
'device.bindDevice': 'Bind New Device',
'device.deviceType': 'Device Type',
'device.loading': 'Loading...',
'device.neverConnected': 'Never connected',
'device.justNow': 'Just now',
'device.minutesAgo': '{minutes} minutes ago',
'device.hoursAgo': '{hours} hours ago',
'device.daysAgo': '{days} days ago',
'device.otaAutoUpdateEnabled': 'OTA auto update enabled',
'device.otaAutoUpdateDisabled': 'OTA auto update disabled',
'device.operationFailed': 'Operation failed, please retry',
'device.deviceUnbound': 'Device unbound',
'device.unbindFailed': 'Unbind failed, please retry',
'device.unbindDevice': 'Unbind Device',
'device.confirmUnbindDevice': 'Are you sure you want to unbind device "{macAddress}"?',
'device.cancel': 'Cancel',
'device.noDevice': 'No Device',
'device.pleaseSelectAgent': 'Please select an agent first',
'device.deviceBindSuccess': 'Device bound successfully!',
'device.bindFailed': 'Bind failed, please check if the verification code is correct',
'device.enterDeviceCode': 'Please enter device verification code',
'device.bindNow': 'Bind Now',
'device.lastConnection': 'Last Connection',
'device.clickToBindFirstDevice': 'Click the + button in the lower right corner to bind your first device',
// Common
'common.success': 'Success',
'common.fail': 'Failed',
'common.loading': 'Loading...',
'common.confirm': 'Confirm',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
'common.pleaseSelect': 'Please select',
'common.unknownError': 'Unknown error',
'common.networkError': 'Network error',
// SM2 encryption related error messages
'sm2.publicKeyNotConfigured': 'SM2 public key not configured, please contact administrator',
'sm2.encryptionFailed': 'Password encryption failed',
'sm2.keyGenerationFailed': 'Key pair generation failed',
'sm2.invalidPublicKey': 'Invalid public key format',
'sm2.encryptionError': 'Error occurred during encryption',
'sm2.publicKeyRetry': 'Retrying to get public key...',
'sm2.publicKeyRetryFailed': 'Public key retrieval retry failed',
// Voiceprint page
'voiceprint.noSelectedAgent': 'No agent selected',
'voiceprint.pleaseSelectAgent': 'Please select an agent first',
'voiceprint.fetchHistoryFailed': 'Failed to fetch chat history',
'voiceprint.clickToSelectVector': 'Click to select voiceprint vector',
'voiceprint.pleaseInputName': 'Please input name',
'voiceprint.pleaseSelectVector': 'Please select voiceprint vector',
'voiceprint.addSuccess': 'Add success',
'voiceprint.addFailed': 'Failed to add speaker',
'voiceprint.editSuccess': 'Edit success',
'voiceprint.editFailed': 'Failed to edit speaker',
'voiceprint.deleteConfirmMsg': 'Are you sure to delete this speaker?',
'voiceprint.deleteConfirmTitle': 'Confirm Delete',
'voiceprint.deleteSuccess': 'Delete success',
'voiceprint.loading': 'Loading...',
'voiceprint.delete': 'Delete',
'voiceprint.emptyTitle': 'No voiceprint data',
'voiceprint.emptyDesc': 'Click the + button at the bottom right to add your first speaker',
'voiceprint.addSpeaker': 'Add Speaker',
'voiceprint.voiceVector': 'Voiceprint Vector',
'voiceprint.name': 'Name',
'voiceprint.description': 'Description',
'voiceprint.pleaseInputDescription': 'Please input description',
'voiceprint.cancel': 'Cancel',
'voiceprint.save': 'Save',
'voiceprint.editSpeaker': 'Edit Speaker',
'voiceprint.selectVector': 'Select Voiceprint Vector',
'voiceprint.voiceprintInterfaceNotConfigured': 'Voiceprint interface not configured',
// Settings page
'settings.pageTitle': 'Settings',
'settings.navigationTitle': 'Settings',
'settings.networkSettings': 'Network Settings',
'settings.serverApiUrl': 'Server API URL',
'settings.validServerUrl': 'Please enter a valid server address (starting with http or https and ending with /xiaozhi)',
'settings.saveSettings': 'Save Settings',
'settings.resetDefault': 'Reset Default',
'settings.restartApp': 'Restart App',
'settings.restartNow': 'Restart Now',
'settings.restartLater': 'Later',
// About us
'settings.aboutApp': 'About XiaoZhi Console',
'settings.aboutContent': 'XiaoZhi Console\n\nA cross-platform mobile management app built with Vue.js 3 + uni-app, providing device management, agent configuration and other functions for xiaozhi ESP32 smart hardware.\n\n© 2025 xiaozhi-esp32-server 0.8.3',
'settings.restartSuccess': 'Saved, you can manually restart the app later',
'settings.serverUrlSavedAndCacheCleared': 'Server URL saved and cache cleared',
'settings.resetToDefaultAndCacheCleared': 'Reset to default and cache cleared',
'settings.resetSuccess': 'Reset successful',
'settings.enterServerUrl': 'Please enter server URL',
'settings.clearCacheFailed': 'Failed to clear cache',
'settings.cacheManagement': 'Cache Management',
'settings.totalCacheSize': 'Total Cache Size',
'settings.appDataSize': 'Total App Data Size',
'settings.cacheClear': 'Cache Clear',
'settings.clearAllCache': 'Clear all cache data',
'settings.clearCache': 'Clear Cache',
'settings.modifyWillClearCache': 'Modifications will clear cache',
'settings.appInfo': 'App Info',
'settings.aboutUs': 'About Us',
'settings.appVersion': 'App Version & Team Info',
'settings.confirmClear': 'Confirm Clear',
'settings.confirmClearMessage': 'Are you sure you want to clear all cache? This will delete all data including login status and require re-login.',
'settings.cacheCleared': 'Cache cleared successfully, redirecting to login page',
'settings.languageSettings': 'Language Settings',
'settings.language': 'Language',
'settings.selectLanguage': 'Select Language',
'settings.languageChanged': 'Language changed successfully',
// Messages
'message.loginSuccess': 'Login successful!',
'message.loginFail': 'Login failed',
'message.registerSuccess': 'Registration successful',
'message.registerFail': 'Registration failed',
'message.saveSuccess': 'Save successful',
'message.saveFail': 'Save failed',
'message.deleteSuccess': 'Delete successful',
'message.deleteFail': 'Delete failed',
'message.bindSuccess': 'Binding successful',
'message.bindFail': 'Binding failed',
'message.unbindSuccess': 'Unbinding successful',
'message.unbindFail': 'Unbinding failed',
'message.networkError': 'Network error, please check your connection',
'message.serverError': 'Server error, please try again later',
'message.invalidAddress': 'Invalid address, please check if the server is started or network connection is normal',
'message.languageChanged': 'Language changed',
'message.passwordError': 'Account or password error',
'message.phoneRegistered': 'This phone number has been registered',
// Agent Tools Page
'agent.tools.pageTitle': 'Agent Tools',
'agent.tools.unselected': 'Unselected',
'agent.tools.selected': 'Selected',
'agent.tools.noMorePlugins': 'No more plugins',
'agent.tools.pleaseSelectPlugin': 'Please select plugin function',
'agent.tools.builtInPlugins': 'Built-in Plugins',
'agent.tools.mcpAccessPoint': 'MCP Access Point',
'agent.tools.copy': 'Copy',
'agent.tools.noTools': 'No tools available',
'agent.tools.parameterConfig': 'Parameter Configuration',
'agent.tools.noParamsNeeded': 'No parameters needed',
'agent.tools.pleaseInput': 'Please input',
'agent.tools.inputOneItemPerLine': 'Input one item per line',
'agent.tools.pleaseInputValidJson': 'Please input valid JSON format',
'agent.tools.enableFunction': 'Enable Function',
'agent.tools.toggleFunction': 'Turn on or off this function',
'agent.tools.jsonFormatError': 'JSON format error',
'agent.tools.noMcpAddressToCopy': 'No MCP address to copy',
'agent.tools.mcpAddressCopied': 'MCP address copied to clipboard',
'agent.tools.copyFailed': 'Copy failed, please try again',
'agent.tools.defaultValue': 'Default value',
'agent.tools.notSelected': 'Unselected',
'agent.tools.clickToConfigure': 'Click to configure',
'agent.tools.mcpEndpoint': 'MCP Endpoint',
'agent.tools.eachLineOneItem': 'Input one item per line',
// Device Config page
'deviceConfig.pageTitle': 'Device Configuration',
'deviceConfig.wifiConfig': 'WiFi Configuration',
'deviceConfig.ultrasonicConfig': 'Ultrasonic Configuration',
'deviceConfig.selectConfigMethod': 'Select Configuration Method',
'deviceConfig.networkConfig': 'Network Configuration',
'deviceConfig.selectedNetwork': 'Selected Network',
'deviceConfig.signal': 'Signal',
'deviceConfig.openNetwork': 'Open Network',
'deviceConfig.encryptedNetwork': 'Encrypted Network',
'deviceConfig.password': 'Password',
'deviceConfig.pleaseEnterPassword': 'Please enter WiFi password',
'deviceConfig.startConfig': 'Start Configuration',
'deviceConfig.connectToXiaozhiHotspot': 'Please connect to xiaozhi hotspot first',
'deviceConfig.detecting': 'Detecting...',
'deviceConfig.reDetect': 'Re-detect',
'deviceConfig.alreadyConnected': 'Connected to xiaozhi hotspot',
'deviceConfig.refreshStatus': 'Refresh Status',
'deviceConfig.wifiNetworks': 'WiFi Networks',
'deviceConfig.selectWifiNetwork': 'Select WiFi Network',
'deviceConfig.refreshScan': 'Refresh Scan',
'deviceConfig.noWifiNetworks': 'No WiFi networks available',
'deviceConfig.clickToRefreshScan': 'Please click Refresh Scan',
'deviceConfig.signalStrong': 'Strong Signal',
'deviceConfig.signalGood': 'Good Signal',
'deviceConfig.signalFair': 'Fair Signal',
'deviceConfig.signalWeak': 'Weak Signal',
'deviceConfig.channel': 'Channel',
'deviceConfig.about': 'about',
'deviceConfig.seconds': 'seconds',
'deviceConfig.generating': 'Generating...',
'deviceConfig.playing': 'Playing...',
'deviceConfig.generateAndPlaySoundWave': 'Generate and Play Sound Wave',
'deviceConfig.playSoundWave': 'Play Sound Wave',
'deviceConfig.stopPlaying': 'Stop Playing',
'deviceConfig.autoLoopPlaySoundWave': 'Auto Loop Play Sound Wave',
'deviceConfig.configAudioFile': 'Configuration Audio File',
'deviceConfig.duration': 'Duration',
'deviceConfig.ultrasonicConfigInstructions': 'Ultrasonic Configuration Instructions',
'deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered': 'Ensure WiFi network is selected and password is entered',
'deviceConfig.clickGenerateAndPlaySoundWave': 'Click Generate and Play Sound Wave, the system will encode configuration information into audio',
'deviceConfig.bringPhoneCloseToXiaozhiDevice': 'Bring phone close to xiaozhi device (1-2 meters distance)',
'deviceConfig.duringAudioPlaybackXiaozhiWillReceive': 'During audio playback, xiaozhi will receive and decode configuration information',
'deviceConfig.afterConfigSuccessDeviceWillConnect': 'After successful configuration, the device will automatically connect to WiFi network',
'deviceConfig.usesAfskModulation': 'Uses AFSK modulation technology, transmitting data through 1800Hz and 1500Hz frequencies',
'deviceConfig.ensureModeratePhoneVolume': 'Please ensure phone volume is moderate to avoid environmental noise interference',
'deviceConfig.generatingUltrasonicConfigAudio': 'Generating ultrasonic configuration audio',
'deviceConfig.configData': 'Configuration data',
'deviceConfig.dataBytesLength': 'Data bytes length',
'deviceConfig.bitStreamLength': 'Bit stream length',
'deviceConfig.base64Length': 'Base64 length',
'deviceConfig.audioFileTooLarge': 'Audio file too large, please shorten SSID or password length',
'deviceConfig.audioGenerationSuccess': 'Audio generation successful',
'deviceConfig.samplePoints': 'Sample points',
'deviceConfig.soundWaveGenerationSuccess': 'Sound wave generation successful',
'deviceConfig.audioGenerationFailed': 'Audio generation failed',
'deviceConfig.soundWaveGenerationFailed': 'Sound wave generation failed',
'deviceConfig.pleaseGenerateAudioFirst': 'Please generate audio first',
'deviceConfig.startPlayingUltrasonicConfigAudio': 'Starting to play ultrasonic configuration audio',
'deviceConfig.ultrasonicAudioStartedPlaying': 'Ultrasonic audio started playing',
'deviceConfig.startPlayingConfigSoundWave': 'Started playing configuration sound wave',
'deviceConfig.ultrasonicAudioPlaybackEnded': 'Ultrasonic audio playback ended',
'deviceConfig.audioPlaybackFailed': 'Audio playback failed',
'deviceConfig.audioResourceBusy': 'Audio resource busy, please try again later',
'deviceConfig.audioFormatNotSupported': 'Audio format not supported, may be a data URI issue',
'deviceConfig.audioFileError': 'Audio file error',
'deviceConfig.cleaningUpAudioContext': 'Cleaning up audio context',
'deviceConfig.cleaningUpAudioContextFailed': 'Failed to clean up audio context',
'deviceConfig.stoppedPlayingUltrasonicAudio': 'Stopped playing ultrasonic audio',
'deviceConfig.stoppedPlaying': 'Stopped playing',
'deviceConfig.configMethod': 'Configuration Method',
'deviceConfig.enterWifiPassword': 'Please enter WiFi password',
'deviceConfig.xiaozhi': 'xiaozhi',
'deviceConfig.connectXiaozhiHotspot': 'Please connect to xiaozhi hotspot',
'deviceConfig.wifiScanResponse': 'WiFi scan response',
'deviceConfig.scanSuccess': 'Scan successful',
'deviceConfig.networks': 'networks',
'deviceConfig.wifiScanFailed': 'WiFi scan failed',
'deviceConfig.scanFailedCheckConnection': 'Scan failed, please check connection',
'deviceConfig.checking': 'Checking',
'deviceConfig.reCheck': 'Re-check',
'deviceConfig.connectedXiaozhiHotspot': 'Connected to xiaozhi hotspot',
'deviceConfig.wifiNetwork': 'WiFi Network',
'deviceConfig.scanning': 'Scanning',
'deviceConfig.cancel': 'Cancel',
'deviceConfig.clickRefreshScan': 'Please click Refresh Scan',
'deviceConfig.esp32ConnectionCheckFailed': 'ESP32 connection check failed',
'deviceConfig.startWifiConfig': 'Starting WiFi configuration',
'deviceConfig.configSuccess': 'Configuration successful',
'deviceConfig.deviceWillConnectTo': 'Device will connect to',
'deviceConfig.deviceWillRestart': 'Device will restart',
'deviceConfig.pleaseDisconnectXiaozhiHotspot': 'Please disconnect from xiaozhi hotspot',
'deviceConfig.configFailed': 'Configuration failed',
'deviceConfig.wifiConfigFailed': 'WiFi configuration failed',
'deviceConfig.pleaseCheckNetworkConnection': 'Please check network connection',
'deviceConfig.startWifiConfigButton': 'Start Configuration',
'deviceConfig.wifiConfigInstructions': 'WiFi Configuration Instructions',
'deviceConfig.phoneConnectXiaozhiHotspot': 'Phone connect to xiaozhi hotspot',
'deviceConfig.selectTargetWifiNetwork': 'Select target WiFi network',
'deviceConfig.enterWifiPasswordIfNeeded': 'Enter WiFi password if needed',
'deviceConfig.clickStartConfigAndWait': 'Click Start Configuration and wait',
'deviceConfig.afterConfigSuccessDeviceWillRestart': 'After successful configuration, device will automatically restart',
'deviceConfig.audioPlaybackError': 'Audio playback error',
'deviceConfig.playbackFailed': 'Playback failed',
}
+70
View File
@@ -0,0 +1,70 @@
import { ref } from 'vue'
import { useLangStore } from '@/store/lang'
import type { Language } from '@/store/lang'
// 导入各个语言的翻译文件
import zhCN from './zh_CN'
import en from './en'
import zhTW from './zh_TW'
// 语言包映射
const messages = {
zh_CN: zhCN,
en,
zh_TW: zhTW,
}
// 当前使用的语言
const currentLang = ref<Language>('zh_CN')
// 初始化语言
export function initI18n() {
const langStore = useLangStore()
currentLang.value = langStore.currentLang
}
// 切换语言
export function changeLanguage(lang: Language) {
currentLang.value = lang
const langStore = useLangStore()
langStore.changeLang(lang)
}
// 获取翻译文本
export function t(key: string, params?: Record<string, string | number>): string {
const langMessages = messages[currentLang.value]
// 直接查找扁平键名
if (langMessages && typeof langMessages === 'object' && key in langMessages) {
let value = langMessages[key]
if (typeof value === 'string') {
// 处理参数替换
if (params) {
let result = value
Object.entries(params).forEach(([paramKey, paramValue]) => {
const regex = new RegExp(`\{${paramKey}\}`, 'g')
result = result.replace(regex, String(paramValue))
})
return result
}
return value
}
return key
}
return key // 如果找不到对应的翻译,返回key本身
}
// 获取当前语言
export function getCurrentLanguage(): Language {
return currentLang.value
}
// 获取支持的语言列表
export function getSupportedLanguages(): {code: Language, name: string}[] {
return [
{ code: 'zh_CN', name: '简体中文' },
{ code: 'en', name: 'English' },
{ code: 'zh_TW', name: '繁體中文' },
]
}
+419
View File
@@ -0,0 +1,419 @@
// 简体中文语言包
export default {
// TabBar
'tabBar.home': '首页',
'tabBar.deviceConfig': '配网',
'tabBar.settings': '系统',
// 设置页面标题
'settings.title': '设置',
// 登录页面
'login.pageTitle': '登录',
'login.navigationTitle': '登录',
'login.fetchConfigError': '获取配置失败:',
'login.selectLanguage': '选择语言',
'login.selectLanguageTip': 'En',
'login.welcomeBack': '欢迎回来',
'login.pleaseLogin': '请登录您的账户',
'login.enterUsername': '请输入用户名',
'login.enterPassword': '请输入密码',
'login.enterCaptcha': '请输入验证码',
'login.loginButton': '登录',
'login.loggingIn': '登录中...',
'login.noAccount': '还没有账户?',
'login.registerNow': '立即注册',
'login.enterPhone': '请输入手机号码',
'login.selectCountry': '选择国家/地区',
'login.confirm': '确认',
'login.serverSetting': '服务端设置',
'login.requiredUsername': '用户名不能为空',
'login.requiredPassword': '密码不能为空',
'login.requiredCaptcha': '验证码不能为空',
'login.requiredMobile': '请输入正确的手机号码',
'login.captchaError': '图形验证码错误',
// 注册页面
'register.pageTitle': '注册',
'register.createAccount': '创建账户',
'register.enterUsername': '请输入用户名',
'register.enterPassword': '请输入密码',
'register.confirmPassword': '请确认密码',
'register.enterPhone': '请输入手机号码',
'register.enterCode': '请输入验证码',
'register.getCode': '获取验证码',
'register.agreeTerms': '我已阅读并同意',
'register.terms': '《用户协议》',
'register.privacy': '《隐私政策》',
'register.registerButton': '注册',
'register.registering': '注册中...',
'register.haveAccount': '已有账户?',
'register.loginNow': '立即登录',
'register.selectCountry': '选择国家/地区',
'register.confirm': '确认',
'register.captchaSendSuccess': '验证码发送成功',
// 首页
'home.pageTitle': '首页',
'home.createAgent': '创建智能体',
'home.agentName': '智能体',
'home.modelInfo': '模型信息',
'home.lastActive': '最近活跃',
'home.greeting': '你好,小智',
'home.subtitle': '让我们度过',
'home.wonderfulDay': '美好的一天!',
'home.emptyState': '暂无智能体',
'home.deviceManagement': '设备管理',
'home.lastConversation': '最近对话:',
'home.delete': '删除',
'home.createFirstAgent': '点击右下角 + 号创建您的第一个智能体',
'home.dialogTitle': '创建智能体',
'home.inputPlaceholder': '例如:客服助手、语音助理、知识问答',
'home.createError': '请输入智能体名称',
'home.createNow': '立即创建',
'home.justNow': '刚刚',
'home.minutesAgo': '分钟前',
'home.hoursAgo': '小时前',
'home.daysAgo': '天前',
// Agent页面
'agent.pageTitle': '智能体',
'agent.roleConfig': '角色配置',
'agent.deviceManagement': '设备管理',
'agent.chatHistory': '聊天记录',
'agent.voiceprintManagement': '声纹管理',
'agent.editTitle': '编辑智能体',
'agent.toolsTitle': '编辑功能',
'agent.voiceActivityDetection': '语音活动检测',
'agent.speechRecognition': '语音识别',
'agent.largeLanguageModel': '大语言模型',
'agent.save': '保存',
'agent.cancel': '取消',
// Agent编辑页面
'agent.basicInfo': '基础信息',
'agent.agentName': '助手昵称',
'agent.inputAgentName': '请输入助手昵称',
'agent.roleMode': '角色模式',
'agent.roleDescription': '角色介绍',
'agent.inputRoleDescription': '请输入角色介绍',
'agent.modelConfig': '模型配置',
'agent.vad': '语音活动检测',
'agent.asr': '语音识别',
'agent.llm': '大语言模型',
'agent.vllm': '视觉大模型',
'agent.intent': '意图识别',
'agent.memory': '记忆',
'agent.voiceSettings': '语音设置',
'agent.tts': '语音合成',
'agent.voiceprint': '角色音色',
'agent.plugins': '插件',
'agent.editFunctions': '编辑功能',
'agent.historyMemory': '历史记忆',
'agent.memoryContent': '记忆内容',
'agent.saving': '保存中...',
'agent.saveSuccess': '保存成功',
'agent.saveFail': '保存失败',
'agent.loadFail': '加载失败',
'agent.pleaseInputAgentName': '请输入智能体名称',
'agent.pleaseInputRoleDescription': '请输入角色介绍',
'agent.pleaseSelect': '请选择',
// 聊天历史页面
'chatHistory.getChatSessions': '获取聊天会话列表',
'chatHistory.noSelectedAgent': '没有选中的智能体',
'chatHistory.getChatSessionsFailed': '获取聊天会话列表失败:',
'chatHistory.unknownTime': '未知时间',
'chatHistory.justNow': '刚刚',
'chatHistory.minutesAgo': '{minutes}分钟前',
'chatHistory.hoursAgo': '{hours}小时前',
'chatHistory.daysAgo': '{days}天前',
'chatHistory.conversationRecord': '对话记录',
'chatHistory.totalChats': '共 {count} 条对话',
'chatHistory.loading': '加载中...',
'chatHistory.noMoreData': '没有更多数据了',
'chatHistory.noChatRecords': '暂无聊天记录',
'chatHistory.chatRecordsDescription': '与智能体的对话记录会显示在这里',
// 聊天详情页面
'chatHistory.pageTitle': '聊天详情',
'chatHistory.assistantName': '智能助手',
'chatHistory.userName': '用户',
'chatHistory.aiAssistantName': 'AI助手',
'chatHistory.loadFailed': '获取聊天记录失败',
'chatHistory.parameterError': '页面参数错误',
'chatHistory.invalidAudioId': '音频ID无效',
'chatHistory.audioPlayFailed': '音频播放失败',
'chatHistory.playAudioFailed': '播放音频失败',
// 设备管理页面
'device.pageTitle': '设备管理',
'device.noDevices': '暂无设备',
'device.macAddress': 'MAC地址',
'device.firmwareVersion': '固件版本',
'device.lastConnected': '最近对话',
'device.otaUpdate': 'OTA升级',
'device.unbind': '解绑',
'device.confirmUnbind': '确定要解绑设备',
'device.bindDevice': '绑定新设备',
'device.deviceType': '设备类型',
'device.loading': '加载中...',
'device.neverConnected': '从未连接',
'device.justNow': '刚刚',
'device.minutesAgo': '{minutes}分钟前',
'device.hoursAgo': '{hours}小时前',
'device.daysAgo': '{days}天前',
'device.otaAutoUpdateEnabled': 'OTA自动升级已开启',
'device.otaAutoUpdateDisabled': 'OTA自动升级已关闭',
'device.operationFailed': '操作失败,请重试',
'device.deviceUnbound': '设备已解绑',
'device.unbindFailed': '解绑失败,请重试',
'device.unbindDevice': '解绑设备',
'device.confirmUnbindDevice': '确定要解绑设备 "{macAddress}" 吗?',
'device.cancel': '取消',
'device.noDevice': '暂无设备',
'device.pleaseSelectAgent': '请先选择智能体',
'device.deviceBindSuccess': '设备绑定成功!',
'device.bindFailed': '绑定失败,请检查验证码是否正确',
'device.enterDeviceCode': '请输入设备验证码',
'device.bindNow': '立即绑定',
'device.lastConnection': '最近对话',
'device.clickToBindFirstDevice': '点击右下角 + 号绑定您的第一个设备',
// 通用
'common.success': '成功',
'common.fail': '失败',
'common.loading': '加载中...',
'common.confirm': '确认',
'common.cancel': '取消',
'common.delete': '删除',
'common.edit': '编辑',
'common.add': '添加',
'common.pleaseSelect': '请选择',
'common.unknownError': '未知错误',
'common.networkError': '网络错误',
// SM2加密相关错误消息
'sm2.publicKeyNotConfigured': 'SM2公钥未配置,请联系管理员',
'sm2.encryptionFailed': '密码加密失败',
'sm2.keyGenerationFailed': '密钥对生成失败',
'sm2.invalidPublicKey': '无效的公钥格式',
'sm2.encryptionError': '加密过程中发生错误',
'sm2.publicKeyRetry': '正在重试获取公钥...',
'sm2.publicKeyRetryFailed': '公钥获取重试失败',
// Voiceprint page
'voiceprint.noSelectedAgent': '没有选中的智能体',
'voiceprint.pleaseSelectAgent': '请先选择智能体',
'voiceprint.fetchHistoryFailed': '获取对话记录失败',
'voiceprint.clickToSelectVector': '点击选择声纹向量',
'voiceprint.pleaseInputName': '请输入姓名',
'voiceprint.pleaseSelectVector': '请选择声纹向量',
'voiceprint.addSuccess': '添加成功',
'voiceprint.addFailed': '添加说话人失败',
'voiceprint.editSuccess': '编辑成功',
'voiceprint.editFailed': '编辑说话人失败',
'voiceprint.deleteConfirmMsg': '确定要删除这个说话人吗?',
'voiceprint.deleteConfirmTitle': '确认删除',
'voiceprint.deleteSuccess': '删除成功',
'voiceprint.loading': '加载中...',
'voiceprint.delete': '删除',
'voiceprint.emptyTitle': '暂无声纹数据',
'voiceprint.emptyDesc': '点击右下角 + 号添加您的第一个说话人',
'voiceprint.addSpeaker': '添加说话人',
'voiceprint.voiceVector': '声纹向量',
'voiceprint.name': '姓名',
'voiceprint.description': '描述',
'voiceprint.pleaseInputDescription': '请输入描述',
'voiceprint.cancel': '取消',
'voiceprint.save': '保存',
'voiceprint.editSpeaker': '编辑说话人',
'voiceprint.selectVector': '选择声纹向量',
'voiceprint.voiceprintInterfaceNotConfigured': '声纹接口未配置',
// 设置页面
'settings.pageTitle': '设置',
'settings.navigationTitle': '设置',
'settings.networkSettings': '网络设置',
'settings.serverApiUrl': '服务端接口地址',
'settings.validServerUrl': '请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)',
'settings.saveSettings': '保存设置',
'settings.resetDefault': '恢复默认',
'settings.restartApp': '重启应用',
'settings.restartNow': '立即重启',
'settings.restartLater': '稍后',
// 关于我们
'settings.aboutApp': '关于小智智控台',
'settings.aboutContent': '小智智控台\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server {version}',
'settings.restartSuccess': '已保存,可稍后手动重启应用',
'settings.serverUrlSavedAndCacheCleared': '服务端地址已保存,缓存已清除',
'settings.resetToDefaultAndCacheCleared': '已恢复默认设置,缓存已清除',
'settings.resetSuccess': '重置成功',
'settings.enterServerUrl': '请输入服务端地址',
'settings.clearCacheFailed': '清除缓存失败',
'settings.cacheManagement': '缓存管理',
'settings.totalCacheSize': '总缓存大小',
'settings.appDataSize': '应用数据总大小',
'settings.cacheClear': '缓存清理',
'settings.clearAllCache': '清空所有缓存数据',
'settings.clearCache': '清除缓存',
'settings.modifyWillClearCache': '修改将清除缓存',
'settings.appInfo': '应用信息',
'settings.aboutUs': '关于我们',
'settings.appVersion': '应用版本与团队信息',
'settings.confirmClear': '确认清除',
'settings.confirmClearMessage': '确定要清除所有缓存吗?这将删除所有数据包括登录状态,需要重新登录。',
'settings.cacheCleared': '缓存清除成功,即将跳转到登录页',
'settings.languageSettings': '语言设置',
'settings.language': '语言',
'settings.selectLanguage': '选择语言',
'settings.languageChanged': '语言切换成功',
// 消息提示
'message.loginSuccess': '登录成功!',
'message.loginFail': '登录失败',
'message.registerSuccess': '注册成功',
'message.registerFail': '注册失败',
'message.saveSuccess': '保存成功',
'message.saveFail': '保存失败',
'message.deleteSuccess': '删除成功',
'message.deleteFail': '删除失败',
'message.bindSuccess': '绑定成功',
'message.bindFail': '绑定失败',
'message.unbindSuccess': '解绑成功',
'message.unbindFail': '解绑失败',
'message.networkError': '网络错误,请检查网络连接',
'message.serverError': '服务器错误,请稍后再试',
'message.invalidAddress': '无效地址,请检查服务端是否启动或网络连接是否正常',
'message.languageChanged': '语言已切换',
'message.passwordError': '账号或密码错误',
'message.phoneRegistered': '此手机号码已经注册过',
// Agent工具页面
'agent.tools.pageTitle': 'Agent工具',
'agent.tools.unselected': '未选',
'agent.tools.selected': '已选',
'agent.tools.noMorePlugins': '暂无更多插件',
'agent.tools.pleaseSelectPlugin': '请选择插件功能',
'agent.tools.builtInPlugins': '内置插件',
'agent.tools.mcpAccessPoint': 'mcp接入点',
'agent.tools.copy': '复制',
'agent.tools.noTools': '暂无工具',
'agent.tools.parameterConfig': '参数配置',
'agent.tools.noParamsNeeded': '无需配置参数',
'agent.tools.pleaseInput': '请输入',
'agent.tools.inputOneItemPerLine': '每行输入一个项目',
'agent.tools.pleaseInputValidJson': '请输入有效的JSON格式',
'agent.tools.enableFunction': '启用功能',
'agent.tools.toggleFunction': '开启或关闭此功能',
'agent.tools.jsonFormatError': 'JSON格式错误',
'agent.tools.noMcpAddressToCopy': '暂无MCP地址可复制',
'agent.tools.mcpAddressCopied': 'MCP地址已复制到剪贴板',
'agent.tools.copyFailed': '复制失败,请重试',
'agent.tools.defaultValue': '默认值',
'agent.tools.notSelected': '未选',
'agent.tools.clickToConfigure': '点击配置',
'agent.tools.mcpEndpoint': 'MCP接入点',
'agent.tools.eachLineOneItem': '每行输入一个项目',
// 设备配置页面
'deviceConfig.pageTitle': '设备配置',
'deviceConfig.wifiConfig': 'WiFi配网',
'deviceConfig.ultrasonicConfig': '超声波配网',
'deviceConfig.selectConfigMethod': '选择配网方式',
'deviceConfig.networkConfig': '网络配置',
'deviceConfig.selectedNetwork': '选中网络',
'deviceConfig.signal': '信号',
'deviceConfig.openNetwork': '开放网络',
'deviceConfig.encryptedNetwork': '加密网络',
'deviceConfig.password': '密码',
'deviceConfig.pleaseEnterPassword': '请输入WiFi密码',
'deviceConfig.startConfig': '开始配网',
'deviceConfig.connectToXiaozhiHotspot': '请先连接xiaozhi热点 (xiaozhi-XXXXXX)',
'deviceConfig.detecting': '检测中...',
'deviceConfig.reDetect': '重新检测',
'deviceConfig.alreadyConnected': '已连接xiaozhi热点',
'deviceConfig.refreshStatus': '刷新状态',
'deviceConfig.wifiNetworks': 'WiFi网络',
'deviceConfig.selectWifiNetwork': '选择WiFi网络',
'deviceConfig.refreshScan': '刷新扫描',
'deviceConfig.noWifiNetworks': '暂无WiFi网络',
'deviceConfig.clickToRefreshScan': '请点击刷新扫描',
'deviceConfig.signalStrong': '信号强',
'deviceConfig.signalGood': '信号良好',
'deviceConfig.signalFair': '信号一般',
'deviceConfig.signalWeak': '信号弱',
'deviceConfig.channel': '频道',
'deviceConfig.about': '约',
'deviceConfig.seconds': '秒',
'deviceConfig.generating': '生成中...',
'deviceConfig.playing': '播放中...',
'deviceConfig.generateAndPlaySoundWave': '生成并播放声波',
'deviceConfig.playSoundWave': '播放声波',
'deviceConfig.stopPlaying': '停止播放',
'deviceConfig.autoLoopPlaySoundWave': '自动循环播放声波',
'deviceConfig.configAudioFile': '配网音频文件',
'deviceConfig.duration': '时长',
'deviceConfig.ultrasonicConfigInstructions': '超声波配网说明',
'deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered': '确保已选择WiFi网络并输入密码',
'deviceConfig.clickGenerateAndPlaySoundWave': '点击生成并播放声波,系统会将配网信息编码为音频',
'deviceConfig.bringPhoneCloseToXiaozhiDevice': '将手机靠近xiaozhi设备(距离1-2米)',
'deviceConfig.duringAudioPlaybackXiaozhiWillReceive': '音频播放时,xiaozhi会接收并解码配网信息',
'deviceConfig.afterConfigSuccessDeviceWillConnect': '配网成功后设备会自动连接WiFi网络',
'deviceConfig.usesAfskModulation': '使用AFSK调制技术,通过1800Hz和1500Hz频率传输数据',
'deviceConfig.ensureModeratePhoneVolume': '请确保手机音量适中,避免环境噪音干扰',
'deviceConfig.generatingUltrasonicConfigAudio': '生成超声波配网音频',
'deviceConfig.configData': '配网数据',
'deviceConfig.dataBytesLength': '数据字节长度',
'deviceConfig.bitStreamLength': '比特流长度',
'deviceConfig.base64Length': 'base64长度',
'deviceConfig.audioFileTooLarge': '音频文件过大,请缩短SSID或密码长度',
'deviceConfig.audioGenerationSuccess': '音频生成成功',
'deviceConfig.samplePoints': '采样点数',
'deviceConfig.soundWaveGenerationSuccess': '声波生成成功',
'deviceConfig.audioGenerationFailed': '音频生成失败',
'deviceConfig.soundWaveGenerationFailed': '声波生成失败',
'deviceConfig.pleaseGenerateAudioFirst': '请先生成音频',
'deviceConfig.startPlayingUltrasonicConfigAudio': '开始播放超声波配网音频',
'deviceConfig.ultrasonicAudioStartedPlaying': '超声波音频开始播放',
'deviceConfig.startPlayingConfigSoundWave': '开始播放配网声波',
'deviceConfig.ultrasonicAudioPlaybackEnded': '超声波音频播放结束',
'deviceConfig.audioPlaybackFailed': '音频播放失败',
'deviceConfig.audioResourceBusy': '音频资源繁忙,请稍后重试',
'deviceConfig.audioFormatNotSupported': '音频格式不支持,可能是data URI问题',
'deviceConfig.audioFileError': '音频文件错误',
'deviceConfig.cleaningUpAudioContext': '清理音频上下文',
'deviceConfig.cleaningUpAudioContextFailed': '清理音频上下文失败',
'deviceConfig.stoppedPlayingUltrasonicAudio': '停止播放超声波音频',
'deviceConfig.stoppedPlaying': '已停止播放',
'deviceConfig.configMethod': '配网方式',
'deviceConfig.enterWifiPassword': '请输入WiFi密码',
'deviceConfig.xiaozhi': 'xiaozhi',
'deviceConfig.connectXiaozhiHotspot': '请连接xiaozhi热点',
'deviceConfig.wifiScanResponse': 'WiFi扫描响应',
'deviceConfig.scanSuccess': '扫描成功',
'deviceConfig.networks': '个网络',
'deviceConfig.wifiScanFailed': 'WiFi扫描失败',
'deviceConfig.scanFailedCheckConnection': '扫描失败,请检查连接',
'deviceConfig.checking': '检查中',
'deviceConfig.reCheck': '重新检查',
'deviceConfig.connectedXiaozhiHotspot': '已连接xiaozhi热点',
'deviceConfig.wifiNetwork': 'WiFi网络',
'deviceConfig.scanning': '扫描中',
'deviceConfig.cancel': '取消',
'deviceConfig.clickRefreshScan': '请点击刷新扫描',
'deviceConfig.esp32ConnectionCheckFailed': 'ESP32连接检查失败',
'deviceConfig.startWifiConfig': '开始WiFi配网',
'deviceConfig.configSuccess': '配网成功',
'deviceConfig.deviceWillConnectTo': '设备将连接到',
'deviceConfig.deviceWillRestart': '设备将重启',
'deviceConfig.pleaseDisconnectXiaozhiHotspot': '请断开xiaozhi热点连接',
'deviceConfig.configFailed': '配网失败',
'deviceConfig.wifiConfigFailed': 'WiFi配网失败',
'deviceConfig.pleaseCheckNetworkConnection': '请检查网络连接',
'deviceConfig.startWifiConfigButton': '开始配网',
'deviceConfig.wifiConfigInstructions': 'WiFi配网说明',
'deviceConfig.phoneConnectXiaozhiHotspot': '手机连接xiaozhi热点',
'deviceConfig.selectTargetWifiNetwork': '选择目标WiFi网络',
'deviceConfig.enterWifiPasswordIfNeeded': '如有需要请输入WiFi密码',
'deviceConfig.clickStartConfigAndWait': '点击开始配网并等待',
'deviceConfig.afterConfigSuccessDeviceWillRestart': '配网成功后设备将自动重启',
'deviceConfig.audioPlaybackError': '音频播放错误',
'deviceConfig.playbackFailed': '播放失败',
}
+419
View File
@@ -0,0 +1,419 @@
// 繁體中文語言包
export default {
// TabBar
'tabBar.home': '首頁',
'tabBar.deviceConfig': '配網',
'tabBar.settings': '系統',
// 設置頁面標題
'settings.title': '設置',
// 登錄頁面
'login.pageTitle': '登錄',
'login.navigationTitle': '登錄',
'login.fetchConfigError': '獲取配置失敗:',
'login.selectLanguage': '選擇語言',
'login.selectLanguageTip': '简体',
'login.welcomeBack': '歡迎回來',
'login.pleaseLogin': '請登錄您的賬戶',
'login.enterUsername': '請輸入用戶名',
'login.enterPassword': '請輸入密碼',
'login.enterCaptcha': '請輸入驗證碼',
'login.loginButton': '登錄',
'login.loggingIn': '登錄中...',
'login.noAccount': '還沒有賬戶?',
'login.registerNow': '立即註冊',
'login.enterPhone': '請輸入手機號碼',
'login.selectCountry': '選擇國家/地區',
'login.confirm': '確認',
'login.serverSetting': '服務端設置',
'login.requiredUsername': '用戶名不能為空',
'login.requiredPassword': '密碼不能為空',
'login.requiredCaptcha': '驗證碼不能為空',
'login.requiredMobile': '請輸入正確的手機號碼',
'login.captchaError': '圖形驗證碼錯誤',
// 註冊頁面
'register.pageTitle': '註冊',
'register.createAccount': '創建賬戶',
'register.enterUsername': '請輸入用戶名',
'register.enterPassword': '請輸入密碼',
'register.confirmPassword': '請確認密碼',
'register.enterPhone': '請輸入手機號碼',
'register.enterCode': '請輸入驗證碼',
'register.getCode': '獲取驗證碼',
'register.agreeTerms': '我已閱讀並同意',
'register.terms': '《用戶協議》',
'register.privacy': '《隱私政策》',
'register.registerButton': '註冊',
'register.registering': '註冊中...',
'register.haveAccount': '已有賬戶?',
'register.loginNow': '立即登錄',
'register.selectCountry': '選擇國家/地區',
'register.confirm': '確認',
'register.captchaSendSuccess': '驗證碼發送成功',
// 首頁
'home.pageTitle': '首頁',
'home.createAgent': '創建智能體',
'home.agentName': '智能體',
'home.modelInfo': '模型信息',
'home.lastActive': '最近活躍',
'home.greeting': '你好,小智',
'home.subtitle': '讓我們度過',
'home.wonderfulDay': '美好的一天!',
'home.emptyState': '暫無智能體',
'home.deviceManagement': '設備管理',
'home.lastConversation': '最近對話:',
'home.delete': '刪除',
'home.createFirstAgent': '點擊右下角 + 號創建您的第一個智能體',
'home.dialogTitle': '創建智能體',
'home.inputPlaceholder': '例如:客服助手、語音助理、知識問答',
'home.createError': '請輸入智能體暱稱',
'home.createNow': '立即創建',
'home.justNow': '剛剛',
'home.minutesAgo': '分鐘前',
'home.hoursAgo': '小時前',
'home.daysAgo': '天前',
// Agent頁面
'agent.pageTitle': '智能體',
'agent.roleConfig': '角色配置',
'agent.deviceManagement': '設備管理',
'agent.chatHistory': '聊天記錄',
'agent.voiceprintManagement': '聲紋管理',
'agent.editTitle': '編輯智能體',
'agent.toolsTitle': '編輯功能',
'agent.voiceActivityDetection': '語音活動檢測',
'agent.speechRecognition': '語音識別',
'agent.largeLanguageModel': '大語言模型',
'agent.save': '保存',
'agent.cancel': '取消',
// Agent編輯頁面
'agent.basicInfo': '基礎資訊',
'agent.agentName': '助手暱稱',
'agent.inputAgentName': '請輸入助手暱稱',
'agent.roleMode': '角色模式',
'agent.roleDescription': '角色介紹',
'agent.inputRoleDescription': '請輸入角色介紹',
'agent.modelConfig': '模型配置',
'agent.vad': '語音活動檢測',
'agent.asr': '語音識別',
'agent.llm': '大語言模型',
'agent.vllm': '視覺大模型',
'agent.intent': '意圖識別',
'agent.memory': '記憶',
'agent.voiceSettings': '語音設置',
'agent.tts': '語音合成',
'agent.voiceprint': '角色音色',
'agent.plugins': '外掛',
'agent.editFunctions': '編輯功能',
'agent.historyMemory': '歷史記憶',
'agent.memoryContent': '記憶內容',
'agent.saving': '儲存中...',
'agent.saveSuccess': '儲存成功',
'agent.saveFail': '儲存失敗',
'agent.loadFail': '加載失敗',
'agent.pleaseInputAgentName': '請輸入助手暱稱',
'agent.pleaseInputRoleDescription': '請輸入角色介紹',
'agent.pleaseSelect': '請選擇',
// 聊天歷史頁面
'chatHistory.getChatSessions': '獲取聊天會話列表',
'chatHistory.noSelectedAgent': '沒有選中的智能體',
'chatHistory.getChatSessionsFailed': '獲取聊天會話列表失敗:',
'chatHistory.unknownTime': '未知時間',
'chatHistory.justNow': '剛剛',
'chatHistory.minutesAgo': '{minutes}分鐘前',
'chatHistory.hoursAgo': '{hours}小時前',
'chatHistory.daysAgo': '{days}天前',
'chatHistory.conversationRecord': '對話記錄',
'chatHistory.totalChats': '共 {count} 條對話',
'chatHistory.loading': '加載中...',
'chatHistory.noMoreData': '沒有更多數據了',
'chatHistory.noChatRecords': '暫無聊天記錄',
'chatHistory.chatRecordsDescription': '與智能體的對話記錄會顯示在這裡',
// 聊天詳情頁面
'chatHistory.pageTitle': '聊天詳情',
'chatHistory.assistantName': '智能助手',
'chatHistory.userName': '用戶',
'chatHistory.aiAssistantName': 'AI助手',
'chatHistory.loadFailed': '獲取聊天記錄失敗',
'chatHistory.parameterError': '頁面參數錯誤',
'chatHistory.invalidAudioId': '音頻ID無效',
'chatHistory.audioPlayFailed': '音頻播放失敗',
'chatHistory.playAudioFailed': '播放音頻失敗',
// 設備管理頁面
'device.pageTitle': '設備管理',
'device.noDevices': '暫無設備',
'device.macAddress': 'MAC地址',
'device.firmwareVersion': '固件版本',
'device.lastConnected': '最近對話',
'device.otaUpdate': 'OTA升級',
'device.unbind': '解除綁定',
'device.confirmUnbind': '確定要解綁設備',
'device.bindDevice': '綁定新設備',
'device.deviceType': '設備類型',
'device.loading': '加載中...',
'device.neverConnected': '從未連接',
'device.justNow': '剛剛',
'device.minutesAgo': '{minutes}分鐘前',
'device.hoursAgo': '{hours}小時前',
'device.daysAgo': '{days}天前',
'device.otaAutoUpdateEnabled': 'OTA自動升級已開啟',
'device.otaAutoUpdateDisabled': 'OTA自動升級已關閉',
'device.operationFailed': '操作失敗,請重試',
'device.deviceUnbound': '設備已解除綁定',
'device.unbindFailed': '解除綁定失敗,請重試',
'device.unbindDevice': '解除綁定設備',
'device.confirmUnbindDevice': '確定要解除綁定設備 "{macAddress}" 嗎?',
'device.cancel': '取消',
'device.noDevice': '暫無設備',
'device.pleaseSelectAgent': '請先選擇智能體',
'device.deviceBindSuccess': '設備綁定成功!',
'device.bindFailed': '綁定失敗,請檢查驗證碼是否正確',
'device.enterDeviceCode': '請輸入設備驗證碼',
'device.bindNow': '立即綁定',
'device.lastConnection': '最近對話',
'device.clickToBindFirstDevice': '點擊右下角 + 號綁定您的第一個設備',
// 通用
'common.success': '成功',
'common.fail': '失敗',
'common.loading': '加載中...',
'common.confirm': '確認',
'common.cancel': '取消',
'common.delete': '刪除',
'common.edit': '編輯',
'common.add': '添加',
'common.pleaseSelect': '請選擇',
'common.unknownError': '未知錯誤',
'common.networkError': '網路錯誤',
// SM2加密相關錯誤消息
'sm2.publicKeyNotConfigured': 'SM2公鑰未配置,請聯繫管理員',
'sm2.encryptionFailed': '密碼加密失敗',
'sm2.keyGenerationFailed': '金鑰對生成失敗',
'sm2.invalidPublicKey': '無效的公鑰格式',
'sm2.encryptionError': '加密過程中發生錯誤',
'sm2.publicKeyRetry': '正在重試獲取公鑰...',
'sm2.publicKeyRetryFailed': '公鑰獲取重試失敗',
// Voiceprint page
'voiceprint.noSelectedAgent': '沒有選中的智能體',
'voiceprint.pleaseSelectAgent': '請先選擇智能體',
'voiceprint.fetchHistoryFailed': '獲取對話記錄失敗',
'voiceprint.clickToSelectVector': '點擊選擇聲紋向量',
'voiceprint.pleaseInputName': '請輸入姓名',
'voiceprint.pleaseSelectVector': '請選擇聲紋向量',
'voiceprint.addSuccess': '添加成功',
'voiceprint.addFailed': '添加說話人失敗',
'voiceprint.editSuccess': '編輯成功',
'voiceprint.editFailed': '編輯說話人失敗',
'voiceprint.deleteConfirmMsg': '確定要刪除這個說話人嗎?',
'voiceprint.deleteConfirmTitle': '確認刪除',
'voiceprint.deleteSuccess': '刪除成功',
'voiceprint.loading': '加載中...',
'voiceprint.delete': '刪除',
'voiceprint.emptyTitle': '暫無聲紋數據',
'voiceprint.emptyDesc': '點擊右下角 + 號添加您的第一個說話人',
'voiceprint.addSpeaker': '添加說話人',
'voiceprint.voiceVector': '聲紋向量',
'voiceprint.name': '姓名',
'voiceprint.description': '描述',
'voiceprint.pleaseInputDescription': '請輸入描述',
'voiceprint.cancel': '取消',
'voiceprint.save': '保存',
'voiceprint.editSpeaker': '編輯說話人',
'voiceprint.selectVector': '選擇聲紋向量',
'voiceprint.voiceprintInterfaceNotConfigured': '聲紋接口未配置',
// 設置頁面
'settings.pageTitle': '設置',
'settings.navigationTitle': '設置',
'settings.networkSettings': '網絡設置',
'settings.serverApiUrl': '服務端接口地址',
'settings.validServerUrl': '請輸入有效的服務端地址(以 http 或 https 開頭,並以 /xiaozhi 結尾)',
'settings.saveSettings': '保存設置',
'settings.resetDefault': '恢復默認',
'settings.restartApp': '重啟應用',
'settings.restartNow': '立即重啟',
'settings.restartLater': '稍後',
// 關於我們
'settings.aboutApp': '關於小智智控台',
'settings.aboutContent': '小智智控台\n\n基於 Vue.js 3 + uni-app 構建的跨平台移動端管理應用,為小智ESP32智能硬體提供設備管理、智能體配置等功能。\n\n© 2025 xiaozhi-esp32-server 0.8.3',
'settings.restartSuccess': '已保存,可稍後手動重啟應用',
'settings.serverUrlSavedAndCacheCleared': '服務端地址已保存,緩存已清除',
'settings.resetToDefaultAndCacheCleared': '已恢復默認設置,緩存已清除',
'settings.resetSuccess': '重置成功',
'settings.enterServerUrl': '請輸入服務端地址',
'settings.clearCacheFailed': '清除緩存失敗',
'settings.cacheManagement': '緩存管理',
'settings.totalCacheSize': '總緩存大小',
'settings.appDataSize': '應用數據總大小',
'settings.cacheClear': '緩存清理',
'settings.clearAllCache': '清空所有緩存數據',
'settings.clearCache': '清除緩存',
'settings.modifyWillClearCache': '修改將清除緩存',
'settings.appInfo': '應用信息',
'settings.aboutUs': '關於我們',
'settings.appVersion': '應用版本與團隊信息',
'settings.confirmClear': '確認清除',
'settings.confirmClearMessage': '確定要清除所有緩存嗎?這將刪除所有數據包括登錄狀態,需要重新登錄。',
'settings.cacheCleared': '緩存清除成功,即將跳轉到登錄頁',
'settings.languageSettings': '語言設置',
'settings.language': '語言',
'settings.selectLanguage': '選擇語言',
'settings.languageChanged': '語言切換成功',
// 消息提示
'message.loginSuccess': '登錄成功!',
'message.loginFail': '登錄失敗',
'message.registerSuccess': '註冊成功',
'message.registerFail': '註冊失敗',
'message.saveSuccess': '保存成功',
'message.saveFail': '保存失敗',
'message.deleteSuccess': '刪除成功',
'message.deleteFail': '刪除失敗',
'message.bindSuccess': '綁定成功',
'message.bindFail': '綁定失敗',
'message.unbindSuccess': '解除綁定成功',
'message.unbindFail': '解除綁定失敗',
'message.networkError': '網絡錯誤,請檢查網絡連接',
'message.serverError': '服務器錯誤,請稍後再試',
'message.invalidAddress': '無效地址,請檢查服務端是否啟動或網絡連接是否正常',
'message.languageChanged': '語言已切換',
'message.passwordError': '帳號或密碼錯誤',
'message.phoneRegistered': '此手機號已經註冊過',
// Agent工具頁面
'agent.tools.pageTitle': 'Agent工具',
'agent.tools.unselected': '未選',
'agent.tools.selected': '已選',
'agent.tools.noMorePlugins': '暫無更多插件',
'agent.tools.pleaseSelectPlugin': '請選擇插件功能',
'agent.tools.builtInPlugins': '內置插件',
'agent.tools.mcpAccessPoint': 'mcp接入點',
'agent.tools.copy': '複製',
'agent.tools.noTools': '暫無工具',
'agent.tools.parameterConfig': '參數配置',
'agent.tools.noParamsNeeded': '無需配置參數',
'agent.tools.pleaseInput': '請輸入',
'agent.tools.inputOneItemPerLine': '每行輸入一個項目',
'agent.tools.pleaseInputValidJson': '請輸入有效的JSON格式',
'agent.tools.enableFunction': '啟用功能',
'agent.tools.toggleFunction': '開啟或關閉此功能',
'agent.tools.jsonFormatError': 'JSON格式錯誤',
'agent.tools.noMcpAddressToCopy': '暫無MCP地址可複製',
'agent.tools.mcpAddressCopied': 'MCP地址已複製到剪貼簿',
'agent.tools.copyFailed': '複製失敗,請重試',
'agent.tools.defaultValue': '默認值',
'agent.tools.notSelected': '未選',
'agent.tools.clickToConfigure': '點擊配置',
'agent.tools.mcpEndpoint': 'MCP接入點',
'agent.tools.eachLineOneItem': '每行輸入一個項目',
// 設備配置頁面
'deviceConfig.pageTitle': '設備配置',
'deviceConfig.wifiConfig': 'WiFi配網',
'deviceConfig.ultrasonicConfig': '超聲波配網',
'deviceConfig.selectConfigMethod': '選擇配網方式',
'deviceConfig.networkConfig': '網絡配置',
'deviceConfig.selectedNetwork': '選中網絡',
'deviceConfig.signal': '信號',
'deviceConfig.openNetwork': '開放網絡',
'deviceConfig.encryptedNetwork': '加密網絡',
'deviceConfig.password': '密碼',
'deviceConfig.pleaseEnterPassword': '請輸入WiFi密碼',
'deviceConfig.startConfig': '開始配網',
'deviceConfig.connectToXiaozhiHotspot': '請先連接xiaozhi熱點 (xiaozhi-XXXXXX)',
'deviceConfig.detecting': '檢測中...',
'deviceConfig.reDetect': '重新檢測',
'deviceConfig.alreadyConnected': '已連接xiaozhi熱點',
'deviceConfig.refreshStatus': '刷新狀態',
'deviceConfig.wifiNetworks': 'WiFi網絡',
'deviceConfig.selectWifiNetwork': '選擇WiFi網絡',
'deviceConfig.refreshScan': '刷新掃描',
'deviceConfig.noWifiNetworks': '暫無WiFi網絡',
'deviceConfig.clickToRefreshScan': '請點擊刷新掃描',
'deviceConfig.signalStrong': '信號強',
'deviceConfig.signalGood': '信號良好',
'deviceConfig.signalFair': '信號一般',
'deviceConfig.signalWeak': '信號弱',
'deviceConfig.channel': '頻道',
'deviceConfig.about': '約',
'deviceConfig.seconds': '秒',
'deviceConfig.generating': '生成中...',
'deviceConfig.playing': '播放中...',
'deviceConfig.generateAndPlaySoundWave': '生成並播放聲波',
'deviceConfig.playSoundWave': '播放聲波',
'deviceConfig.stopPlaying': '停止播放',
'deviceConfig.autoLoopPlaySoundWave': '自動循環播放聲波',
'deviceConfig.configAudioFile': '配網音頻文件',
'deviceConfig.duration': '時長',
'deviceConfig.ultrasonicConfigInstructions': '超聲波配網說明',
'deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered': '確保已選擇WiFi網絡並輸入密碼',
'deviceConfig.clickGenerateAndPlaySoundWave': '點擊生成並播放聲波,系統會將配網信息編碼為音頻',
'deviceConfig.bringPhoneCloseToXiaozhiDevice': '將手機靠近xiaozhi設備(距離1-2米)',
'deviceConfig.duringAudioPlaybackXiaozhiWillReceive': '音頻播放時,xiaozhi會接收並解碼配網信息',
'deviceConfig.afterConfigSuccessDeviceWillConnect': '配網成功後設備會自動連接WiFi網絡',
'deviceConfig.usesAfskModulation': '使用AFSK調制技術,通過1800Hz和1500Hz頻率傳輸數據',
'deviceConfig.ensureModeratePhoneVolume': '請確保手機音量適中,避免環境噪音干擾',
'deviceConfig.generatingUltrasonicConfigAudio': '生成超聲波配網音頻',
'deviceConfig.configData': '配網數據',
'deviceConfig.dataBytesLength': '數據字節長度',
'deviceConfig.bitStreamLength': '比特流長度',
'deviceConfig.base64Length': 'base64長度',
'deviceConfig.audioFileTooLarge': '音頻文件過大,請縮短SSID或密碼長度',
'deviceConfig.audioGenerationSuccess': '音頻生成成功',
'deviceConfig.samplePoints': '採樣點數',
'deviceConfig.soundWaveGenerationSuccess': '聲波生成成功',
'deviceConfig.audioGenerationFailed': '音頻生成失敗',
'deviceConfig.soundWaveGenerationFailed': '聲波生成失敗',
'deviceConfig.pleaseGenerateAudioFirst': '請先生成音頻',
'deviceConfig.startPlayingUltrasonicConfigAudio': '開始播放超聲波配網音頻',
'deviceConfig.ultrasonicAudioStartedPlaying': '超聲波音頻開始播放',
'deviceConfig.startPlayingConfigSoundWave': '開始播放配網聲波',
'deviceConfig.ultrasonicAudioPlaybackEnded': '超聲波音頻播放結束',
'deviceConfig.audioPlaybackFailed': '音頻播放失敗',
'deviceConfig.audioResourceBusy': '音頻資源繁忙,請稍後重試',
'deviceConfig.audioFormatNotSupported': '音頻格式不支援,可能是data URI問題',
'deviceConfig.audioFileError': '音頻文件錯誤',
'deviceConfig.cleaningUpAudioContext': '清理音頻上下文',
'deviceConfig.cleaningUpAudioContextFailed': '清理音頻上下文失敗',
'deviceConfig.stoppedPlayingUltrasonicAudio': '停止播放超聲波音頻',
'deviceConfig.stoppedPlaying': '已停止播放',
'deviceConfig.configMethod': '配網方式',
'deviceConfig.enterWifiPassword': '請輸入WiFi密碼',
'deviceConfig.xiaozhi': 'xiaozhi',
'deviceConfig.connectXiaozhiHotspot': '請連接xiaozhi熱點',
'deviceConfig.wifiScanResponse': 'WiFi掃描響應',
'deviceConfig.scanSuccess': '掃描成功',
'deviceConfig.networks': '個網絡',
'deviceConfig.wifiScanFailed': 'WiFi掃描失敗',
'deviceConfig.scanFailedCheckConnection': '掃描失敗,請檢查連接',
'deviceConfig.checking': '檢查中',
'deviceConfig.reCheck': '重新檢查',
'deviceConfig.connectedXiaozhiHotspot': '已連接xiaozhi熱點',
'deviceConfig.wifiNetwork': 'WiFi網絡',
'deviceConfig.scanning': '掃描中',
'deviceConfig.cancel': '取消',
'deviceConfig.clickRefreshScan': '請點擊刷新掃描',
'deviceConfig.esp32ConnectionCheckFailed': 'ESP32連接檢查失敗',
'deviceConfig.startWifiConfig': '開始WiFi配網',
'deviceConfig.configSuccess': '配網成功',
'deviceConfig.deviceWillConnectTo': '設備將連接到',
'deviceConfig.deviceWillRestart': '設備將重啟',
'deviceConfig.pleaseDisconnectXiaozhiHotspot': '請斷開xiaozhi熱點連接',
'deviceConfig.configFailed': '配網失敗',
'deviceConfig.wifiConfigFailed': 'WiFi配網失敗',
'deviceConfig.pleaseCheckNetworkConnection': '請檢查網絡連接',
'deviceConfig.startWifiConfigButton': '開始配網',
'deviceConfig.wifiConfigInstructions': 'WiFi配網說明',
'deviceConfig.phoneConnectXiaozhiHotspot': '手機連接xiaozhi熱點',
'deviceConfig.selectTargetWifiNetwork': '選擇目標WiFi網絡',
'deviceConfig.enterWifiPasswordIfNeeded': '如有需要請輸入WiFi密碼',
'deviceConfig.clickStartConfigAndWait': '點擊開始配網並等待',
'deviceConfig.afterConfigSuccessDeviceWillRestart': '配網成功後設備將自動重啟',
'deviceConfig.audioPlaybackError': '音頻播放錯誤',
'deviceConfig.playbackFailed': '播放失敗',
}
+7
View File
@@ -7,12 +7,19 @@ import store from './store'
import '@/style/index.scss' import '@/style/index.scss'
import 'virtual:uno.css' import 'virtual:uno.css'
// 导入国际化相关功能
import { initI18n } from './i18n'
import { useLangStore } from './store/lang'
export function createApp() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
app.use(store) app.use(store)
app.use(routeInterceptor) app.use(routeInterceptor)
app.use(VueQueryPlugin) app.use(VueQueryPlugin)
// 初始化国际化
initI18n()
return { return {
app, app,
} }
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"globalStyle": { "globalStyle": {
"navigationStyle": "default", "navigationStyle": "default",
"navigationBarTitleText": "小智智控台", "navigationBarTitleText": "智控台",
"navigationBarBackgroundColor": "#f8f8f8", "navigationBarBackgroundColor": "#f8f8f8",
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"backgroundColor": "#FFFFFF" "backgroundColor": "#FFFFFF"
@@ -104,7 +104,7 @@
"path": "pages/device-config/index", "path": "pages/device-config/index",
"type": "page", "type": "page",
"style": { "style": {
"navigationBarTitleText": "设备配", "navigationBarTitleText": "设备配",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
@@ -114,7 +114,7 @@
"layout": "default", "layout": "default",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "登陆" "navigationBarTitleText": "Login"
} }
}, },
{ {
@@ -155,4 +155,4 @@
] ]
} }
] ]
} }
+40 -38
View File
@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { getAgentDetail, getModelOptions, getPluginFunctions, getRoleTemplates, getTTSVoices, updateAgent } from '@/api/agent/agent' import { getAgentDetail, getModelOptions, getPluginFunctions, getRoleTemplates, getTTSVoices, updateAgent } from '@/api/agent/agent'
import { usePluginStore } from '@/store' import { usePluginStore } from '@/store'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { t } from '@/i18n'
defineOptions({ defineOptions({
name: 'AgentEdit', name: 'AgentEdit',
@@ -37,14 +38,15 @@ const formData = ref<Partial<AgentDetail>>({
// 显示名称数据 // 显示名称数据
const displayNames = ref({ const displayNames = ref({
vad: '请选择', // 显示名称数据
asr: '请选择', vad: t('agent.pleaseSelect'),
llm: '请选择', asr: t('agent.pleaseSelect'),
vllm: '请选择', llm: t('agent.pleaseSelect'),
intent: '请选择', vllm: t('agent.pleaseSelect'),
memory: '请选择', intent: t('agent.pleaseSelect'),
tts: '请选择', memory: t('agent.pleaseSelect'),
voiceprint: '请选择', tts: t('agent.pleaseSelect'),
voiceprint: t('agent.pleaseSelect'),
}) })
// 角色模板数据 // 角色模板数据
@@ -143,7 +145,7 @@ async function loadAgentDetail() {
} }
catch (error) { catch (error) {
console.error('加载智能体详情失败:', error) console.error('加载智能体详情失败:', error)
toast.error('加载失败') toast.error(t('agent.loadFail'))
} }
finally { finally {
loading.value = false loading.value = false
@@ -358,12 +360,12 @@ function getModelDisplayName(modelType: string, modelId: string) {
// 保存智能体 // 保存智能体
async function saveAgent() { async function saveAgent() {
if (!formData.value.agentName?.trim()) { if (!formData.value.agentName?.trim()) {
toast.warning('请输入助手昵称') toast.warning(t('agent.pleaseInputAgentName'))
return return
} }
if (!formData.value.systemPrompt?.trim()) { if (!formData.value.systemPrompt?.trim()) {
toast.warning('请输入角色介绍') toast.warning(t('agent.pleaseInputRoleDescription'))
return return
} }
@@ -371,11 +373,11 @@ async function saveAgent() {
saving.value = true saving.value = true
await updateAgent(agentId.value, formData.value) await updateAgent(agentId.value, formData.value)
toast.success('保存成功') toast.success(t('agent.saveSuccess'))
} }
catch (error) { catch (error) {
console.error('保存失败:', error) console.error('保存失败:', error)
toast.error('保存失败') toast.error(t('agent.saveFail'))
} }
finally { finally {
saving.value = false saving.value = false
@@ -441,10 +443,10 @@ onMounted(async () => {
<template> <template>
<view class="bg-[#f5f7fb] px-[20rpx]"> <view class="bg-[#f5f7fb] px-[20rpx]">
<!-- 基础信息标题 --> <!--// 基础信息标题
<view class="pb-[20rpx] first:pt-[20rpx]"> <view class="pb-[20rpx] first:pt-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
基础信息 {{ t('agent.basicInfo') }}
</text> </text>
</view> </view>
@@ -452,20 +454,20 @@ onMounted(async () => {
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);"> <view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<view class="mb-[24rpx] last:mb-0"> <view class="mb-[24rpx] last:mb-0">
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
助手昵称 {{ t('agent.agentName') }}
</text> </text>
<input <input
v-model="formData.agentName" v-model="formData.agentName"
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text" type="text"
placeholder="请输入助手昵称" :placeholder="t('agent.inputAgentName')"
> >
</view> </view>
<view class="mb-[24rpx] last:mb-0"> <view class="mb-[24rpx] last:mb-0">
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
角色模式 {{ t('agent.roleMode') }}
</text> </text>
<view class="mt-0 flex flex-wrap gap-[12rpx]"> <view class="mt-0 flex flex-wrap gap-[12rpx]">
<view <view
v-for="template in roleTemplates" v-for="template in roleTemplates"
@@ -483,12 +485,12 @@ onMounted(async () => {
<view class="mb-[24rpx] last:mb-0"> <view class="mb-[24rpx] last:mb-0">
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
角色介绍 {{ t('agent.roleDescription') }}
</text> </text>
<textarea <textarea
v-model="formData.systemPrompt" v-model="formData.systemPrompt"
:maxlength="2000" :maxlength="2000"
placeholder="请输入角色介绍" :placeholder="t('agent.inputRoleDescription')"
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
/> />
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]"> <view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
@@ -500,7 +502,7 @@ onMounted(async () => {
<!-- 模型配置标题 --> <!-- 模型配置标题 -->
<view class="pb-[20rpx]"> <view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
模型配置 {{ t('agent.modelConfig') }}
</text> </text>
</view> </view>
@@ -509,7 +511,7 @@ onMounted(async () => {
<view class="flex flex-col gap-[16rpx]"> <view class="flex flex-col gap-[16rpx]">
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vad')"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vad')">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
语音活动检测 {{ t('agent.vad') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.vad }} {{ displayNames.vad }}
@@ -519,7 +521,7 @@ onMounted(async () => {
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('asr')"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('asr')">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
语音识别 {{ t('agent.asr') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.asr }} {{ displayNames.asr }}
@@ -529,7 +531,7 @@ onMounted(async () => {
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('llm')"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('llm')">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
大语言模型 {{ t('agent.llm') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.llm }} {{ displayNames.llm }}
@@ -539,7 +541,7 @@ onMounted(async () => {
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vllm')"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vllm')">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
视觉大模型 {{ t('agent.vllm') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.vllm }} {{ displayNames.vllm }}
@@ -549,7 +551,7 @@ onMounted(async () => {
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('intent')"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('intent')">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
意图识别 {{ t('agent.intent') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.intent }} {{ displayNames.intent }}
@@ -559,7 +561,7 @@ onMounted(async () => {
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('memory')"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('memory')">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
记忆 {{ t('agent.memory') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.memory }} {{ displayNames.memory }}
@@ -572,7 +574,7 @@ onMounted(async () => {
<!-- 语音设置标题 --> <!-- 语音设置标题 -->
<view class="pb-[20rpx]"> <view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
语音设置 {{ t('agent.voiceSettings') }}
</text> </text>
</view> </view>
@@ -581,7 +583,7 @@ onMounted(async () => {
<view class="flex flex-col gap-[16rpx]"> <view class="flex flex-col gap-[16rpx]">
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('tts')"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('tts')">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
语音合成 {{ t('agent.tts') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.tts }} {{ displayNames.tts }}
@@ -591,7 +593,7 @@ onMounted(async () => {
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('voiceprint')"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('voiceprint')">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
角色音色 {{ t('agent.voiceprint') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.voiceprint }} {{ displayNames.voiceprint }}
@@ -601,10 +603,10 @@ onMounted(async () => {
<view class="flex items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx]"> <view class="flex items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx]">
<view class="text-[28rpx] text-[#232338] font-medium"> <view class="text-[28rpx] text-[#232338] font-medium">
插件 {{ t('agent.plugins') }}
</view> </view>
<view class="cursor-pointer rounded-[20rpx] bg-[rgba(51,108,255,0.1)] px-[24rpx] py-[12rpx] text-[24rpx] text-[#336cff] transition-all duration-300 active:bg-[#336cff] active:text-white" @click="handleTools"> <view class="cursor-pointer rounded-[20rpx] bg-[rgba(51,108,255,0.1)] px-[24rpx] py-[12rpx] text-[24rpx] text-[#336cff] transition-all duration-300 active:bg-[#336cff] active:text-white" @click="handleTools">
<text>编辑功能</text> <text>{{ t('agent.editFunctions') }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -613,7 +615,7 @@ onMounted(async () => {
<!-- 记忆历史标题 --> <!-- 记忆历史标题 -->
<view class="pb-[20rpx]"> <view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
历史记忆 {{ t('agent.historyMemory') }}
</text> </text>
</view> </view>
@@ -622,7 +624,7 @@ onMounted(async () => {
<view class="mb-[24rpx] last:mb-0"> <view class="mb-[24rpx] last:mb-0">
<textarea <textarea
v-model="formData.summaryMemory" v-model="formData.summaryMemory"
placeholder="记忆内容" :placeholder="t('agent.memoryContent')"
disabled disabled
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f0f0f0] p-[20rpx] text-[26rpx] text-[#65686f] leading-[1.6] opacity-80 outline-none" class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f0f0f0] p-[20rpx] text-[26rpx] text-[#65686f] leading-[1.6] opacity-80 outline-none"
/> />
@@ -638,7 +640,7 @@ onMounted(async () => {
custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]" custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
@click="saveAgent" @click="saveAgent"
> >
{{ saving ? '保存中...' : '保存' }} {{ saving ? t('agent.saving') : t('agent.save') }}
</wd-button> </wd-button>
</view> </view>
<!-- 模型选择器 --> <!-- 模型选择器 -->
@@ -11,6 +11,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { t } from '@/i18n'
import CustomTabs from '@/components/custom-tabs/index.vue' import CustomTabs from '@/components/custom-tabs/index.vue'
import ChatHistory from '@/pages/chat-history/index.vue' import ChatHistory from '@/pages/chat-history/index.vue'
import DeviceManagement from '@/pages/device/index.vue' import DeviceManagement from '@/pages/device/index.vue'
@@ -42,7 +43,6 @@ systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets safeAreaInsets = systemInfo.safeAreaInsets
// #endif // #endif
// 智能体ID // 智能体ID
const currentAgentId = ref('default') const currentAgentId = ref('default')
@@ -65,25 +65,25 @@ const voiceprintRef = ref()
// Tab 配置 // Tab 配置
const tabList = [ const tabList = [
{ {
label: '角色配置', label: t('agent.roleConfig'),
value: 'agent-config', value: 'agent-config',
icon: '/static/tabbar/robot.png', icon: '/static/tabbar/robot.png',
activeIcon: '/static/tabbar/robot_activate.png', activeIcon: '/static/tabbar/robot_activate.png',
}, },
{ {
label: '设备管理', label: t('agent.deviceManagement'),
value: 'device-management', value: 'device-management',
icon: '/static/tabbar/device.png', icon: '/static/tabbar/device.png',
activeIcon: '/static/tabbar/device_activate.png', activeIcon: '/static/tabbar/device_activate.png',
}, },
{ {
label: '聊天记录', label: t('agent.chatHistory'),
value: 'chat-history', value: 'chat-history',
icon: '/static/tabbar/chat.png', icon: '/static/tabbar/chat.png',
activeIcon: '/static/tabbar/chat_activate.png', activeIcon: '/static/tabbar/chat_activate.png',
}, },
{ {
label: '声纹管理', label: t('agent.voiceprintManagement'),
value: 'voiceprint-management', value: 'voiceprint-management',
icon: '/static/tabbar/microphone.png', icon: '/static/tabbar/microphone.png',
activeIcon: '/static/tabbar/microphone_activate.png', activeIcon: '/static/tabbar/microphone_activate.png',
@@ -160,7 +160,7 @@ onMounted(async () => {
<template> <template>
<view class="h-screen flex flex-col bg-[#f5f7fb]"> <view class="h-screen flex flex-col bg-[#f5f7fb]">
<!-- 导航栏 --> <!-- 导航栏 -->
<wd-navbar title="智能体" safe-area-inset-top> <wd-navbar :title="t('agent.pageTitle')" safe-area-inset-top>
<template #left> <template #left>
<wd-icon name="arrow-left" size="18" @click="goBack" /> <wd-icon name="arrow-left" size="18" @click="goBack" />
</template> </template>
+35 -34
View File
@@ -3,8 +3,8 @@
"layout": "default", "layout": "default",
"style": { "style": {
"navigationBarTitleText": "编辑功能", "navigationBarTitleText": "编辑功能",
"navigationStyle": "custom", "navigationStyle": "custom"
}, }
} }
</route> </route>
@@ -12,12 +12,13 @@
import { useMessage } from 'wot-design-uni' import { useMessage } from 'wot-design-uni'
import { getMcpAddress, getMcpTools } from '@/api/agent/agent' import { getMcpAddress, getMcpTools } from '@/api/agent/agent'
import { usePluginStore } from '@/store' import { usePluginStore } from '@/store'
import { t } from '@/i18n'
const message = useMessage() const message = useMessage()
const pluginStore = usePluginStore() const pluginStore = usePluginStore()
const segmentedList = ref<string[]>(['未选', '已选']) const segmentedList = ref<string[]>([t('agent.tools.notSelected'), t('agent.tools.selected')])
const currentSegmented = ref('未选') const currentSegmented = ref(t('agent.tools.notSelected'))
const notSelectedList = ref<any[]>([]) const notSelectedList = ref<any[]>([])
const selectedList = ref<any[]>([]) const selectedList = ref<any[]>([])
@@ -191,7 +192,7 @@ function handleJsonChange(key: string, value: string, field: any) {
} }
} }
catch { catch {
message.alert('JSON格式错误') message.alert(t('agent.tools.jsonFormatError'))
} }
} }
@@ -220,7 +221,7 @@ function goBack() {
// 复制MCP地址 // 复制MCP地址
function copyMcpAddress() { function copyMcpAddress() {
if (!mcpAddress.value) { if (!mcpAddress.value) {
message.alert('暂无MCP地址可复制') message.alert(t('agent.tools.noMcpAddressToCopy'))
return return
} }
@@ -228,11 +229,11 @@ function copyMcpAddress() {
data: mcpAddress.value, data: mcpAddress.value,
showToast: false, showToast: false,
success: () => { success: () => {
message.alert('MCP地址已复制到剪贴板') message.alert(t('agent.tools.mcpAddressCopied'))
}, },
fail: () => { fail: () => {
message.alert('复制失败,请重试') message.alert(t('agent.tools.copyFailed'))
}, },
}) })
} }
@@ -248,7 +249,7 @@ function getFieldDisplayValue(field: any, value: any) {
function getFieldRemark(field: any) { function getFieldRemark(field: any) {
let description = field.label || '' let description = field.label || ''
if (field.default) { if (field.default) {
description += `默认值${field.default}` description += `${t('agent.tools.defaultValue')}${field.default}`
} }
return description return description
} }
@@ -284,7 +285,7 @@ onMounted(async () => {
<!-- 内置插件区域 --> <!-- 内置插件区域 -->
<view class="mt-[20rpx] flex flex-1 flex-col"> <view class="mt-[20rpx] flex flex-1 flex-col">
<view class="text-[32rpx] text-[#333] font-medium"> <view class="text-[32rpx] text-[#333] font-medium">
内置插件 {{ t('agent.tools.builtInPlugins') }}
</view> </view>
<view <view
class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]" class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]"
@@ -299,7 +300,7 @@ onMounted(async () => {
<view class="mt-[20rpx] flex-1 overflow-hidden"> <view class="mt-[20rpx] flex-1 overflow-hidden">
<!-- 未选插件 --> <!-- 未选插件 -->
<scroll-view <scroll-view
v-if="currentSegmented === '未选'" v-if="currentSegmented === t('agent.tools.notSelected')"
class="max-h-[600rpx] bg-transparent" class="max-h-[600rpx] bg-transparent"
scroll-y scroll-y
> >
@@ -307,7 +308,7 @@ onMounted(async () => {
v-if="notSelectedList.length === 0" v-if="notSelectedList.length === 0"
class="h-[400rpx] flex items-center justify-center" class="h-[400rpx] flex items-center justify-center"
> >
<wd-status-tip image="content" tip="暂无更多插件" /> <wd-status-tip image="content" tip="{{ t('agent.tools.noMorePlugins') }}" />
</view> </view>
<view v-else class="p-[20rpx] space-y-[20rpx]"> <view v-else class="p-[20rpx] space-y-[20rpx]">
<view <view
@@ -343,7 +344,7 @@ onMounted(async () => {
v-if="selectedList.length === 0" v-if="selectedList.length === 0"
class="h-[400rpx] flex items-center justify-center" class="h-[400rpx] flex items-center justify-center"
> >
<wd-status-tip image="content" tip="请选择插件功能" /> <wd-status-tip image="content" tip="{{ t('agent.tools.pleaseSelectPlugin') }}" />
</view> </view>
<view v-else class="p-[20rpx] space-y-[20rpx]"> <view v-else class="p-[20rpx] space-y-[20rpx]">
<view <view
@@ -359,7 +360,7 @@ onMounted(async () => {
{{ func.name }} {{ func.name }}
</view> </view>
<view class="text-[24rpx] text-[#1677ff]"> <view class="text-[24rpx] text-[#1677ff]">
点击配置参数 {{ t('agent.tools.clickToConfigure') }}
</view> </view>
</view> </view>
<view class="flex space-x-[20rpx]"> <view class="flex space-x-[20rpx]">
@@ -393,7 +394,7 @@ onMounted(async () => {
<!-- MCP接入点区域 --> <!-- MCP接入点区域 -->
<view class="mt-[20rpx] flex flex-1 flex-col"> <view class="mt-[20rpx] flex flex-1 flex-col">
<view class="text-[32rpx] text-[#333] font-medium"> <view class="text-[32rpx] text-[#333] font-medium">
mcp接入点 {{ t('agent.tools.mcpEndpoint') }}
</view> </view>
<view <view
class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]" class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]"
@@ -406,11 +407,11 @@ onMounted(async () => {
class="flex-1 rounded-[10rpx] bg-[#f5f7fb] p-[20rpx]" class="flex-1 rounded-[10rpx] bg-[#f5f7fb] p-[20rpx]"
> >
<view <view
class="ml-[20rpx] h-[70rpx] flex items-center justify-center rounded-[10rpx] bg-[#1677ff] px-[20rpx] text-[24rpx] text-white" class="ml-[20rpx] h-[70rpx] flex items-center justify-center rounded-[10rpx] bg-[#1677ff] px-[20rpx] text-[24rpx] text-white"
@click="copyMcpAddress" @click="copyMcpAddress"
> >
复制 {{ t('agent.tools.copy') }}
</view> </view>
</view> </view>
<!-- 工具列表 --> <!-- 工具列表 -->
<view class="mt-[20rpx] flex-1 overflow-hidden"> <view class="mt-[20rpx] flex-1 overflow-hidden">
@@ -419,7 +420,7 @@ onMounted(async () => {
v-if="mcpTools && mcpTools.length === 0" v-if="mcpTools && mcpTools.length === 0"
class="h-[400rpx] flex items-center justify-center" class="h-[400rpx] flex items-center justify-center"
> >
<wd-status-tip image="content" tip="暂无工具" /> <wd-status-tip image="content" :tip="t('agent.tools.noTools')" />
</view> </view>
<view v-else class="p-[20rpx]"> <view v-else class="p-[20rpx]">
<view class="flex flex-wrap"> <view class="flex flex-wrap">
@@ -441,7 +442,7 @@ onMounted(async () => {
<!-- 参数编辑弹窗 --> <!-- 参数编辑弹窗 -->
<wd-action-sheet <wd-action-sheet
v-model="showParamDialog" v-model="showParamDialog"
:title="`参数配置 - ${currentFunction?.name || ''}`" :title="`${t('agent.tools.paramConfiguration')} - ${currentFunction?.name || ''}`"
custom-header-class="h-[75vh]" custom-header-class="h-[75vh]"
@close="closeParamEdit" @close="closeParamEdit"
> >
@@ -460,7 +461,7 @@ onMounted(async () => {
class="h-[400rpx] flex items-center justify-center" class="h-[400rpx] flex items-center justify-center"
> >
<text class="text-[28rpx] text-[#999]"> <text class="text-[28rpx] text-[#999]">
{{ currentFunction?.name }} 无需配置参数 {{ currentFunction?.name }} {{ t('agent.tools.noParamsNeeded') }}
</text> </text>
</view> </view>
@@ -490,7 +491,7 @@ onMounted(async () => {
v-model="tempParams[field.key]" v-model="tempParams[field.key]"
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text" type="text"
:placeholder="`请输入${field.label}`" :placeholder="`${t('agent.tools.pleaseInput')}${field.label}`"
@input=" @input="
handleParamChange(field.key, $event.detail.value, field) handleParamChange(field.key, $event.detail.value, field)
" "
@@ -499,12 +500,12 @@ onMounted(async () => {
<!-- 数组类型 --> <!-- 数组类型 -->
<view v-else-if="field.type === 'array'"> <view v-else-if="field.type === 'array'">
<text class="mb-[16rpx] block text-[24rpx] text-[#65686f]"> <text class="mb-[16rpx] block text-[24rpx] text-[#65686f]">
每行输入一个项目 {{ t('agent.tools.eachLineOneItem') }}
</text> </text>
<textarea <textarea
v-model="arrayTextCache[field.key]" v-model="arrayTextCache[field.key]"
class="box-border min-h-[200rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border min-h-[200rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
:placeholder="`请输入${field.label},每行一个`" :placeholder="`${t('agent.tools.pleaseInput')}${field.label}${t('agent.tools.eachLineOneItem')}`"
@input=" @input="
handleArrayChange(field.key, $event.detail.value, field) handleArrayChange(field.key, $event.detail.value, field)
" "
@@ -514,12 +515,12 @@ onMounted(async () => {
<!-- JSON类型 --> <!-- JSON类型 -->
<view v-else-if="field.type === 'json'"> <view v-else-if="field.type === 'json'">
<text class="mb-[16rpx] block text-[24rpx] text-[#65686f]"> <text class="mb-[16rpx] block text-[24rpx] text-[#65686f]">
请输入有效的JSON格式 {{ t('agent.tools.pleaseInputValidJson') }}
</text> </text>
<textarea <textarea
v-model="jsonTextCache[field.key]" v-model="jsonTextCache[field.key]"
class="box-border min-h-[300rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] font-mono focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border min-h-[300rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] font-mono focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
placeholder="请输入合法的JSON格式" :placeholder="t('agent.tools.pleaseInputValidJson')"
@blur=" @blur="
handleJsonChange(field.key, $event.detail.value, field) handleJsonChange(field.key, $event.detail.value, field)
" "
@@ -532,7 +533,7 @@ onMounted(async () => {
v-model="tempParams[field.key]" v-model="tempParams[field.key]"
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="number" type="number"
:placeholder="`请输入${field.label}`" :placeholder="`${t('agent.tools.pleaseInput')}${field.label}`"
@input=" @input="
handleParamChange( handleParamChange(
field.key, field.key,
@@ -549,10 +550,10 @@ onMounted(async () => {
> >
<view class="flex-1"> <view class="flex-1">
<text class="mb-[8rpx] block text-[28rpx] text-[#232338]"> <text class="mb-[8rpx] block text-[28rpx] text-[#232338]">
启用功能 {{ t('agent.tools.enableFunction') }}
</text> </text>
<text class="block text-[24rpx] text-[#65686f]"> <text class="block text-[24rpx] text-[#65686f]">
开启或关闭此功能 {{ t('agent.tools.toggleFunction') }}
</text> </text>
</view> </view>
<switch <switch
@@ -569,7 +570,7 @@ onMounted(async () => {
v-model="tempParams[field.key]" v-model="tempParams[field.key]"
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text" type="text"
:placeholder="`请输入${field.label}`" :placeholder="`${t('agent.tools.pleaseInput')}${field.label}`"
@input=" @input="
handleParamChange(field.key, $event.detail.value, field) handleParamChange(field.key, $event.detail.value, field)
" "
@@ -15,6 +15,7 @@ import { computed, ref } from 'vue'
import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history' import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history'
import { getEnvBaseUrl } from '@/utils' import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { t } from '@/i18n'
defineOptions({ defineOptions({
name: 'ChatDetail', name: 'ChatDetail',
@@ -49,7 +50,7 @@ const agentId = ref('')
const currentAgent = computed(() => { const currentAgent = computed(() => {
return { return {
id: agentId.value, id: agentId.value,
agentName: '智能助手', agentName: t('chatHistory.assistantName'),
} }
}) })
@@ -80,7 +81,7 @@ async function loadChatHistory() {
} }
catch (error) { catch (error) {
console.error('获取聊天记录失败:', error) console.error('获取聊天记录失败:', error)
toast.error('获取聊天记录失败') toast.error(t('chatHistory.loadFailed'))
} }
finally { finally {
loading.value = false loading.value = false
@@ -114,10 +115,10 @@ function getMessageContent(message: ChatMessage): string {
function getSpeakerName(message: ChatMessage): string { function getSpeakerName(message: ChatMessage): string {
if (message.chatType === 1) { if (message.chatType === 1) {
const parsed = parseUserMessage(message.content) const parsed = parseUserMessage(message.content)
return parsed ? parsed.speaker : '用户' return parsed ? parsed.speaker : t('chatHistory.userName')
} }
else { else {
return currentAgent.value?.agentName || 'AI助手' return currentAgent.value?.agentName || t('chatHistory.aiAssistantName')
} }
} }
@@ -130,7 +131,7 @@ function formatTime(timeStr: string) {
// 播放音频 // 播放音频
async function playAudio(audioId: string) { async function playAudio(audioId: string) {
if (!audioId) { if (!audioId) {
toast.error('音频ID无效') toast.error(t('chatHistory.invalidAudioId'))
return return
} }
@@ -168,7 +169,7 @@ async function playAudio(audioId: string) {
// 监听播放错误 // 监听播放错误
audioContext.value.onError((error) => { audioContext.value.onError((error) => {
console.error('音频播放失败:', error) console.error('音频播放失败:', error)
toast.error('音频播放失败') toast.error(t('chatHistory.audioPlayFailed'))
playingAudioId.value = null playingAudioId.value = null
if (audioContext.value) { if (audioContext.value) {
audioContext.value.destroy() audioContext.value.destroy()
@@ -181,7 +182,7 @@ async function playAudio(audioId: string) {
} }
catch (error) { catch (error) {
console.error('播放音频失败:', error) console.error('播放音频失败:', error)
toast.error('播放音频失败') toast.error(t('chatHistory.playAudioFailed'))
playingAudioId.value = null playingAudioId.value = null
} }
} }
@@ -194,7 +195,7 @@ onLoad((options) => {
} }
else { else {
console.error('缺少必要参数') console.error('缺少必要参数')
toast.error('页面参数错误') toast.error(t('chatHistory.parameterError'))
} }
}) })
@@ -214,7 +215,7 @@ onUnload(() => {
<view class="w-full bg-white" :style="{ height: `${safeAreaInsets?.top}px` }" /> <view class="w-full bg-white" :style="{ height: `${safeAreaInsets?.top}px` }" />
<!-- 导航栏 --> <!-- 导航栏 -->
<wd-navbar title="聊天详情"> <wd-navbar :title="t('chatHistory.pageTitle')">
<template #left> <template #left>
<wd-icon name="arrow-left" size="18" @click="goBack" /> <wd-icon name="arrow-left" size="18" @click="goBack" />
</template> </template>
@@ -230,7 +231,7 @@ onUnload(() => {
<view v-if="loading" class="flex flex-col items-center justify-center gap-[20rpx] p-[100rpx_0]"> <view v-if="loading" class="flex flex-col items-center justify-center gap-[20rpx] p-[100rpx_0]">
<wd-loading /> <wd-loading />
<text class="text-[28rpx] text-[#65686f]"> <text class="text-[28rpx] text-[#65686f]">
加载中... {{ t('chatHistory.loading') }}
</text> </text>
</view> </view>
@@ -2,6 +2,7 @@
import type { ChatSession } from '@/api/chat-history/types' import type { ChatSession } from '@/api/chat-history/types'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { getChatSessions } from '@/api/chat-history/chat-history' import { getChatSessions } from '@/api/chat-history/chat-history'
import { t } from '@/i18n'
defineOptions({ defineOptions({
name: 'ChatHistory', name: 'ChatHistory',
@@ -53,11 +54,11 @@ const currentAgentId = computed(() => {
// 加载聊天会话列表 // 加载聊天会话列表
async function loadChatSessions(page = 1, isRefresh = false) { async function loadChatSessions(page = 1, isRefresh = false) {
try { try {
console.log('获取聊天会话列表', { page, isRefresh }) console.log(t('chatHistory.getChatSessions'), { page, isRefresh })
// 检查是否有当前选中的智能体 // 检查是否有当前选中的智能体
if (!currentAgentId.value) { if (!currentAgentId.value) {
console.warn('没有选中的智能体') console.warn(t('chatHistory.noSelectedAgent'))
sessionList.value = [] sessionList.value = []
return return
} }
@@ -86,7 +87,7 @@ async function loadChatSessions(page = 1, isRefresh = false) {
currentPage.value = page currentPage.value = page
} }
catch (error) { catch (error) {
console.error('获取聊天会话列表失败:', error) console.error(t('chatHistory.getChatSessionsFailed'), error)
if (page === 1) { if (page === 1) {
sessionList.value = [] sessionList.value = []
} }
@@ -115,48 +116,48 @@ async function loadMore() {
// 格式化时间 // 格式化时间
function formatTime(timeStr: string) { function formatTime(timeStr: string) {
if (!timeStr) if (!timeStr)
return '未知时间' return t('chatHistory.unknownTime')
// 处理时间字符串,确保格式正确 // 处理时间字符串,确保格式正确
const date = new Date(timeStr.replace(' ', 'T')) // 转换为ISO格式 const date = new Date(timeStr.replace(' ', 'T')) // 转换为ISO格式
const now = new Date() const now = new Date()
// 检查日期是否有效 // 检查日期是否有效
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return timeStr // 如果解析失败,直接返回原字符串 return timeStr // 如果解析失败,直接返回原字符串
} }
const diff = now.getTime() - date.getTime() const diff = now.getTime() - date.getTime()
// 小于1分钟 // 小于1分钟
if (diff < 60000) if (diff < 60000)
return '刚刚' return t('chatHistory.justNow')
// 小于1小时 // 小于1小时
if (diff < 3600000) if (diff < 3600000)
return `${Math.floor(diff / 60000)}分钟前` return t('chatHistory.minutesAgo', { minutes: Math.floor(diff / 60000) })
// 小于1天(24小时) // 小于1天(24小时)
if (diff < 86400000) if (diff < 86400000)
return `${Math.floor(diff / 3600000)}小时前` return t('chatHistory.hoursAgo', { hours: Math.floor(diff / 3600000) })
// 小于7天 // 小于7天
if (diff < 604800000) { if (diff < 604800000) {
const days = Math.floor(diff / 86400000) const days = Math.floor(diff / 86400000)
return `${days}天前` return t('chatHistory.daysAgo', { days })
} }
// 超过7天,显示具体日期 // 超过7天,显示具体日期
const year = date.getFullYear() const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0')
const currentYear = now.getFullYear() const currentYear = now.getFullYear()
// 如果是当前年份,不显示年份 // 如果是当前年份,不显示年份
if (year === currentYear) { if (year === currentYear) {
return `${month}-${day}` return `${month}-${day}`
} }
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
@@ -186,8 +187,8 @@ defineExpose({
<view v-if="loading && sessionList.length === 0" class="loading-container"> <view v-if="loading && sessionList.length === 0" class="loading-container">
<wd-loading color="#336cff" /> <wd-loading color="#336cff" />
<text class="loading-text"> <text class="loading-text">
加载中... {{ t('chatHistory.loading') }}
</text> </text>
</view> </view>
<!-- 会话列表 --> <!-- 会话列表 -->
@@ -204,7 +205,7 @@ defineExpose({
<view class="session-info"> <view class="session-info">
<view class="session-header"> <view class="session-header">
<text class="session-title"> <text class="session-title">
对话记录 {{ session.sessionId.substring(0, 8) }}... {{ t('chatHistory.conversationRecord') }} {{ session.sessionId.substring(0, 8) }}...
</text> </text>
<text class="session-time"> <text class="session-time">
{{ formatTime(session.createdAt) }} {{ formatTime(session.createdAt) }}
@@ -212,7 +213,7 @@ defineExpose({
</view> </view>
<view class="session-meta"> <view class="session-meta">
<text class="chat-count"> <text class="chat-count">
{{ session.chatCount }} 条对话 {{ t('chatHistory.totalChats', { count: session.chatCount }) }}
</text> </text>
</view> </view>
</view> </view>
@@ -225,14 +226,14 @@ defineExpose({
<view v-if="loadingMore" class="loading-more"> <view v-if="loadingMore" class="loading-more">
<wd-loading color="#336cff" size="24" /> <wd-loading color="#336cff" size="24" />
<text class="loading-more-text"> <text class="loading-more-text">
加载中... {{ t('chatHistory.loading') }}
</text> </text>
</view> </view>
<!-- 没有更多数据 --> <!-- 没有更多数据 -->
<view v-else-if="!hasMore && sessionList.length > 0" class="no-more"> <view v-else-if="!hasMore && sessionList.length > 0" class="no-more">
<text class="no-more-text"> <text class="no-more-text">
没有更多数据了 {{ t('chatHistory.noMoreData') }}
</text> </text>
</view> </view>
</view> </view>
@@ -241,11 +242,11 @@ defineExpose({
<view v-else-if="!loading" class="empty-state"> <view v-else-if="!loading" class="empty-state">
<wd-icon name="chat" custom-class="empty-icon" /> <wd-icon name="chat" custom-class="empty-icon" />
<text class="empty-text"> <text class="empty-text">
暂无聊天记录 {{ t('chatHistory.noChatRecords') }}
</text> </text>
<text class="empty-desc"> <text class="empty-desc">
与智能体的对话记录会显示在这里 {{ t('chatHistory.chatRecordsDescription') }}
</text> </text>
</view> </view>
</view> </view>
</template> </template>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useToast } from 'wot-design-uni' import { useToast } from 'wot-design-uni'
import { t } from '@/i18n'
// 类型定义 // 类型定义
interface WiFiNetwork { interface WiFiNetwork {
@@ -53,7 +54,7 @@ const audioLengthText = computed(() => {
const textBytes = stringToBytes(dataStr) const textBytes = stringToBytes(dataStr)
const totalBits = (START_BYTES.length + textBytes.length + 1 + END_BYTES.length) * 8 const totalBits = (START_BYTES.length + textBytes.length + 1 + END_BYTES.length) * 8
const duration = Math.ceil(totalBits / BIT_RATE) const duration = Math.ceil(totalBits / BIT_RATE)
return `${duration}` return `${t('deviceConfig.about')}${duration}${t('deviceConfig.seconds')}`
}) })
// 字符串转字节数组 - uniapp兼容版本 // 字符串转字节数组 - uniapp兼容版本
@@ -227,15 +228,15 @@ async function generateAndPlay() {
generating.value = true generating.value = true
try { try {
console.log('生成超声波配网音频...') console.log(t('deviceConfig.generatingUltrasonicConfigAudio') + '...')
// 准备配网数据 - 参考HTML文件格式 // 准备配网数据 - 参考HTML文件格式
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}` const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
const textBytes = stringToBytes(dataStr) const textBytes = stringToBytes(dataStr)
const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES] const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES]
console.log('配网数据:', { ssid: props.selectedNetwork.ssid, password: props.password }) console.log(t('deviceConfig.configData') + ':', { ssid: props.selectedNetwork.ssid, password: props.password })
console.log('数据字节长度:', textBytes.length) console.log(t('deviceConfig.dataBytesLength') + ':', textBytes.length)
// 转换为比特流 // 转换为比特流
let bits: number[] = [] let bits: number[] = []
@@ -243,7 +244,7 @@ async function generateAndPlay() {
bits = bits.concat(toBits(b)) bits = bits.concat(toBits(b))
}) })
console.log('比特流长度:', bits.length) console.log(t('deviceConfig.bitStreamLength') + ':', bits.length)
// AFSK调制 - 减少采样率降低文件大小 // AFSK调制 - 减少采样率降低文件大小
const reducedSampleRate = 22050 // 降低采样率 const reducedSampleRate = 22050 // 降低采样率
@@ -266,19 +267,19 @@ async function generateAndPlay() {
const base64 = arrayBufferToBase64(wavBuffer) const base64 = arrayBufferToBase64(wavBuffer)
const dataUri = `data:audio/wav;base64,${base64}` const dataUri = `data:audio/wav;base64,${base64}`
console.log('base64长度:', base64.length, '约', Math.round(base64.length / 1024), 'KB') console.log(t('deviceConfig.base64Length') + ':', base64.length, t('deviceConfig.about'), Math.round(base64.length / 1024), 'KB')
// 检查数据大小 // 检查数据大小
if (base64.length > 1024 * 1024) { // 超过1MB if (base64.length > 1024 * 1024) { // 超过1MB
throw new Error('音频文件过大,请缩短SSID或密码长度') throw new Error(t('deviceConfig.audioFileTooLarge'))
} }
audioFilePath.value = dataUri audioFilePath.value = dataUri
audioGenerated.value = true audioGenerated.value = true
console.log('音频生成成功,比特流长度:', bits.length, '采样点数:', floatBuf.length) console.log(t('deviceConfig.audioGenerationSuccess') + ',比特流长度:', bits.length, t('deviceConfig.samplePoints') + ':', floatBuf.length)
toast.success('声波生成成功') toast.success(t('deviceConfig.soundWaveGenerationSuccess'))
// 延迟播放 // 延迟播放
setTimeout(async () => { setTimeout(async () => {
@@ -286,8 +287,8 @@ async function generateAndPlay() {
}, 800) // 增加延迟时间 }, 800) // 增加延迟时间
} }
catch (error) { catch (error) {
console.error('音频生成失败:', error) console.error(t('deviceConfig.audioGenerationFailed') + ':', error)
toast.error(`声波生成失败: ${error.message || error}`) toast.error(`${t('deviceConfig.soundWaveGenerationFailed')}: ${error.message || error}`)
} }
finally { finally {
generating.value = false generating.value = false
@@ -344,7 +345,7 @@ function buildWavOptimized(pcm: Uint8Array, sampleRate: number): ArrayBuffer {
// 播放音频 // 播放音频
async function playAudio() { async function playAudio() {
if (!audioFilePath.value) { if (!audioFilePath.value) {
toast.error('请先生成音频') toast.error(t('deviceConfig.pleaseGenerateAudioFirst'))
return return
} }
@@ -356,7 +357,7 @@ async function playAudio() {
await new Promise(resolve => setTimeout(resolve, 200)) await new Promise(resolve => setTimeout(resolve, 200))
playing.value = true playing.value = true
console.log('开始播放超声波配网音频') console.log(t('deviceConfig.startPlayingUltrasonicConfigAudio'))
// 创建新的音频上下文 // 创建新的音频上下文
const innerAudioContext = uni.createInnerAudioContext() const innerAudioContext = uni.createInnerAudioContext()
@@ -370,12 +371,12 @@ async function playAudio() {
// 简化的事件监听 // 简化的事件监听
innerAudioContext.onPlay(() => { innerAudioContext.onPlay(() => {
console.log('超声波音频开始播放') console.log(t('deviceConfig.ultrasonicAudioStartedPlaying'))
toast.success('开始播放配网声波') toast.success(t('deviceConfig.startPlayingConfigSoundWave'))
}) })
innerAudioContext.onEnded(() => { innerAudioContext.onEnded(() => {
console.log('超声波音频播放结束') console.log(t('deviceConfig.ultrasonicAudioPlaybackEnded'))
if (!autoLoop.value) { if (!autoLoop.value) {
playing.value = false playing.value = false
cleanupAudio() cleanupAudio()
@@ -383,18 +384,18 @@ async function playAudio() {
}) })
innerAudioContext.onError((error) => { innerAudioContext.onError((error) => {
console.error('音频播放失败:', error) console.error(t('deviceConfig.audioPlaybackFailed') + ':', error)
playing.value = false playing.value = false
let errorMsg = '音频播放失败' let errorMsg = t('deviceConfig.audioPlaybackFailed')
if (error.errCode === -99) { if (error.errCode === -99) {
errorMsg = '音频资源繁忙,请稍后重试' errorMsg = t('deviceConfig.audioResourceBusy')
} }
else if (error.errCode === 10004) { else if (error.errCode === 10004) {
errorMsg = '音频格式不支持,可能是data URI问题' errorMsg = t('deviceConfig.audioFormatNotSupported')
} }
else if (error.errCode === 10003) { else if (error.errCode === 10003) {
errorMsg = '音频文件错误' errorMsg = t('deviceConfig.audioFileError')
} }
toast.error(errorMsg) toast.error(errorMsg)
@@ -416,10 +417,10 @@ async function playAudio() {
}, 300) }, 300)
} }
catch (error) { catch (error) {
console.error('播放音频异常:', error) console.error(t('deviceConfig.audioPlaybackError') + ':', error)
playing.value = false playing.value = false
await cleanupAudio() await cleanupAudio()
toast.error(`播放失败: ${error.message}`) toast.error(`${t('deviceConfig.playbackFailed')}: ${error.message}`)
} }
} }
@@ -429,10 +430,10 @@ async function cleanupAudio() {
try { try {
audioContext.value.pause() audioContext.value.pause()
audioContext.value.destroy() audioContext.value.destroy()
console.log('清理音频上下文') console.log(t('deviceConfig.cleaningUpAudioContext'))
} }
catch (e) { catch (e) {
console.log('清理音频上下文失败:', e) console.log(t('deviceConfig.cleaningUpAudioContextFailed') + ':', e)
} }
finally { finally {
audioContext.value = null audioContext.value = null
@@ -445,8 +446,8 @@ async function stopAudio() {
playing.value = false playing.value = false
await cleanupAudio() await cleanupAudio()
console.log('停止播放超声波音频') console.log(t('deviceConfig.stoppedPlayingUltrasonicAudio'))
toast.success('已停止播放') toast.success(t('deviceConfig.stoppedPlaying'))
} }
</script> </script>
@@ -456,18 +457,18 @@ async function stopAudio() {
<view v-if="props.selectedNetwork" class="selected-network"> <view v-if="props.selectedNetwork" class="selected-network">
<view class="network-info"> <view class="network-info">
<view class="network-name"> <view class="network-name">
选中网络: {{ props.selectedNetwork.ssid }} {{ t('deviceConfig.selectedNetwork') }}: {{ props.selectedNetwork.ssid }}
</view> </view>
<view class="network-details"> <view class="network-details">
<text class="network-signal"> <text class="network-signal">
信号: {{ props.selectedNetwork.rssi }}dBm {{ t('deviceConfig.signal') }}: {{ props.selectedNetwork.rssi }}dBm
</text> </text>
<text class="network-security"> <text class="network-security">
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }} {{ props.selectedNetwork.authmode === 0 ? t('deviceConfig.openNetwork') : t('deviceConfig.encryptedNetwork') }}
</text> </text>
</view> </view>
<view v-if="props.password" class="network-password"> <view v-if="props.password" class="network-password">
密码: {{ '*'.repeat(props.password.length) }} {{ t('deviceConfig.password') }}: {{ '*'.repeat(props.password.length) }}
</view> </view>
</view> </view>
</view> </view>
@@ -482,81 +483,81 @@ async function stopAudio() {
:disabled="!canGenerate" :disabled="!canGenerate"
@click="generateAndPlay" @click="generateAndPlay"
> >
{{ generating ? '生成中...' : '🎵 生成并播放声波' }} {{ generating ? t('deviceConfig.generating') : '🎵 ' + t('deviceConfig.generateAndPlaySoundWave') }}
</wd-button> </wd-button>
<wd-button <wd-button
v-if="audioGenerated" v-if="audioGenerated"
type="success" type="success"
size="large" size="large"
block block
:loading="playing" :loading="playing"
@click="playAudio" @click="playAudio"
> >
{{ playing ? '播放中...' : '🔊 播放声波' }} {{ playing ? t('deviceConfig.playing') : '🔊 ' + t('deviceConfig.playSoundWave') }}
</wd-button> </wd-button>
<wd-button <wd-button
v-if="playing" v-if="playing"
type="warning" type="warning"
size="large" size="large"
block block
@click="stopAudio" @click="stopAudio"
> >
停止播放 {{ t('deviceConfig.stopPlaying') }}
</wd-button> </wd-button>
</view> </view>
<!-- 音频控制选项 --> <!-- 音频控制选项 -->
<view v-if="audioGenerated" class="audio-options"> <view v-if="audioGenerated" class="audio-options">
<view class="option-item"> <view class="option-item">
<wd-checkbox v-model="autoLoop"> <wd-checkbox v-model="autoLoop">
自动循环播放声波 {{ t('deviceConfig.autoLoopPlaySoundWave') }}
</wd-checkbox> </wd-checkbox>
</view> </view>
</view> </view>
<!-- 音频播放器 --> <!-- 音频播放器 -->
<view v-if="audioGenerated" class="audio-player"> <view v-if="audioGenerated" class="audio-player">
<view class="player-info"> <view class="player-info">
<text class="audio-title"> <text class="audio-title">
配网音频文件 {{ t('deviceConfig.configAudioFile') }}
</text> </text>
<text class="audio-duration"> <text class="audio-duration">
时长: {{ audioLengthText }} {{ t('deviceConfig.duration') }}: {{ audioLengthText }}
</text> </text>
</view> </view>
</view> </view>
<!-- 使用说明 --> <!-- 使用说明 -->
<view class="help-section"> <view class="help-section">
<view class="help-title"> <view class="help-title">
超声波配网说明 {{ t('deviceConfig.ultrasonicConfigInstructions') }}
</view>
<view class="help-content">
<text class="help-item">
1. {{ t('deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered') }}
</text>
<text class="help-item">
2. {{ t('deviceConfig.clickGenerateAndPlaySoundWave') }}
</text>
<text class="help-item">
3. {{ t('deviceConfig.bringPhoneCloseToXiaozhiDevice') }}
</text>
<text class="help-item">
4. {{ t('deviceConfig.duringAudioPlaybackXiaozhiWillReceive') }}
</text>
<text class="help-item">
5. {{ t('deviceConfig.afterConfigSuccessDeviceWillConnect') }}
</text>
<text class="help-tip">
{{ t('deviceConfig.usesAfskModulation') }}
</text>
<text class="help-tip">
{{ t('deviceConfig.ensureModeratePhoneVolume') }}
</text>
</view>
</view> </view>
<view class="help-content">
<text class="help-item">
1. 确保已选择WiFi网络并输入密码
</text>
<text class="help-item">
2. 点击生成并播放声波系统会将配网信息编码为音频
</text>
<text class="help-item">
3. 将手机靠近xiaozhi设备距离1-2
</text>
<text class="help-item">
4. 音频播放时xiaozhi会接收并解码配网信息
</text>
<text class="help-item">
5. 配网成功后设备会自动连接WiFi网络
</text>
<text class="help-tip">
使用AFSK调制技术通过1800Hz和1500Hz频率传输数据
</text>
<text class="help-tip">
请确保手机音量适中避免环境噪音干扰
</text>
</view>
</view>
</view> </view>
</template> </template>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useToast } from 'wot-design-uni' import { useToast } from 'wot-design-uni'
import { t } from '@/i18n'
// 类型定义 // 类型定义
interface WiFiNetwork { interface WiFiNetwork {
@@ -44,7 +45,7 @@ async function checkESP32Connection() {
return response.statusCode === 200 return response.statusCode === 200
} }
catch (error) { catch (error) {
console.log('ESP32连接检查失败:', error) console.log(t('deviceConfig.esp32ConnectionCheckFailed') + ':', error)
return false return false
} }
} }
@@ -57,12 +58,12 @@ async function submitConfig() {
// 检查ESP32连接 // 检查ESP32连接
const connected = await checkESP32Connection() const connected = await checkESP32Connection()
if (!connected) { if (!connected) {
toast.error('请先连接xiaozhi热点') toast.error(t('deviceConfig.connectXiaozhiHotspot'))
return return
} }
configuring.value = true configuring.value = true
console.log('开始WiFi配网:', props.selectedNetwork.ssid) console.log(t('deviceConfig.startWifiConfig') + ':', props.selectedNetwork.ssid)
try { try {
const response = await uni.request({ const response = await uni.request({
@@ -81,16 +82,16 @@ async function submitConfig() {
console.log('WiFi配网响应:', response) console.log('WiFi配网响应:', response)
if (response.statusCode === 200 && (response.data as any)?.success) { if (response.statusCode === 200 && (response.data as any)?.success) {
toast.success(`配网成功!设备将连接到 ${props.selectedNetwork.ssid},设备会自动重启。请断开xiaozhi热点连接。`) toast.success(`${t('deviceConfig.configSuccess')}${t('deviceConfig.deviceWillConnectTo')} ${props.selectedNetwork.ssid}${t('deviceConfig.deviceWillRestart')}${t('deviceConfig.pleaseDisconnectXiaozhiHotspot')}`)
} }
else { else {
const errorMsg = (response.data as any)?.error || '配网失败' const errorMsg = (response.data as any)?.error || t('deviceConfig.configFailed')
toast.error(errorMsg) toast.error(errorMsg)
} }
} }
catch (error) { catch (error) {
console.error('WiFi配网失败:', error) console.error(t('deviceConfig.wifiConfigFailed') + ':', error)
toast.error('配网失败,请检查网络连接') toast.error(`${t('deviceConfig.configFailed')}${t('deviceConfig.pleaseCheckNetworkConnection')}`)
} }
finally { finally {
configuring.value = false configuring.value = false
@@ -104,14 +105,14 @@ async function submitConfig() {
<view v-if="props.selectedNetwork" class="selected-network"> <view v-if="props.selectedNetwork" class="selected-network">
<view class="network-info"> <view class="network-info">
<view class="network-name"> <view class="network-name">
选中网络: {{ props.selectedNetwork.ssid }} {{ t('deviceConfig.selectedNetwork') }}: {{ props.selectedNetwork.ssid }}
</view> </view>
<view class="network-details"> <view class="network-details">
<text class="network-signal"> <text class="network-signal">
信号: {{ props.selectedNetwork.rssi }}dBm {{ t('deviceConfig.signal') }}: {{ props.selectedNetwork.rssi }}dBm
</text> </text>
<text class="network-security"> <text class="network-security">
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }} {{ props.selectedNetwork.authmode === 0 ? t('deviceConfig.openNetwork') : t('deviceConfig.encryptedNetwork') }}
</text> </text>
</view> </view>
</view> </view>
@@ -127,30 +128,30 @@ async function submitConfig() {
:disabled="!canSubmit" :disabled="!canSubmit"
@click="submitConfig" @click="submitConfig"
> >
{{ configuring ? '配网中...' : '开始WiFi配网' }} {{ configuring ? t('deviceConfig.configuring') : t('deviceConfig.startWifiConfigButton') }}
</wd-button> </wd-button>
</view> </view>
<!-- 使用说明 --> <!-- 使用说明 -->
<view class="help-section"> <view class="help-section">
<view class="help-title"> <view class="help-title">
WiFi配网说明 {{ t('deviceConfig.wifiConfigInstructions') }}
</view> </view>
<view class="help-content"> <view class="help-content">
<text class="help-item"> <text class="help-item">
1. 手机连接xiaozhi热点 (xiaozhi-XXXXXX) 1. {{ t('deviceConfig.phoneConnectXiaozhiHotspot') }} (xiaozhi-XXXXXX)
</text> </text>
<text class="help-item"> <text class="help-item">
2. 选择要配网的目标WiFi网络 2. {{ t('deviceConfig.selectTargetWifiNetwork') }}
</text> </text>
<text class="help-item"> <text class="help-item">
3. 输入WiFi密码如果需要 3. {{ t('deviceConfig.enterWifiPasswordIfNeeded') }}
</text> </text>
<text class="help-item"> <text class="help-item">
4. 点击开始配网等待设备连接 4. {{ t('deviceConfig.clickStartConfigAndWait') }}
</text> </text>
<text class="help-tip"> <text class="help-tip">
配网成功后设备会自动重启并连接目标WiFi {{ t('deviceConfig.afterConfigSuccessDeviceWillRestart') }}
</text> </text>
</view> </view>
</view> </view>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineEmits, defineExpose, onMounted, ref } from 'vue' import { computed, defineEmits, defineExpose, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni' import { useToast } from 'wot-design-uni'
import { t } from '@/i18n'
// 类型定义 // 类型定义
interface WiFiNetwork { interface WiFiNetwork {
@@ -40,7 +41,7 @@ const selectorExpanded = ref(false)
// 计算属性 // 计算属性
const networkDisplayText = computed(() => { const networkDisplayText = computed(() => {
if (!selectedNetwork.value) if (!selectedNetwork.value)
return '请选择WiFi网络' return t('deviceConfig.selectWifiNetwork')
return selectedNetwork.value.ssid return selectedNetwork.value.ssid
}) })
@@ -55,7 +56,7 @@ async function checkESP32Connection() {
}) })
isConnectedToESP32.value = response.statusCode === 200 isConnectedToESP32.value = response.statusCode === 200
emit('connection-status', isConnectedToESP32.value) emit('connection-status', isConnectedToESP32.value)
console.log('xiaozhi连接状态:', isConnectedToESP32.value) console.log(`${t('deviceConfig.xiaozhi')}连接状态:`, isConnectedToESP32.value)
} }
catch (error) { catch (error) {
isConnectedToESP32.value = false isConnectedToESP32.value = false
@@ -70,9 +71,9 @@ async function checkESP32Connection() {
// 扫描WiFi网络 // 扫描WiFi网络
async function scanWifi() { async function scanWifi() {
if (!isConnectedToESP32.value) { if (!isConnectedToESP32.value) {
toast.error('请先连接xiaozhi热点') toast.error(t('deviceConfig.connectXiaozhiHotspot'))
return return
} }
scanning.value = true scanning.value = true
console.log('开始扫描WiFi网络') console.log('开始扫描WiFi网络')
@@ -84,13 +85,13 @@ async function scanWifi() {
timeout: 10000, timeout: 10000,
}) })
console.log('WiFi扫描响应:', response) console.log(t('deviceConfig.wifiScanResponse') + ':', response)
if (response.statusCode === 200 && response.data) { if (response.statusCode === 200 && response.data) {
const data = response.data as any const data = response.data as any
if (data.success && Array.isArray(data.networks)) { if (data.success && Array.isArray(data.networks)) {
wifiNetworks.value = data.networks wifiNetworks.value = data.networks
console.log(`扫描成功,发现 ${data.networks.length} 个网络`) console.log(`${t('deviceConfig.scanSuccess')},发现 ${data.networks.length} ${t('deviceConfig.networks')}`)
} }
else if (Array.isArray(response.data)) { else if (Array.isArray(response.data)) {
// 兼容旧格式 // 兼容旧格式
@@ -110,8 +111,8 @@ async function scanWifi() {
} }
} }
catch (error) { catch (error) {
console.error('WiFi扫描失败:', error) console.error(t('deviceConfig.wifiScanFailed') + ':', error)
toast.error('扫描失败,请检查xiaozhi连接') toast.error(t('deviceConfig.scanFailedCheckConnection'))
} }
finally { finally {
scanning.value = false scanning.value = false
@@ -124,9 +125,9 @@ async function showNetworkSelector() {
await checkESP32Connection() await checkESP32Connection()
if (!isConnectedToESP32.value) { if (!isConnectedToESP32.value) {
toast.error('请先连接xiaozhi热点') toast.error(t('deviceConfig.connectXiaozhiHotspot'))
return return
} }
selectorExpanded.value = true selectorExpanded.value = true
@@ -172,12 +173,12 @@ function reset() {
// 获取信号强度描述 // 获取信号强度描述
function getSignalStrength(rssi: number): string { function getSignalStrength(rssi: number): string {
if (rssi >= -50) if (rssi >= -50)
return '信号强' return t('deviceConfig.signalStrong')
if (rssi >= -60) if (rssi >= -60)
return '信号良好' return t('deviceConfig.signalGood')
if (rssi >= -70) if (rssi >= -70)
return '信号一般' return t('deviceConfig.signalFair')
return '信号弱' return t('deviceConfig.signalWeak')
} }
// 获取信号强度颜色 // 获取信号强度颜色
@@ -212,81 +213,81 @@ onMounted(() => {
<!-- Xiaozhi连接状态 --> <!-- Xiaozhi连接状态 -->
<view v-if="props.autoConnect" class="connection-status"> <view v-if="props.autoConnect" class="connection-status">
<view v-if="!isConnectedToESP32" class="status-warning"> <view v-if="!isConnectedToESP32" class="status-warning">
<view class="status-content"> <view class="status-content">
<text class="warning-text"> <text class="warning-text">
请先连接xiaozhi热点 (xiaozhi-XXXXXX) {{ t('deviceConfig.connectXiaozhiHotspot') }} (xiaozhi-XXXXXX)
</text> </text>
<wd-button <wd-button
size="small" size="small"
type="primary" type="primary"
:loading="checkingConnection" :loading="checkingConnection"
@click="checkESP32Connection" @click="checkESP32Connection"
> >
{{ checkingConnection ? '检测中...' : '重新检测' }} {{ checkingConnection ? t('deviceConfig.checking') : t('deviceConfig.reCheck') }}
</wd-button> </wd-button>
</view>
</view> </view>
</view>
<view v-else class="status-success"> <view v-else class="status-success">
<view class="status-content"> <view class="status-content">
<text class="success-text"> <text class="success-text">
已连接xiaozhi热点 {{ t('deviceConfig.connectedXiaozhiHotspot') }}
</text> </text>
<wd-button <wd-button
size="small" size="small"
:loading="checkingConnection" :loading="checkingConnection"
@click="checkESP32Connection" @click="checkESP32Connection"
> >
{{ checkingConnection ? '检测中...' : '刷新状态' }} {{ checkingConnection ? t('deviceConfig.checking') : t('deviceConfig.refreshStatus') }}
</wd-button> </wd-button>
</view>
</view> </view>
</view>
</view> </view>
<!-- WiFi网络选择器 --> <!-- WiFi网络选择器 -->
<view class="network-selector"> <view class="network-selector">
<view class="selector-item" @click="showNetworkSelector"> <view class="selector-item" @click="showNetworkSelector">
<text class="selector-label"> <text class="selector-label">
WiFi网络 {{ t('deviceConfig.wifiNetwork') }}
</text> </text>
<text class="selector-value"> <text class="selector-value">
{{ networkDisplayText }} {{ networkDisplayText }}
</text> </text>
<wd-icon name="arrow-right" custom-class="arrow-icon" /> <wd-icon name="arrow-right" custom-class="arrow-icon" />
</view>
</view> </view>
</view>
<!-- 展开的网络列表 --> <!-- 展开的网络列表 -->
<view v-if="selectorExpanded" class="network-list-overlay"> <view v-if="selectorExpanded" class="network-list-overlay">
<view class="network-list-container"> <view class="network-list-container">
<view class="list-header"> <view class="list-header">
<text class="list-title"> <text class="list-title">
选择WiFi网络 {{ t('deviceConfig.selectWifiNetwork') }}
</text> </text>
<view class="list-actions"> <view class="list-actions">
<wd-button <wd-button
type="primary" type="primary"
size="small" size="small"
:loading="scanning" :loading="scanning"
@click="scanWifi" @click="scanWifi"
> >
{{ scanning ? '扫描中...' : '刷新扫描' }} {{ scanning ? t('deviceConfig.scanning') : t('deviceConfig.refreshScan') }}
</wd-button> </wd-button>
<wd-button <wd-button
size="small" size="small"
@click="selectorExpanded = false" @click="selectorExpanded = false"
> >
取消 {{ t('deviceConfig.cancel') }}
</wd-button> </wd-button>
</view>
</view> </view>
</view>
<view class="network-list"> <view class="network-list">
<view v-if="wifiNetworks.length === 0 && !scanning" class="empty-state"> <view v-if="wifiNetworks.length === 0 && !scanning" class="empty-state">
<text class="empty-text"> <text class="empty-text">
暂无WiFi网络 {{ t('deviceConfig.noWifiNetworks') }}
</text> </text>
<text class="empty-tip"> <text class="empty-tip">
请点击刷新扫描 {{ t('deviceConfig.clickRefreshScan') }}
</text> </text>
</view> </view>
@@ -303,16 +304,16 @@ onMounted(() => {
</view> </view>
<view class="wifi-details"> <view class="wifi-details">
<text class="wifi-signal"> <text class="wifi-signal">
信号: {{ network.rssi }}dBm {{ t('deviceConfig.signal') }}: {{ network.rssi }}dBm
</text> </text>
<text class="wifi-channel"> <text class="wifi-channel">
频道: {{ network.channel }} {{ t('deviceConfig.channel') }}: {{ network.channel }}
</text> </text>
</view> </view>
</view> </view>
<view class="wifi-security"> <view class="wifi-security">
<text class="security-icon"> <text class="security-icon">
{{ network.authmode === 0 ? '开放' : '加密' }} {{ network.authmode === 0 ? t('deviceConfig.open') : t('deviceConfig.encrypted') }}
</text> </text>
</view> </view>
</view> </view>
@@ -324,17 +325,17 @@ onMounted(() => {
<!-- 密码输入 --> <!-- 密码输入 -->
<view v-if="selectedNetwork && selectedNetwork.authmode > 0" class="password-section"> <view v-if="selectedNetwork && selectedNetwork.authmode > 0" class="password-section">
<view class="password-item"> <view class="password-item">
<text class="password-label"> <text class="password-label">
网络密码 {{ t('deviceConfig.networkPassword') }}
</text> </text>
<wd-input <wd-input
v-model="password" v-model="password"
placeholder="请输入WiFi密码" :placeholder="t('deviceConfig.enterWifiPassword')"
show-password show-password
clearable clearable
@input="onPasswordChange" @input="onPasswordChange"
/> />
</view> </view>
</view> </view>
</view> </view>
</template> </template>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { t } from '@/i18n'
import UltrasonicConfig from './components/ultrasonic-config.vue' import UltrasonicConfig from './components/ultrasonic-config.vue'
import WifiConfig from './components/wifi-config.vue' import WifiConfig from './components/wifi-config.vue'
import WifiSelector from './components/wifi-selector.vue' import WifiSelector from './components/wifi-selector.vue'
@@ -33,11 +34,11 @@ const selectedWifiInfo = ref<{
// 配网模式选项 // 配网模式选项
const configTypeOptions = [ const configTypeOptions = [
{ {
name: 'WiFi配网', name: t('deviceConfig.wifiConfig'),
value: 'wifi' as const, value: 'wifi' as const,
}, },
// { // {
// name: '超声波配网', // name: t('deviceConfig.ultrasonicConfig'),
// value: 'ultrasonic' as const, // value: 'ultrasonic' as const,
// }, // },
] ]
@@ -67,28 +68,36 @@ function onNetworkSelected(network: WiFiNetwork | null, password: string) {
function onConnectionStatusChange(connected: boolean) { function onConnectionStatusChange(connected: boolean) {
console.log('ESP32连接状态:', connected) console.log('ESP32连接状态:', connected)
} }
// 在组件挂载后设置导航栏标题
import { onMounted } from 'vue'
onMounted(() => {
uni.setNavigationBarTitle({
title: t('deviceConfig.pageTitle')
})
})
</script> </script>
<template> <template>
<view class="min-h-screen bg-[#f5f7fb]"> <view class="min-h-screen bg-[#f5f7fb]">
<wd-navbar title="设备配网" safe-area-inset-top /> <wd-navbar :title="t('deviceConfig.pageTitle')" safe-area-inset-top />
<view class="box-border px-[20rpx]"> <view class="box-border px-[20rpx]">
<!-- 配网方式选择 --> <!-- 配网方式选择 -->
<view class="pb-[20rpx] first:pt-[20rpx]"> <view class="pb-[20rpx] first:pt-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
配网方式 {{ t('deviceConfig.configMethod') }}
</text> </text>
</view> </view>
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);"> <view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:border-[#336cff] active:bg-[#eef3ff]" @click="showConfigTypeSelector"> <view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:border-[#336cff] active:bg-[#eef3ff]" @click="showConfigTypeSelector">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
配网方式 {{ t('deviceConfig.configMethod') }}
</text> </text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]"> <text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ configType === 'wifi' ? 'WiFi配网' : '超声波配网' }} {{ configType === 'wifi' ? t('deviceConfig.wifiConfig') : t('deviceConfig.ultrasonicConfig') }}
</text> </text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" /> <wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view> </view>
</view> </view>
@@ -96,8 +105,8 @@ function onConnectionStatusChange(connected: boolean) {
<!-- WiFi网络选择 --> <!-- WiFi网络选择 -->
<view class="pb-[20rpx]"> <view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
网络配置 {{ t('deviceConfig.networkConfig') }}
</text> </text>
</view> </view>
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);"> <view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
@@ -139,7 +148,7 @@ function onConnectionStatusChange(connected: boolean) {
<route lang="jsonc" type="page"> <route lang="jsonc" type="page">
{ {
"style": { "style": {
"navigationBarTitleText": "设备配", "navigationBarTitleText": "设备配",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
} }
+34 -33
View File
@@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue'
import { useMessage } from 'wot-design-uni' import { useMessage } from 'wot-design-uni'
import { bindDevice, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device' import { bindDevice, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { t } from '@/i18n'
defineOptions({ defineOptions({
name: 'DeviceManage', name: 'DeviceManage',
@@ -91,19 +92,19 @@ function getDeviceTypeName(boardKey: string): string {
// 格式化时间 // 格式化时间
function formatTime(timeStr: string) { function formatTime(timeStr: string) {
if (!timeStr) if (!timeStr)
return '从未连接' return t('device.neverConnected')
const date = new Date(timeStr) const date = new Date(timeStr)
const now = new Date() const now = new Date()
const diff = now.getTime() - date.getTime() const diff = now.getTime() - date.getTime()
if (diff < 60000) if (diff < 60000)
return '刚刚' return t('device.justNow')
if (diff < 3600000) if (diff < 3600000)
return `${Math.floor(diff / 60000)}分钟前` return t('device.minutesAgo', { minutes: Math.floor(diff / 60000) })
if (diff < 86400000) if (diff < 86400000)
return `${Math.floor(diff / 3600000)}小时前` return t('device.hoursAgo', { hours: Math.floor(diff / 3600000) })
if (diff < 604800000) if (diff < 604800000)
return `${Math.floor(diff / 86400000)}天前` return t('device.daysAgo', { days: Math.floor(diff / 86400000) })
return date.toLocaleDateString() return date.toLocaleDateString()
} }
@@ -114,11 +115,11 @@ async function toggleAutoUpdate(device: Device) {
const newStatus = device.autoUpdate === 1 ? 0 : 1 const newStatus = device.autoUpdate === 1 ? 0 : 1
await updateDeviceAutoUpdate(device.id, newStatus) await updateDeviceAutoUpdate(device.id, newStatus)
device.autoUpdate = newStatus device.autoUpdate = newStatus
toast.success(newStatus === 1 ? 'OTA自动升级已开启' : 'OTA自动升级已关闭') toast.success(newStatus === 1 ? t('device.otaAutoUpdateEnabled') : t('device.otaAutoUpdateDisabled'))
} }
catch (error: any) { catch (error: any) {
console.error('更新设备OTA状态失败:', error) console.error('更新设备OTA状态失败:', error)
toast.error('操作失败,请重试') toast.error(t('device.operationFailed'))
} }
} }
@@ -127,21 +128,21 @@ async function handleUnbindDevice(device: Device) {
try { try {
await unbindDevice(device.id) await unbindDevice(device.id)
await loadDeviceList() await loadDeviceList()
toast.success('设备已解绑') toast.success(t('device.deviceUnbound'))
} }
catch (error: any) { catch (error: any) {
console.error('解绑设备失败:', error) console.error('解绑设备失败:', error)
toast.error('解绑失败,请重试') toast.error(t('device.unbindFailed'))
} }
} }
// 确认解绑设备 // 确认解绑设备
function confirmUnbindDevice(device: Device) { function confirmUnbindDevice(device: Device) {
message.confirm({ message.confirm({
title: '解绑设备', title: t('device.unbindDevice'),
msg: `确定要解绑设备 "${device.macAddress}" 吗?`, msg: t('device.confirmUnbindDevice', { macAddress: device.macAddress }),
confirmButtonText: '确定解绑', confirmButtonText: t('device.confirmUnbind'),
cancelButtonText: '取消', cancelButtonText: t('device.cancel'),
}).then(() => { }).then(() => {
handleUnbindDevice(device) handleUnbindDevice(device)
}).catch(() => { }).catch(() => {
@@ -153,18 +154,18 @@ function confirmUnbindDevice(device: Device) {
async function handleBindDevice(code: string) { async function handleBindDevice(code: string) {
try { try {
if (!currentAgentId.value) { if (!currentAgentId.value) {
toast.error('请先选择智能体') toast.error(t('device.pleaseSelectAgent'))
return return
} }
await bindDevice(currentAgentId.value, code.trim()) await bindDevice(currentAgentId.value, code.trim())
await loadDeviceList() await loadDeviceList()
toast.success('设备绑定成功!') toast.success(t('device.deviceBindSuccess'))
} }
catch (error: any) { catch (error: any) {
console.error('绑定设备失败:', error) console.error('绑定设备失败:', error)
const errorMessage = error?.message || '绑定失败,请检查验证码是否正确' const errorMessage = error?.message || '绑定失败,请检查验证码是否正确'
toast.error(errorMessage) toast.error(errorMessage || t('device.bindFailed'))
} }
} }
@@ -172,12 +173,12 @@ async function handleBindDevice(code: string) {
function openBindDialog() { function openBindDialog() {
message message
.prompt({ .prompt({
title: '绑定设备', title: t('device.bindDevice'),
inputPlaceholder: '请输入设备验证码', inputPlaceholder: t('device.enterDeviceCode'),
inputValue: '', inputValue: '',
inputPattern: /^\d{6}$/, inputPattern: /^\d{6}$/,
confirmButtonText: '立即绑定', confirmButtonText: t('device.bindNow'),
cancelButtonText: '取消', cancelButtonText: t('device.cancel'),
}) })
.then(async (result: any) => { .then(async (result: any) => {
if (result.value && String(result.value).trim()) { if (result.value && String(result.value).trim()) {
@@ -219,7 +220,7 @@ defineExpose({
<view v-if="loading && deviceList.length === 0" class="loading-container"> <view v-if="loading && deviceList.length === 0" class="loading-container">
<wd-loading color="#336cff" /> <wd-loading color="#336cff" />
<text class="loading-text"> <text class="loading-text">
加载中... {{ t('device.loading') }}
</text> </text>
</view> </view>
@@ -240,19 +241,19 @@ defineExpose({
<view class="mb-[20rpx]"> <view class="mb-[20rpx]">
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]"> <text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
MAC地址{{ device.macAddress }} {{ t('device.macAddress') }}{{ device.macAddress }}
</text> </text>
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]"> <text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
固件版本{{ device.appVersion }} {{ t('device.firmwareVersion') }}{{ device.appVersion }}
</text> </text>
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]"> <text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
最近对话{{ formatTime(device.lastConnectedAt) }} {{ t('device.lastConnection') }}{{ formatTime(device.lastConnectedAt) }}
</text> </text>
</view> </view>
<view class="flex items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx]"> <view class="flex items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx]">
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
OTA升级 {{ t('device.otaUpdate') }}
</text> </text>
<wd-switch <wd-switch
:model-value="device.autoUpdate === 1" :model-value="device.autoUpdate === 1"
@@ -271,7 +272,7 @@ defineExpose({
@click.stop="confirmUnbindDevice(device)" @click.stop="confirmUnbindDevice(device)"
> >
<wd-icon name="delete" /> <wd-icon name="delete" />
<text>解绑</text> <text>{{ t('device.unbind') }}</text>
</view> </view>
</view> </view>
</template> </template>
@@ -285,10 +286,10 @@ defineExpose({
<view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center"> <view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center">
<wd-icon name="phone" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" /> <wd-icon name="phone" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" />
<text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium"> <text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium">
暂无设备 {{ t('device.noDevice') }}
</text> </text>
<text class="text-[26rpx] text-[#999999] leading-[1.5]"> <text class="text-[26rpx] text-[#999999] leading-[1.5]">
点击右下角 + 号绑定您的第一个设备 {{ t('device.clickToBindFirstDevice') }}
</text> </text>
</view> </view>
</view> </view>
+31 -24
View File
@@ -17,6 +17,7 @@ import { useMessage } from 'wot-design-uni'
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js' import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent' import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { t } from '@/i18n'
defineOptions({ defineOptions({
name: 'Home', name: 'Home',
@@ -78,11 +79,11 @@ async function handleCreateAgent(agentName: string) {
await createAgent({ agentName: agentName.trim() }) await createAgent({ agentName: agentName.trim() })
// 创建成功后刷新列表 // 创建成功后刷新列表
pagingRef.value.reload() pagingRef.value.reload()
toast.success(`智能体"${agentName}"创建成功!`) toast.success(`${t('home.agentName')}"${agentName}"${t('message.saveSuccess')}`)
} }
catch (error: any) { catch (error: any) {
console.error('创建智能体失败:', error) console.error('创建智能体失败:', error)
const errorMessage = error?.message || '创建失败,请重试' const errorMessage = error?.message || t('message.saveFail')
toast.error(errorMessage) toast.error(errorMessage)
} }
} }
@@ -93,11 +94,11 @@ async function handleDeleteAgent(agent: Agent) {
await deleteAgent(agent.id) await deleteAgent(agent.id)
// 删除成功后刷新列表 // 删除成功后刷新列表
pagingRef.value.reload() pagingRef.value.reload()
toast.success(`智能体"${agent.agentName}"已删除`) toast.success(`${t('home.agentName')}${t('message.deleteSuccess')}`)
} }
catch (error: any) { catch (error: any) {
console.error('删除智能体失败:', error) console.error('删除智能体失败:', error)
const errorMessage = error?.message || '删除失败,请重试' const errorMessage = error?.message || t('message.deleteFail')
toast.error(errorMessage) toast.error(errorMessage)
} }
} }
@@ -119,13 +120,14 @@ function handleCardClick(agent: Agent) {
function openCreateDialog() { function openCreateDialog() {
message message
.prompt({ .prompt({
title: '创建智能体', title: t('home.dialogTitle'),
msg: '', msg: '',
inputPlaceholder: '例如:客服助手、语音助理、知识问答', inputPlaceholder: t('home.inputPlaceholder'),
inputValue: '', inputValue: '',
inputPattern: /^[\u4E00-\u9FA5a-z0-9\s]{1,50}$/i, inputPattern: /^[\u4E00-\u9FA5a-z0-9\s]{1,50}$/i,
confirmButtonText: '立即创建', inputError: t('home.createError'),
cancelButtonText: '取消', confirmButtonText: t('home.createNow'),
cancelButtonText: t('common.cancel'),
}) })
.then(async (result: any) => { .then(async (result: any) => {
if (result.value && String(result.value).trim()) { if (result.value && String(result.value).trim()) {
@@ -144,12 +146,12 @@ function formatTime(timeStr: string) {
const diff = now.getTime() - date.getTime() const diff = now.getTime() - date.getTime()
if (diff < 60000) if (diff < 60000)
return '刚刚' return t('home.justNow')
if (diff < 3600000) if (diff < 3600000)
return `${Math.floor(diff / 60000)}分钟前` return `${Math.floor(diff / 60000)}${t('home.minutesAgo')}`
if (diff < 86400000) if (diff < 86400000)
return `${Math.floor(diff / 3600000)}小时前` return `${Math.floor(diff / 3600000)}${t('home.hoursAgo')}`
return `${Math.floor(diff / 86400000)}天前` return `${Math.floor(diff / 86400000)}${t('home.daysAgo')}`
} }
// 页面显示时刷新列表 // 页面显示时刷新列表
@@ -159,12 +161,20 @@ onShow(() => {
pagingRef.value.reload() pagingRef.value.reload()
} }
}) })
// 在组件挂载后设置导航栏标题
import { onMounted } from 'vue'
onMounted(() => {
uni.setNavigationBarTitle({
title: t('home.pageTitle')
})
})
</script> </script>
<template> <template>
<z-paging <z-paging
ref="pagingRef" v-model="agentList" :refresher-enabled="true" :auto-show-back-to-top="true" ref="pagingRef" v-model="agentList" :refresher-enabled="true" :auto-show-back-to-top="true"
:loading-more-enabled="false" :show-loading-more="false" :hide-empty-view="false" empty-view-text="暂无智能体" :loading-more-enabled="false" :show-loading-more="false" :hide-empty-view="false" :empty-view-text="t('home.emptyState')"
empty-view-img="" :refresher-threshold="80" :back-to-top-style="{ empty-view-img="" :refresher-threshold="80" :back-to-top-style="{
backgroundColor: '#fff', backgroundColor: '#fff',
borderRadius: '50%', borderRadius: '50%',
@@ -178,16 +188,13 @@ onShow(() => {
<view class="banner-content"> <view class="banner-content">
<view class="welcome-info"> <view class="welcome-info">
<text class="greeting"> <text class="greeting">
你好小智 {{ t('home.greeting') }}
</text> </text>
<text class="subtitle"> <text class="subtitle">
让我们度过 <text class="highlight"> {{ t('home.subtitle') }} <text class="highlight">
美好的一天 {{ t('home.wonderfulDay') }}
</text> </text>
</text> </text>
<text class="english-subtitle">
Hello, Let's have a wonderful day!
</text>
</view> </view>
<view class="wave-decoration"> <view class="wave-decoration">
<!-- 添加波浪装饰 --> <!-- 添加波浪装饰 -->
@@ -227,13 +234,13 @@ onShow(() => {
<view class="stat-chip"> <view class="stat-chip">
<wd-icon name="phone" custom-class="chip-icon" /> <wd-icon name="phone" custom-class="chip-icon" />
<text class="chip-text"> <text class="chip-text">
设备管理({{ agent.deviceCount }}) {{ t('home.deviceManagement') }}({{ agent.deviceCount }})
</text> </text>
</view> </view>
<view v-if="agent.lastConnectedAt" class="stat-chip"> <view v-if="agent.lastConnectedAt" class="stat-chip">
<wd-icon name="time" custom-class="chip-icon" /> <wd-icon name="time" custom-class="chip-icon" />
<text class="chip-text"> <text class="chip-text">
最近对话{{ formatTime(agent.lastConnectedAt) }} {{ t('home.lastConversation') }}{{ formatTime(agent.lastConnectedAt) }}
</text> </text>
</view> </view>
</view> </view>
@@ -247,7 +254,7 @@ onShow(() => {
<view class="swipe-actions"> <view class="swipe-actions">
<view class="action-btn delete-btn" @click.stop="handleDeleteAgent(agent)"> <view class="action-btn delete-btn" @click.stop="handleDeleteAgent(agent)">
<wd-icon name="delete" /> <wd-icon name="delete" />
<text>删除</text> <text>{{ t('home.delete') }}</text>
</view> </view>
</view> </view>
</template> </template>
@@ -260,10 +267,10 @@ onShow(() => {
<view class="empty-state"> <view class="empty-state">
<wd-icon name="robot" custom-class="empty-icon" /> <wd-icon name="robot" custom-class="empty-icon" />
<text class="empty-text"> <text class="empty-text">
暂无智能体 {{ t('home.emptyState') }}
</text> </text>
<text class="empty-desc"> <text class="empty-desc">
点击右下角 + 号创建您的第一个智能体 {{ t('home.createFirstAgent') }}
</text> </text>
</view> </view>
</template> </template>
+186 -47
View File
@@ -3,7 +3,7 @@
"layout": "default", "layout": "default",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "登陆" "navigationBarTitleText": "Login"
} }
} }
</route> </route>
@@ -15,6 +15,11 @@ import { login } from '@/api/auth'
import { useConfigStore } from '@/store' import { useConfigStore } from '@/store'
import { getEnvBaseUrl } from '@/utils' import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
// 导入国际化相关功能
import { t, changeLanguage, getSupportedLanguages, initI18n } from '@/i18n'
import type { Language } from '@/store/lang'
// 导入SM2加密工具
import { sm2Encrypt } from '@/utils'
// 获取屏幕边界到安全区域距离 // 获取屏幕边界到安全区域距离
let safeAreaInsets let safeAreaInsets
@@ -39,7 +44,7 @@ systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets safeAreaInsets = systemInfo.safeAreaInsets
// #endif // #endif
// 表单数据 // 表单数据
const formData = ref<LoginData>({ const formData = ref({
username: '', username: '',
password: '', password: '',
captcha: '', captcha: '',
@@ -73,6 +78,11 @@ const areaCodeList = computed(() => {
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }] return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
}) })
// SM2公钥
const sm2PublicKey = computed(() => {
return configStore.config.sm2PublicKey
})
// 切换登录方式 // 切换登录方式
function toggleLoginType() { function toggleLoginType() {
loginType.value = loginType.value === 'username' ? 'mobile' : 'username' loginType.value = loginType.value === 'username' ? 'mobile' : 'username'
@@ -136,40 +146,64 @@ async function handleLogin() {
// 表单验证 // 表单验证
if (loginType.value === 'username') { if (loginType.value === 'username') {
if (!formData.value.username) { if (!formData.value.username) {
toast.warning('请输入用户名') toast.warning(t('login.enterUsername'))
return return
} }
} }
else { else {
if (!formData.value.mobile) { if (!formData.value.mobile) {
toast.warning('请输入手机号') toast.warning(t('login.enterPhone'))
return return
} }
// 手机号格式验证 // 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/ const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(formData.value.mobile)) { if (!phoneRegex.test(formData.value.mobile)) {
toast.warning('请输入正确的手机号') toast.warning(t('login.enterPhone'))
return return
} }
} }
if (!formData.value.password) { if (!formData.value.password) {
toast.warning('请输入密码') toast.warning(t('login.enterPassword'))
return return
} }
if (!formData.value.captcha) { if (!formData.value.captcha) {
toast.warning('请输入验证码') toast.warning(t('login.enterCaptcha'))
return
}
// 检查SM2公钥是否配置
if (!sm2PublicKey.value) {
toast.warning(t('sm2.publicKeyNotConfigured'))
return return
} }
try { try {
loading.value = true loading.value = true
// 加密密码
let encryptedPassword
try {
// 拼接验证码和密码
const captchaAndPassword = formData.value.captcha + formData.value.password
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
} catch (error) {
console.error('密码加密失败:', error)
toast.warning(t('sm2.encryptionFailed'))
return
}
// 构建登录数据 // 构建登录数据
const loginData = { ...formData.value } const loginData: LoginData = {
username: '',
password: encryptedPassword,
captchaId: formData.value.captchaId
}
// 如果是手机号登录,将区号+手机号拼接到username字段 // 如果是手机号登录,将区号+手机号拼接到username字段
if (loginType.value === 'mobile') { if (loginType.value === 'mobile') {
loginData.username = `${selectedAreaCode.value}${formData.value.mobile}` loginData.username = `${selectedAreaCode.value}${formData.value.mobile}`
} else {
loginData.username = formData.value.username
} }
const response = await login(loginData) const response = await login(loginData)
@@ -177,7 +211,7 @@ async function handleLogin() {
uni.setStorageSync('token', response.token) uni.setStorageSync('token', response.token)
uni.setStorageSync('expire', response.expire) uni.setStorageSync('expire', response.expire)
toast.success('登录成功') toast.success(t('message.loginSuccess'))
// 跳转到主页 // 跳转到主页
setTimeout(() => { setTimeout(() => {
@@ -189,6 +223,14 @@ async function handleLogin() {
catch (error: any) { catch (error: any) {
// 登录失败重新获取验证码 // 登录失败重新获取验证码
refreshCaptcha() refreshCaptcha()
// 处理验证码错误 - 从error.message中解析错误码
if (error.message.includes('请求错误[10067]')) {
toast.warning(t('login.captchaError'))
}
// 处理账号或密码错误
else if (error.message.includes('请求错误[10004]')) {
toast.warning(t('message.passwordError'))
}
} }
finally { finally {
loading.value = false loading.value = false
@@ -200,14 +242,26 @@ onLoad(() => {
refreshCaptcha() refreshCaptcha()
}) })
// 语言切换相关
const showLanguageSheet = ref(false)
const supportedLanguages = getSupportedLanguages()
// 初始化国际化
initI18n()
// 切换语言
function handleLanguageChange(lang: Language) {
changeLanguage(lang)
showLanguageSheet.value = false
}
// 组件挂载时确保配置已加载 // 组件挂载时确保配置已加载
onMounted(async () => { onMounted(async () => {
if (!configStore.config.name) { if (!configStore.config.name) {
try { try {
await configStore.fetchPublicConfig() await configStore.fetchPublicConfig()
} } catch (error) {
catch (error) { console.error(t('login.fetchConfigError'), error)
console.error('获取配置失败:', error)
} }
} }
}) })
@@ -219,21 +273,25 @@ onMounted(async () => {
<view class="logo-section"> <view class="logo-section">
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" /> <wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
<text class="welcome-text"> <text class="welcome-text">
欢迎回来 {{ t('login.welcomeBack') }}
</text> </text>
<text class="subtitle"> <text class="subtitle">
请登录您的账户 {{ t('login.pleaseLogin') }}
</text> </text>
</view> </view>
</view> </view>
<!-- 右上角服务端设置按钮 --> <!-- 右上角按钮 -->
<view <view class="top-right-buttons" :style="{ top: `${safeAreaInsets?.top + 10}px` }">
class="server-btn" <!-- 语言切换按钮 -->
:style="{ top: `${safeAreaInsets?.top + 10}px` }" <view class="lang-btn" @click="showLanguageSheet = true">
@click="goToServerSetting" <text class="lang-text-icon">{{ t('login.selectLanguageTip') }}</text>
> </view>
<wd-icon name="setting" custom-class="server-icon" />
<!-- 服务端设置按钮 -->
<view class="server-btn" @click="goToServerSetting">
<wd-icon name="setting" custom-class="server-icon" />
</view>
</view> </view>
<view class="form-container"> <view class="form-container">
@@ -253,7 +311,7 @@ onMounted(async () => {
v-model="formData.mobile" v-model="formData.mobile"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入手机号码" :placeholder="t('login.enterPhone')"
type="number" type="number"
:maxlength="11" :maxlength="11"
/> />
@@ -270,7 +328,7 @@ onMounted(async () => {
v-model="formData.username" v-model="formData.username"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入用户名" :placeholder="t('login.enterUsername')"
/> />
</view> </view>
</view> </view>
@@ -282,7 +340,7 @@ onMounted(async () => {
v-model="formData.password" v-model="formData.password"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入密码" :placeholder="t('login.enterPassword')"
clearable clearable
show-password show-password
:maxlength="20" :maxlength="20"
@@ -296,7 +354,7 @@ onMounted(async () => {
v-model="formData.captcha" v-model="formData.captcha"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入验证码" :placeholder="t('login.enterCaptcha')"
:maxlength="6" :maxlength="6"
/> />
<view class="captcha-image" @click="refreshCaptcha"> <view class="captcha-image" @click="refreshCaptcha">
@@ -304,26 +362,19 @@ onMounted(async () => {
</view> </view>
</view> </view>
</view> </view>
<view class="forgot-password">
<text class="forgot-text">
忘记密码
</text>
</view>
<view <view
class="login-btn" class="login-btn"
@click="handleLogin" @click="handleLogin"
> >
{{ loading ? '登录中...' : '登录' }} {{ loading ? t('login.loggingIn') : t('login.loginButton') }}
</view> </view>
<view class="register-hint"> <view class="register-hint">
<text class="hint-text"> <text class="hint-text">
还没有账户 {{ t('login.noAccount') }}
</text> </text>
<text class="register-link" @click="goToRegister"> <text class="register-link" @click="goToRegister">
立即注册 {{ t('login.registerNow') }}
</text> </text>
</view> </view>
@@ -352,7 +403,7 @@ onMounted(async () => {
<!-- 区号选择弹窗 --> <!-- 区号选择弹窗 -->
<wd-action-sheet <wd-action-sheet
v-model="showAreaCodeSheet" v-model="showAreaCodeSheet"
title="选择国家/地区" :title="t('login.selectCountry')"
:close-on-click-modal="true" :close-on-click-modal="true"
@close="closeAreaCodeSheet" @close="closeAreaCodeSheet"
> >
@@ -386,11 +437,33 @@ onMounted(async () => {
custom-class="confirm-btn" custom-class="confirm-btn"
@click="closeAreaCodeSheet" @click="closeAreaCodeSheet"
> >
确认 {{ t('login.confirm') }}
</wd-button> </wd-button>
</view> </view>
</view> </view>
</wd-action-sheet> </wd-action-sheet>
<!-- 语言选择弹窗 -->
<wd-action-sheet
v-model="showLanguageSheet"
:title="t('login.selectLanguage')"
:close-on-click-modal="true"
>
<view class="language-sheet">
<scroll-view scroll-y class="language-list">
<view
v-for="lang in supportedLanguages"
:key="lang.code"
class="language-item"
@click="handleLanguageChange(lang.code)"
>
<text class="language-name">
{{ lang.name }}
</text>
</view>
</scroll-view>
</view>
</wd-action-sheet>
</view> </view>
</template> </template>
@@ -498,7 +571,7 @@ onMounted(async () => {
&.captcha-wrapper { &.captcha-wrapper {
.captcha-image { .captcha-image {
margin-left: 20rpx; margin-left: 20rpx;
width: 120rpx; width: 150rpx;
height: 60rpx; height: 60rpx;
border-radius: 8rpx; border-radius: 8rpx;
overflow: hidden; overflow: hidden;
@@ -793,20 +866,52 @@ onMounted(async () => {
} }
} }
} }
.server-btn { // 右上角按钮组
.top-right-buttons {
position: absolute; position: absolute;
right: 20rpx; // 距离右边距 right: 20rpx;
top: 40rpx; // 顶部稍微下移,不贴状态栏 display: flex;
gap: 20rpx;
z-index: 999;
}
// 语言切换按钮
.lang-btn {
width: 48rpx; width: 48rpx;
height: 48rpx; height: 48rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 999;
cursor: pointer; cursor: pointer;
background: rgba(255, 255, 255, 0.15); // 半透明背景,更好看 background: rgba(255, 255, 255, 0.15);
border-radius: 24rpx; // 圆形按钮 border-radius: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2); // 阴影 box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2);
&:active {
transform: scale(0.95);
}
.lang-text-icon {
font-size: 18rpx;
color: #FFFFFF;
}
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
// 服务端设置按钮
.server-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: rgba(255, 255, 255, 0.15);
border-radius: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2);
&:active { &:active {
transform: scale(0.95); transform: scale(0.95);
@@ -814,11 +919,45 @@ onMounted(async () => {
.server-icon { .server-icon {
font-size: 28rpx; font-size: 28rpx;
color: #FFFFFF; // 白色图标 color: #FFFFFF;
} }
&:hover { &:hover {
background: rgba(255, 255, 255, 0.25); // 悬停效果 background: rgba(255, 255, 255, 0.25);
}
}
// 语言选择弹窗样式
.language-sheet {
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
overflow: hidden;
.language-list {
max-height: 60vh;
padding: 0 40rpx;
.language-item {
display: flex;
align-items: center;
padding: 32rpx 0;
border-bottom: 1rpx solid #f8f9fa;
cursor: pointer;
transition: background-color 0.3s ease;
&:hover {
background-color: #f8f9fa;
}
&:last-child {
border-bottom: none;
}
.language-name {
font-size: 32rpx;
color: #333333;
}
}
} }
} }
</style> </style>
+126 -96
View File
@@ -14,6 +14,10 @@ import { register, sendSmsCode } from '@/api/auth'
import { useConfigStore } from '@/store' import { useConfigStore } from '@/store'
import { getEnvBaseUrl } from '@/utils' import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
// 导入国际化相关功能
import { t, initI18n } from '@/i18n'
// 导入SM2加密工具
import { sm2Encrypt } from '@/utils'
// 获取屏幕边界到安全区域距离 // 获取屏幕边界到安全区域距离
let safeAreaInsets let safeAreaInsets
@@ -88,6 +92,11 @@ const areaCodeList = computed(() => {
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }] return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
}) })
// SM2公钥
const sm2PublicKey = computed(() => {
return configStore.config.sm2PublicKey
})
// 切换注册方式 // 切换注册方式
function toggleRegisterType() { function toggleRegisterType() {
registerType.value = registerType.value === 'username' ? 'mobile' : 'username' registerType.value = registerType.value === 'username' ? 'mobile' : 'username'
@@ -134,18 +143,18 @@ async function refreshCaptcha() {
// 发送短信验证码 // 发送短信验证码
async function sendSmsVerification() { async function sendSmsVerification() {
if (!formData.value.mobile) { if (!formData.value.mobile) {
toast.warning('请输入手机号') toast.warning(t('register.enterPhone'))
return return
} }
if (!formData.value.captcha) { if (!formData.value.captcha) {
toast.warning('请输入图形验证码') toast.warning(t('register.enterCode'))
return return
} }
// 手机号格式验证 // 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/ const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(formData.value.mobile)) { if (!phoneRegex.test(formData.value.mobile)) {
toast.warning('请输入正确的手机号') toast.warning(t('register.enterPhone'))
return return
} }
@@ -157,7 +166,7 @@ async function sendSmsVerification() {
captchaId: formData.value.captchaId, captchaId: formData.value.captchaId,
}) })
toast.success('验证码发送成功') toast.success(t('register.captchaSendSuccess'))
// 开始倒计时 // 开始倒计时
smsCountdown.value = 60 smsCountdown.value = 60
@@ -169,6 +178,10 @@ async function sendSmsVerification() {
}, 1000) }, 1000)
} }
catch (error: any) { catch (error: any) {
// 处理验证码错误 - 从error.message中解析错误码
if (error.message.includes('请求错误[10067]')) {
toast.warning(t('login.captchaError'))
}
// 发送失败重新获取图形验证码 // 发送失败重新获取图形验证码
refreshCaptcha() refreshCaptcha()
} }
@@ -180,58 +193,76 @@ async function sendSmsVerification() {
// 注册 // 注册
async function handleRegister() { async function handleRegister() {
// 表单验证 // 表单验证
if (registerType.value === 'username') { if (enableMobileRegister.value) {
if (!formData.value.username) { // 手机号注册验证
toast.warning('请输入用户名')
return
}
}
else {
if (!formData.value.mobile) { if (!formData.value.mobile) {
toast.warning('请输入手机号') toast.warning(t('register.enterPhone'))
return return
} }
// 手机号格式验证 // 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/ const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(formData.value.mobile)) { if (!phoneRegex.test(formData.value.mobile)) {
toast.warning('请输入正确的手机号') toast.warning(t('register.enterPhone'))
return return
} }
if (!formData.value.mobileCaptcha) { if (!formData.value.mobileCaptcha) {
toast.warning('请输入短信验证码') toast.warning(t('register.enterCode'))
return
}
}
else {
// 用户名注册验证
if (!formData.value.username) {
toast.warning(t('register.enterUsername'))
return return
} }
} }
if (!formData.value.password) { if (!formData.value.password) {
toast.warning('请输入密码') toast.warning(t('register.enterPassword'))
return return
} }
if (!formData.value.confirmPassword) { if (!formData.value.confirmPassword) {
toast.warning('请确认密码') toast.warning(t('register.confirmPassword'))
return return
} }
if (formData.value.password !== formData.value.confirmPassword) { if (formData.value.password !== formData.value.confirmPassword) {
toast.warning('两次输入的密码不一致') toast.warning(t('register.confirmPassword'))
return return
} }
if (!formData.value.captcha) { if (!formData.value.captcha) {
toast.warning('请输入验证码') toast.warning(t('register.enterCode'))
return
}
// 检查SM2公钥是否配置
if (!sm2PublicKey.value) {
toast.warning(t('sm2.publicKeyNotConfigured'))
return return
} }
try { try {
loading.value = true loading.value = true
// 加密密码
let encryptedPassword
try {
// 拼接验证码和密码
const captchaAndPassword = formData.value.captcha + formData.value.password
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
} catch (error) {
console.error('密码加密失败:', error)
toast.warning(t('sm2.encryptionFailed'))
return
}
// 构建注册数据 // 构建注册数据
const registerData = { const registerData = {
username: registerType.value === 'mobile' ? `${selectedAreaCode.value}${formData.value.mobile}` : formData.value.username, username: enableMobileRegister.value ? `${selectedAreaCode.value}${formData.value.mobile}` : formData.value.username,
password: formData.value.password, password: encryptedPassword,
confirmPassword: formData.value.confirmPassword,
captcha: formData.value.captcha,
captchaId: formData.value.captchaId, captchaId: formData.value.captchaId,
areaCode: formData.value.areaCode, areaCode: formData.value.areaCode,
mobile: formData.value.mobile, mobile: formData.value.mobile,
@@ -239,14 +270,24 @@ async function handleRegister() {
} }
await register(registerData) await register(registerData)
toast.success('注册成功') toast.success(t('message.registerSuccess'))
// 跳转到登录页 // 跳转到登录页
setTimeout(() => { setTimeout(() => {
uni.navigateBack() uni.redirectTo({
url: '/pages/login/index'
})
}, 1000) }, 1000)
} }
catch (error: any) { catch (error: any) {
// 处理验证码错误 - 从error.message中解析错误码
if (error.message.includes('请求错误[10067]')) {
toast.warning(t('login.captchaError'))
}
// 处理手机号码已注册错误
else if (error.message.includes('请求错误[10070]')) {
toast.warning(t('message.phoneRegistered'))
}
// 注册失败重新获取验证码 // 注册失败重新获取验证码
refreshCaptcha() refreshCaptcha()
} }
@@ -257,7 +298,9 @@ async function handleRegister() {
// 返回登录 // 返回登录
function goBack() { function goBack() {
uni.navigateBack() uni.redirectTo({
url: '/pages/login/index'
})
} }
// 页面加载时获取验证码 // 页面加载时获取验证码
@@ -275,7 +318,11 @@ onMounted(async () => {
console.error('获取配置失败:', error) console.error('获取配置失败:', error)
} }
} }
// 初始化国际化
initI18n()
}) })
</script> </script>
<template> <template>
@@ -287,10 +334,10 @@ onMounted(async () => {
<view class="logo-section"> <view class="logo-section">
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" /> <wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
<text class="welcome-text"> <text class="welcome-text">
欢迎注册 {{ t('register.pageTitle') }}
</text> </text>
<text class="subtitle"> <text class="subtitle">
创建您的新账户 {{ t('register.createAccount') }}
</text> </text>
</view> </view>
</view> </view>
@@ -298,7 +345,7 @@ onMounted(async () => {
<view class="form-container"> <view class="form-container">
<view class="form"> <view class="form">
<!-- 手机号注册 --> <!-- 手机号注册 -->
<template v-if="registerType === 'mobile'"> <template v-if="enableMobileRegister">
<view class="input-group"> <view class="input-group">
<view class="input-wrapper mobile-wrapper"> <view class="input-wrapper mobile-wrapper">
<view class="area-code-selector" @click="openAreaCodeSheet"> <view class="area-code-selector" @click="openAreaCodeSheet">
@@ -312,7 +359,7 @@ onMounted(async () => {
v-model="formData.mobile" v-model="formData.mobile"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入手机号码" :placeholder="t('register.enterPhone')"
type="number" type="number"
:maxlength="11" :maxlength="11"
/> />
@@ -329,7 +376,7 @@ onMounted(async () => {
v-model="formData.username" v-model="formData.username"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入用户名" :placeholder="t('register.enterUsername')"
/> />
</view> </view>
</view> </view>
@@ -338,63 +385,63 @@ onMounted(async () => {
<view class="input-group"> <view class="input-group">
<view class="input-wrapper"> <view class="input-wrapper">
<wd-input <wd-input
v-model="formData.password" v-model="formData.password"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入密码" :placeholder="t('register.enterPassword')"
show-password show-password
:maxlength="20" :maxlength="20"
/> />
</view> </view>
</view> </view>
<view class="input-group"> <view class="input-group">
<view class="input-wrapper"> <view class="input-wrapper">
<wd-input <wd-input
v-model="formData.confirmPassword" v-model="formData.confirmPassword"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请确认密码" :placeholder="t('register.confirmPassword')"
show-password show-password
:maxlength="20" :maxlength="20"
/> />
</view> </view>
</view> </view>
<view class="input-group"> <view class="input-group">
<view class="input-wrapper captcha-wrapper"> <view class="input-wrapper captcha-wrapper">
<wd-input <wd-input
v-model="formData.captcha" v-model="formData.captcha"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入验证码" :placeholder="t('register.enterCode')"
:maxlength="6" :maxlength="6"
/> />
<view class="captcha-image" @click="refreshCaptcha"> <view class="captcha-image" @click="refreshCaptcha">
<image :src="captchaImage" class="captcha-img" /> <image :src="captchaImage" class="captcha-img" />
</view> </view>
</view> </view>
</view> </view>
<!-- 手机验证码输入框 --> <!-- 手机验证码输入框 -->
<view v-if="registerType === 'mobile'" class="input-group"> <view v-if="enableMobileRegister" class="input-group">
<view class="input-wrapper sms-wrapper"> <view class="input-wrapper sms-wrapper">
<wd-input <wd-input
v-model="formData.mobileCaptcha" v-model="formData.mobileCaptcha"
custom-class="styled-input" custom-class="styled-input"
no-border no-border
placeholder="请输入短信验证码" :placeholder="t('register.enterCode')"
type="number" type="number"
:maxlength="6" :maxlength="6"
/> />
<wd-button <wd-button
:loading="smsLoading" :loading="smsLoading"
:disabled="smsCountdown > 0" :disabled="smsCountdown > 0"
custom-class="sms-btn" custom-class="sms-btn"
@click="sendSmsVerification" @click="sendSmsVerification"
> >
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }} {{ smsCountdown > 0 ? `${smsCountdown}s` : t('register.getCode') }}
</wd-button> </wd-button>
</view> </view>
</view> </view>
@@ -404,44 +451,25 @@ onMounted(async () => {
:loading="loading" :loading="loading"
@click="handleRegister" @click="handleRegister"
> >
{{ loading ? '注册中...' : '注册' }} {{ loading ? t('register.registering') : t('register.registerButton') }}
</view> </view>
<view class="login-hint"> <view class="login-hint">
<text class="hint-text"> <text class="hint-text">
已有账户 {{ t('register.haveAccount') }}
</text> </text>
<text class="login-link" @click="goBack"> <text class="login-link" @click="goBack">
立即登录 {{ t('register.loginNow') }}
</text> </text>
</view> </view>
<!-- 注册方式切换 -->
<view v-if="enableMobileRegister" class="register-type-switch">
<view class="switch-tabs">
<view
class="switch-tab"
:class="{ active: registerType === 'username' }"
@click="toggleRegisterType"
>
<wd-icon name="user" />
</view>
<view
class="switch-tab"
:class="{ active: registerType === 'mobile' }"
@click="toggleRegisterType"
>
<wd-icon name="phone" />
</view>
</view>
</view>
</view> </view>
</view> </view>
<!-- 区号选择弹窗 --> <!-- 区号选择弹窗 -->
<wd-action-sheet <wd-action-sheet
v-model="showAreaCodeSheet" v-model="showAreaCodeSheet"
title="选择国家/地区" :title="t('register.selectCountry')"
:close-on-click-modal="true" :close-on-click-modal="true"
@close="closeAreaCodeSheet" @close="closeAreaCodeSheet"
> >
@@ -475,11 +503,13 @@ onMounted(async () => {
custom-class="confirm-btn" custom-class="confirm-btn"
@click="closeAreaCodeSheet" @click="closeAreaCodeSheet"
> >
确认 {{ t('register.confirm') }}
</wd-button> </wd-button>
</view> </view>
</view> </view>
</wd-action-sheet> </wd-action-sheet>
</view> </view>
</template> </template>
@@ -612,7 +642,7 @@ onMounted(async () => {
&.captcha-wrapper { &.captcha-wrapper {
.captcha-image { .captcha-image {
margin-left: 20rpx; margin-left: 20rpx;
width: 120rpx; width: 150rpx;
height: 60rpx; height: 60rpx;
border-radius: 8rpx; border-radius: 8rpx;
overflow: hidden; overflow: hidden;
+149 -56
View File
@@ -11,6 +11,8 @@ import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, se
import { isMp } from '@/utils/platform' import { isMp } from '@/utils/platform'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni' import { useToast } from 'wot-design-uni'
import { t, changeLanguage, getSupportedLanguages, getCurrentLanguage } from '@/i18n'
import type { Language } from '@/store/lang'
defineOptions({ defineOptions({
name: 'SettingsPage', name: 'SettingsPage',
@@ -62,7 +64,7 @@ function validateUrl() {
} }
if (!/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) { if (!/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) {
urlError.value = '请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)' urlError.value = t('settings.validServerUrl')
} }
} }
@@ -85,12 +87,12 @@ async function testServerBaseUrl() {
if (response.statusCode === 200) { if (response.statusCode === 200) {
return true return true
} else { } else {
toast.error('无效地址,请检查服务端是否启动或网络连接是否正常') toast.error(t('message.invalidAddress'))
return false return false
} }
} catch (error) { } catch (error) {
console.error('测试服务端地址失败:', error) console.error('测试服务端地址失败:', error)
toast.error('无效地址,请检查服务端是否启动或网络连接是否正常') toast.error(t('message.invalidAddress'))
return false return false
} }
} }
@@ -98,7 +100,7 @@ async function testServerBaseUrl() {
// 保存服务端地址 // 保存服务端地址
async function saveServerBaseUrl() { async function saveServerBaseUrl() {
if (!baseUrlInput.value || !/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) { if (!baseUrlInput.value || !/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) {
toast.warning('请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)') toast.warning(t('settings.validServerUrl'))
return return
} }
@@ -113,21 +115,32 @@ async function saveServerBaseUrl() {
clearAllCacheAfterUrlChange() clearAllCacheAfterUrlChange()
uni.showModal({ uni.showModal({
title: '重启应用', title: t('settings.restartApp'),
content: '服务端地址已保存并清空缓存,是否立即重启生效?', content: t('settings.serverUrlSavedAndCacheCleared'),
confirmText: '立即重启', confirmText: t('settings.restartNow'),
cancelText: '稍后', cancelText: t('settings.restartLater'),
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
restartApp() restartApp()
} }
else { else {
toast.success('已保存,可稍后手动重启应用') toast.success(t('settings.restartSuccess'))
} }
}, },
}) })
} }
// 语言切换
const supportedLanguages = getSupportedLanguages()
const currentLanguage = ref<Language>(getCurrentLanguage())
const showLanguageSheet = ref(false)
function handleLanguageChange(lang: Language) {
changeLanguage(lang)
showLanguageSheet.value = false
toast.success(t('settings.languageChanged'))
}
// 重置为 env 默认 // 重置为 env 默认
function resetServerBaseUrl() { function resetServerBaseUrl() {
clearServerBaseUrlOverride() clearServerBaseUrlOverride()
@@ -137,16 +150,16 @@ function resetServerBaseUrl() {
clearAllCacheAfterUrlChange() clearAllCacheAfterUrlChange()
uni.showModal({ uni.showModal({
title: '重启应用', title: t('settings.restartApp'),
content: '已重置为默认地址并清空缓存,是否立即重启生效?', content: t('settings.resetToDefaultAndCacheCleared'),
confirmText: '立即重启', confirmText: t('settings.restartNow'),
cancelText: '稍后', cancelText: t('settings.restartLater'),
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
restartApp() restartApp()
} }
else { else {
toast.success('已重置,可稍后手动重启应用') toast.success(t('settings.resetSuccess'))
} }
}, },
}) })
@@ -185,8 +198,7 @@ function clearAllCacheAfterUrlChange() {
// 重新获取缓存信息 // 重新获取缓存信息
getCacheInfo() getCacheInfo()
} } catch (error) {
catch (error) {
console.error('清除缓存失败:', error) console.error('清除缓存失败:', error)
} }
} }
@@ -195,12 +207,14 @@ function clearAllCacheAfterUrlChange() {
async function clearCache() { async function clearCache() {
try { try {
uni.showModal({ uni.showModal({
title: '确认清除', title: t('settings.confirmClear'),
content: '确定要清除所有缓存吗?这将删除所有数据包括登录状态,需要重新登录。', content: t('settings.confirmClearMessage'),
confirmText: t('common.confirm'),
cancelText: t('common.cancel'),
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
clearAllCacheAfterUrlChange() clearAllCacheAfterUrlChange()
toast.success('缓存清除成功,即将跳转到登录页') toast.success(t('settings.cacheCleared'))
// 延迟跳转到登录页 // 延迟跳转到登录页
setTimeout(() => { setTimeout(() => {
@@ -209,22 +223,22 @@ async function clearCache() {
} }
}, },
}) })
} } catch (error) {
catch (error) {
console.error('清除缓存失败:', error) console.error('清除缓存失败:', error)
toast.error('清除缓存失败') toast.error(t('settings.clearCacheFailed'))
} }
} }
// 关于我们 // 关于我们
function showAbout() { function showAbout() {
uni.showModal({ uni.showModal({
title: `关于${import.meta.env.VITE_APP_TITLE}`, title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
content: `${import.meta.env.VITE_APP_TITLE}\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server`, content: t('settings.aboutContent', {
title: `关于小智智控台`, appName: import.meta.env.VITE_APP_TITLE,
content: `小智智控台\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server 0.8.2`, version: '0.8.3'
}),
showCancel: false, showCancel: false,
confirmText: '确定', confirmText: t('common.confirm'),
}) })
} }
@@ -234,19 +248,24 @@ onMounted(async () => {
loadServerBaseUrl() loadServerBaseUrl()
} }
getCacheInfo() getCacheInfo()
// 动态设置导航栏标题为国际化文本
uni.setNavigationBarTitle({
title: t('settings.title')
})
}) })
</script> </script>
<template> <template>
<view class="min-h-screen bg-[#f5f7fb]"> <view class="min-h-screen bg-[#f5f7fb]">
<wd-navbar title="设置" placeholder safe-area-inset-top fixed /> <wd-navbar :title="t('settings.title')" placeholder safe-area-inset-top fixed />
<view class="p-[24rpx]"> <view class="p-[24rpx]">
<!-- 网络设置 - 仅在非小程序环境显示 --> <!-- 网络设置 - 仅在非小程序环境显示 -->
<view v-if="!isMp" class="mb-[32rpx]"> <view v-if="!isMp" class="mb-[32rpx]">
<view class="mb-[24rpx] flex items-center"> <view class="mb-[24rpx] flex items-center">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
网络设置 {{ t('settings.networkSettings') }}
</text> </text>
</view> </view>
@@ -254,19 +273,19 @@ onMounted(async () => {
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);"> style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
<view class="mb-[24rpx]"> <view class="mb-[24rpx]">
<text class="text-[28rpx] text-[#232338] font-semibold"> <text class="text-[28rpx] text-[#232338] font-semibold">
服务端接口地址 {{ t('settings.serverApiUrl') }}
</text> </text>
<text class="mt-[8rpx] block text-[24rpx] text-[#9d9ea3]"> <text class="mt-[8rpx] block text-[24rpx] text-[#9d9ea3]">
修改后将自动清空缓存并重启应用 {{ t('settings.modifyWillClearCache') }}
</text> </text>
</view> </view>
<view class="mb-[24rpx]"> <view class="mb-[24rpx]">
<view class="w-full rounded-[16rpx] border border-[#eeeeee] bg-[#f5f7fb] overflow-hidden"> <view class="w-full rounded-[16rpx] border border-[#eeeeee] bg-[#f5f7fb] overflow-hidden">
<wd-input v-model="baseUrlInput" type="text" clearable :maxlength="200" <wd-input v-model="baseUrlInput" type="text" clearable :maxlength="200"
placeholder="输入服务端地址,如 https://example.com/xiaozhi" :placeholder="t('settings.enterServerUrl')"
custom-class="!border-none !bg-transparent h-[88rpx] px-[24rpx] items-center" custom-class="!border-none !bg-transparent h-[64rpx] px-[24rpx] items-center"
input-class="text-[28rpx] text-[#232338]" @input="validateUrl" @blur="validateUrl" /> input-class="text-[28rpx] text-[#232338]" @input="validateUrl" @blur="validateUrl" />
</view> </view>
<text v-if="urlError" class="mt-[8rpx] block text-[24rpx] text-[#ff4d4f]"> <text v-if="urlError" class="mt-[8rpx] block text-[24rpx] text-[#ff4d4f]">
{{ urlError }} {{ urlError }}
@@ -277,12 +296,12 @@ onMounted(async () => {
<wd-button type="primary" <wd-button type="primary"
custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-[#336cff] border-none shadow-[0_4rpx_16rpx_rgba(51,108,255,0.3)] active:shadow-[0_2rpx_8rpx_rgba(51,108,255,0.4)] active:scale-98" custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-[#336cff] border-none shadow-[0_4rpx_16rpx_rgba(51,108,255,0.3)] active:shadow-[0_2rpx_8rpx_rgba(51,108,255,0.4)] active:scale-98"
@click="saveServerBaseUrl"> @click="saveServerBaseUrl">
保存设置 {{ t('settings.saveSettings') }}
</wd-button> </wd-button>
<wd-button type="default" <wd-button type="default"
custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-white border-[#eeeeee] text-[#65686f] active:bg-[#f5f7fb]" custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-white border-[#eeeeee] text-[#65686f] active:bg-[#f5f7fb]"
@click="resetServerBaseUrl"> @click="resetServerBaseUrl">
恢复默认 {{ t('settings.resetDefault') }}
</wd-button> </wd-button>
</view> </view>
</view> </view>
@@ -292,7 +311,7 @@ onMounted(async () => {
<view class="mb-[32rpx]"> <view class="mb-[32rpx]">
<view class="mb-[24rpx] flex items-center"> <view class="mb-[24rpx] flex items-center">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
缓存管理 {{ t('settings.cacheManagement') }}
</text> </text>
</view> </view>
@@ -304,11 +323,11 @@ onMounted(async () => {
class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]"> class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]">
<view> <view>
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
总缓存大小 {{ t('settings.totalCacheSize') }}
</text> </text>
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]"> <text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
应用数据总大小 {{ t('settings.appDataSize') }}
</text> </text>
</view> </view>
<text class="text-[28rpx] text-[#65686f] font-semibold"> <text class="text-[28rpx] text-[#65686f] font-semibold">
{{ cacheInfo.storageSize }} {{ cacheInfo.storageSize }}
@@ -320,16 +339,16 @@ onMounted(async () => {
class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx]"> class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx]">
<view> <view>
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
缓存清理 {{ t('settings.cacheClear') }}
</text> </text>
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]"> <text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
清空所有缓存数据 {{ t('settings.clearAllCache') }}
</text> </text>
</view> </view>
<view <view
class="cursor-pointer rounded-[24rpx] bg-[rgba(255,107,107,0.1)] px-[28rpx] py-[16rpx] text-[24rpx] text-[#ff6b6b] font-semibold transition-all duration-300 active:scale-95 active:bg-[#ff6b6b] active:text-white" class="cursor-pointer rounded-[24rpx] bg-[rgba(255,107,107,0.1)] px-[28rpx] py-[16rpx] text-[24rpx] text-[#ff6b6b] font-semibold transition-all duration-300 active:scale-95 active:bg-[#ff6b6b] active:text-white"
@click="clearCache"> @click="clearCache">
清除缓存 {{ t('settings.clearCache') }}
</view> </view>
</view> </view>
</view> </view>
@@ -340,8 +359,8 @@ onMounted(async () => {
<view class="mb-[32rpx]"> <view class="mb-[32rpx]">
<view class="mb-[24rpx] flex items-center"> <view class="mb-[24rpx] flex items-center">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
应用信息 {{ t('settings.appInfo') }}
</text> </text>
</view> </view>
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]" <view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
@@ -351,17 +370,68 @@ onMounted(async () => {
@click="showAbout"> @click="showAbout">
<view> <view>
<text class="text-[28rpx] text-[#232338] font-medium"> <text class="text-[28rpx] text-[#232338] font-medium">
关于我们 {{ t('settings.aboutUs') }}
</text> </text>
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]"> <text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
应用版本与团队信息 {{ t('settings.appVersion') }}
</text> </text>
</view> </view>
<wd-icon name="arrow-right" custom-class="text-[32rpx] text-[#9d9ea3]" /> <wd-icon name="arrow-right" custom-class="text-[32rpx] text-[#9d9ea3]" />
</view> </view>
</view> </view>
</view> </view>
<!-- 语言设置 -->
<view class="mb-[32rpx]">
<view class="mb-[24rpx] flex items-center">
<text class="text-[32rpx] text-[#232338] font-bold">
{{ t('settings.languageSettings') }}
</text>
</view>
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]" @click="showLanguageSheet = true">
<view>
<text class="text-[32rpx] text-[#232338] font-medium">
{{ t('settings.language') }}
</text>
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
{{ t('settings.selectLanguage') }}
</text>
</view>
<view class="flex items-center">
<text class="text-[32rpx] text-[#9d9ea3] font-semibold mr-[16rpx]">
{{ supportedLanguages.find(lang => lang.code === currentLanguage)?.name }}
</text>
<wd-icon name="arrow-right" custom-class="text-[32rpx] text-[#9d9ea3]" />
</view>
</view>
</view>
</view>
<!-- 语言选择弹窗 -->
<wd-action-sheet
v-model="showLanguageSheet"
:title="t('settings.selectLanguage')"
:close-on-click-modal="true"
>
<view class="language-sheet">
<scroll-view scroll-y class="language-list">
<view
v-for="lang in supportedLanguages"
:key="lang.code"
class="language-item"
@click="handleLanguageChange(lang.code)"
>
<text class="language-name">
{{ lang.name }}
</text>
</view>
</scroll-view>
</view>
</wd-action-sheet>
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<view style="height: env(safe-area-inset-bottom);" /> <view style="height: env(safe-area-inset-bottom);" />
@@ -370,4 +440,27 @@ onMounted(async () => {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
// 保持与 edit.vue 一致的风格,样式主要通过类名控制</style> // 保持与 edit.vue 一致的风格,样式主要通过类名控制
// 语言选择弹窗样式
.language-sheet {
.language-list {
max-height: 50vh;
.language-item {
padding: 30rpx 0;
text-align: center;
border-bottom: 1rpx solid #f0f0f0;
.language-name {
font-size: 28rpx;
color: #333;
}
&:last-child {
border-bottom: none;
}
&:active {
background-color: #f5f7fb;
}
}
}
}
</style>
@@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue'
import { useMessage } from 'wot-design-uni' import { useMessage } from 'wot-design-uni'
import { useToast } from 'wot-design-uni/components/wd-toast' import { useToast } from 'wot-design-uni/components/wd-toast'
import { createVoicePrint, deleteVoicePrint, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint' import { createVoicePrint, deleteVoicePrint, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
import { t } from '@/i18n'
defineOptions({ defineOptions({
name: 'VoicePrintManage', name: 'VoicePrintManage',
@@ -81,7 +82,7 @@ async function loadVoicePrintList() {
// 检查是否有当前选中的智能体 // 检查是否有当前选中的智能体
if (!currentAgentId.value) { if (!currentAgentId.value) {
console.warn('没有选中的智能体') console.warn(t('voiceprint.noSelectedAgent'))
voicePrintList.value = [] voicePrintList.value = []
return return
} }
@@ -117,7 +118,7 @@ async function refresh() {
async function loadChatHistory() { async function loadChatHistory() {
try { try {
if (!currentAgentId.value) { if (!currentAgentId.value) {
toast.error('请先选择智能体') toast.error(t('voiceprint.pleaseSelectAgent'))
return return
} }
@@ -133,24 +134,47 @@ async function loadChatHistory() {
} }
catch (error) { catch (error) {
console.error('获取对话记录失败:', error) console.error('获取对话记录失败:', error)
toast.error('获取对话记录失败') toast.error(t('voiceprint.fetchHistoryFailed'))
} }
} }
// 打开添加弹窗 // 打开添加弹窗
function openAddDialog() { function openAddDialog() {
if (!currentAgentId.value) { if (!currentAgentId.value) {
toast.error('请先选择智能体') toast.error(t('voiceprint.pleaseSelectAgent'))
return return
} }
addForm.value = { // 检查声纹接口是否配置(通过尝试获取声纹列表来检测)
agentId: currentAgentId.value, const checkVoicePrintConfig = async () => {
audioId: '', try {
sourceName: '', await getVoicePrintList(currentAgentId.value)
introduce: '', // 接口正常,继续打开添加弹窗
addForm.value = {
agentId: currentAgentId.value,
audioId: '',
sourceName: '',
introduce: '',
}
showAddDialog.value = true
} catch (error: any) {
// 捕捉声纹接口未配置错误
if (error.message && error.message.includes('请求错误[10054]')) {
toast.error(t('voiceprint.voiceprintInterfaceNotConfigured'))
} else {
// 其他错误,继续打开弹窗
addForm.value = {
agentId: currentAgentId.value,
audioId: '',
sourceName: '',
introduce: '',
}
showAddDialog.value = true
}
}
} }
showAddDialog.value = true
checkVoicePrintConfig()
} }
// 打开编辑弹窗 // 打开编辑弹窗
@@ -162,7 +186,7 @@ function openEditDialog(item: VoicePrint) {
// 获取选中音频的显示内容 // 获取选中音频的显示内容
function getSelectedAudioContent(audioId: string) { function getSelectedAudioContent(audioId: string) {
if (!audioId) if (!audioId)
return '点击选择声纹向量' return t('voiceprint.clickToSelectVector')
const chatItem = chatHistoryList.value.find(item => item.audioId === audioId) const chatItem = chatHistoryList.value.find(item => item.audioId === audioId)
return chatItem ? chatItem.content : `已选择: ${audioId.substring(0, 8)}...` return chatItem ? chatItem.content : `已选择: ${audioId.substring(0, 8)}...`
} }
@@ -181,34 +205,34 @@ function selectAudioId({ item }: { item: any }) {
// 提交添加说话人 // 提交添加说话人
async function submitAdd() { async function submitAdd() {
if (!addForm.value.sourceName.trim()) { if (!addForm.value.sourceName.trim()) {
toast.error('请输入姓名') toast.error(t('voiceprint.pleaseInputName'))
return return
} }
if (!addForm.value.audioId) { if (!addForm.value.audioId) {
toast.error('请选择声纹向量') toast.error(t('voiceprint.pleaseSelectVector'))
return return
} }
try { try {
await createVoicePrint(addForm.value) await createVoicePrint(addForm.value)
toast.success('添加成功') toast.success(t('voiceprint.addSuccess'))
showAddDialog.value = false showAddDialog.value = false
await loadVoicePrintList() await loadVoicePrintList()
} }
catch (error) { catch (error) {
console.error('添加说话人失败:', error) console.error('添加说话人失败:', error)
toast.error('添加说话人失败') toast.error(t('voiceprint.addFailed'))
} }
} }
// 提交编辑说话人 // 提交编辑说话人
async function submitEdit() { async function submitEdit() {
if (!editForm.value.sourceName.trim()) { if (!editForm.value.sourceName.trim()) {
toast.error('请输入姓名') toast.error(t('voiceprint.pleaseInputName'))
return return
} }
if (!editForm.value.audioId) { if (!editForm.value.audioId) {
toast.error('请选择声纹向量') toast.error(t('voiceprint.pleaseSelectVector'))
return return
} }
@@ -220,13 +244,13 @@ async function submitEdit() {
introduce: editForm.value.introduce, introduce: editForm.value.introduce,
createDate: editForm.value.createDate, createDate: editForm.value.createDate,
}) })
toast.success('编辑成功') toast.success(t('voiceprint.editSuccess'))
showEditDialog.value = false showEditDialog.value = false
await loadVoicePrintList() await loadVoicePrintList()
} }
catch (error) { catch (error) {
console.error('编辑说话人失败:', error) console.error('编辑说话人失败:', error)
toast.error('编辑说话人失败') toast.error(t('voiceprint.editFailed'))
} }
} }
@@ -239,11 +263,11 @@ function handleEdit(item: VoicePrint) {
// 删除声纹 // 删除声纹
async function handleDelete(id: string) { async function handleDelete(id: string) {
message.confirm({ message.confirm({
msg: '确定要删除这个说话人吗?', msg: t('voiceprint.deleteConfirmMsg'),
title: '确认删除', title: t('voiceprint.deleteConfirmTitle'),
}).then(async () => { }).then(async () => {
await deleteVoicePrint(id) await deleteVoicePrint(id)
toast.success('删除成功') toast.success(t('voiceprint.deleteSuccess'))
await loadVoicePrintList() await loadVoicePrintList()
}).catch(() => { }).catch(() => {
console.log('点击了取消按钮') console.log('点击了取消按钮')
@@ -268,7 +292,7 @@ defineExpose({
<view v-if="loading && voicePrintList.length === 0" class="loading-container"> <view v-if="loading && voicePrintList.length === 0" class="loading-container">
<wd-loading color="#336cff" /> <wd-loading color="#336cff" />
<text class="loading-text"> <text class="loading-text">
加载中... {{ t('voiceprint.loading') }}
</text> </text>
</view> </view>
@@ -302,7 +326,7 @@ defineExpose({
@click="handleDelete(item.id)" @click="handleDelete(item.id)"
> >
<wd-icon name="delete" /> <wd-icon name="delete" />
删除 {{ t('voiceprint.delete') }}
</view> </view>
</view> </view>
</template> </template>
@@ -315,11 +339,11 @@ defineExpose({
<view v-else-if="!loading" class="empty-container"> <view v-else-if="!loading" class="empty-container">
<view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center"> <view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center">
<wd-icon name="voice" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" /> <wd-icon name="voice" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" />
<text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium"> <text class="mb-[32rpx] text-[32rpx] text-[#666666] font-medium">
暂无声纹数据 {{ t('voiceprint.emptyTitle') }}
</text> </text>
<text class="text-[26rpx] text-[#999999] leading-[1.5]"> <text class="text-[26rpx] text-[#999999] leading-[1.5]">
点击右下角 + 号添加您的第一个说话人 {{ t('voiceprint.emptyDesc') }}
</text> </text>
</view> </view>
</view> </view>
@@ -341,7 +365,7 @@ defineExpose({
<view> <view>
<view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]"> <view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]">
<text class="w-full text-center text-[32rpx] text-[#232338] font-semibold"> <text class="w-full text-center text-[32rpx] text-[#232338] font-semibold">
添加说话人 {{ t('voiceprint.addSpeaker') }}
</text> </text>
</view> </view>
@@ -349,7 +373,7 @@ defineExpose({
<!-- 声纹向量选择 --> <!-- 声纹向量选择 -->
<view class="mb-[32rpx]"> <view class="mb-[32rpx]">
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 声纹向量 * {{ t('voiceprint.voiceVector') }}
</text> </text>
<view <view
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
@@ -368,22 +392,22 @@ defineExpose({
<!-- 姓名 --> <!-- 姓名 -->
<view class="mb-[32rpx]"> <view class="mb-[32rpx]">
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 姓名 * {{ t('voiceprint.name') }}
</text> </text>
<input <input
v-model="addForm.sourceName" v-model="addForm.sourceName"
class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text" placeholder="请输入姓名" type="text" :placeholder="t('voiceprint.pleaseInputName')"
> >
</view> </view>
<!-- 描述 --> <!-- 描述 -->
<view> <view>
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 描述 * {{ t('voiceprint.description') }}
</text> </text>
<textarea <textarea
v-model="addForm.introduce" :maxlength="100" placeholder="请输入描述" v-model="addForm.introduce" :maxlength="100" :placeholder="t('voiceprint.pleaseInputDescription')"
class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
/> />
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]"> <view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
@@ -394,11 +418,11 @@ defineExpose({
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]"> <view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
<wd-button type="info" custom-class="flex-1" @click="showAddDialog = false"> <wd-button type="info" custom-class="flex-1" @click="showAddDialog = false">
取消 {{ t('voiceprint.cancel') }}
</wd-button> </wd-button>
<wd-button type="primary" custom-class="flex-1" @click="submitAdd"> <wd-button type="primary" custom-class="flex-1" @click="submitAdd">
保存 {{ t('voiceprint.save') }}
</wd-button> </wd-button>
</view> </view>
</view> </view>
</wd-popup> </wd-popup>
@@ -411,7 +435,7 @@ defineExpose({
<view> <view>
<view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]"> <view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]">
<text class="w-full text-center text-[32rpx] text-[#232338] font-semibold"> <text class="w-full text-center text-[32rpx] text-[#232338] font-semibold">
编辑说话人 {{ t('voiceprint.editSpeaker') }}
</text> </text>
</view> </view>
@@ -419,7 +443,7 @@ defineExpose({
<!-- 声纹向量选择 --> <!-- 声纹向量选择 -->
<view class="mb-[32rpx]"> <view class="mb-[32rpx]">
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 声纹向量 * {{ t('voiceprint.voiceVector') }}
</text> </text>
<view <view
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
@@ -438,22 +462,22 @@ defineExpose({
<!-- 姓名 --> <!-- 姓名 -->
<view class="mb-[32rpx]"> <view class="mb-[32rpx]">
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 姓名 * {{ t('voiceprint.name') }}
</text> </text>
<input <input
v-model="editForm.sourceName" v-model="editForm.sourceName"
class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text" placeholder="请输入姓名" type="text" :placeholder="t('voiceprint.pleaseInputName')"
> >
</view> </view>
<!-- 描述 --> <!-- 描述 -->
<view> <view>
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium"> <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 描述 * {{ t('voiceprint.description') }}
</text> </text>
<textarea <textarea
v-model="editForm.introduce" :maxlength="100" placeholder="请输入描述" v-model="editForm.introduce" :maxlength="100" :placeholder="t('voiceprint.pleaseInputDescription')"
class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]" class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
/> />
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]"> <view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
@@ -464,18 +488,18 @@ defineExpose({
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]"> <view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
<wd-button type="info" custom-class="flex-1" @click="showEditDialog = false"> <wd-button type="info" custom-class="flex-1" @click="showEditDialog = false">
取消 {{ t('voiceprint.cancel') }}
</wd-button> </wd-button>
<wd-button type="primary" custom-class="flex-1" @click="submitEdit"> <wd-button type="primary" custom-class="flex-1" @click="submitEdit">
保存 {{ t('voiceprint.save') }}
</wd-button> </wd-button>
</view> </view>
</view> </view>
</wd-popup> </wd-popup>
<!-- 语音对话记录选择动作面板 --> <!-- 语音对话记录选择动作面板 -->
<wd-action-sheet <wd-action-sheet
v-model="showChatHistoryDialog" :actions="chatHistoryActions" title="选择声纹向量" v-model="showChatHistoryDialog" :actions="chatHistoryActions" :title="t('voiceprint.selectVector')"
@select="selectAudioId" @select="selectAudioId"
/> />
</template> </template>
+2 -1
View File
@@ -1,7 +1,7 @@
import type { PublicConfig } from '@/api/auth' import type { PublicConfig } from '@/api/auth'
import { getPublicConfig } from '@/api/auth'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { getPublicConfig } from '@/api/auth'
// 初始化状态 // 初始化状态
const initialConfigState: PublicConfig = { const initialConfigState: PublicConfig = {
@@ -12,6 +12,7 @@ const initialConfigState: PublicConfig = {
mobileAreaList: [], mobileAreaList: [],
beianIcpNum: '', beianIcpNum: '',
beianGaNum: '', beianGaNum: '',
sm2PublicKey: '',
name: import.meta.env.VITE_APP_TITLE, name: import.meta.env.VITE_APP_TITLE,
} }
+41
View File
@@ -0,0 +1,41 @@
import { ref } from 'vue'
import { ref } from 'vue'
import { defineStore } from 'pinia'
// 支持的语言类型
export type Language = 'zh_CN' | 'en' | 'zh_TW'
export interface LangStore {
currentLang: Language
changeLang: (lang: Language) => void
}
export const useLangStore = defineStore(
'lang',
(): LangStore => {
// 从本地存储获取语言设置,如果没有则使用默认值
const savedLang = uni.getStorageSync('app_language') as Language | null
const currentLang = ref<Language>(savedLang || 'zh_CN')
// 切换语言
const changeLang = (lang: Language) => {
currentLang.value = lang
// 将语言设置保存到本地存储
uni.setStorageSync('app_language', lang)
}
return {
currentLang,
changeLang,
}
},
{
persist: {
key: 'lang',
serializer: {
serialize: state => JSON.stringify(state.currentLang),
deserialize: value => ({ currentLang: JSON.parse(value) }),
},
},
},
)
+57
View File
@@ -196,3 +196,60 @@ export function getEnvBaseUploadUrl() {
return baseUploadUrl return baseUploadUrl
} }
import smCrypto from 'sm-crypto'
/**
* 生成SM2密钥对(十六进制格式)
* @returns {Object} 包含公钥和私钥的对象
*/
export function generateSm2KeyPairHex() {
// 使用sm-crypto库生成SM2密钥对
const sm2 = smCrypto.sm2;
const keypair = sm2.generateKeyPairHex();
return {
publicKey: keypair.publicKey,
privateKey: keypair.privateKey,
clientPublicKey: keypair.publicKey, // 客户端公钥
clientPrivateKey: keypair.privateKey // 客户端私钥
};
}
/**
* SM2公钥加密
* @param {string} publicKey 公钥(十六进制格式)
* @param {string} plainText 明文
* @returns {string} 加密后的密文(十六进制格式)
*/
export function sm2Encrypt(publicKey: string, plainText: string): string {
if (!publicKey) {
throw new Error('公钥不能为null或undefined');
}
if (!plainText) {
throw new Error('明文不能为空');
}
const sm2 = smCrypto.sm2;
// SM2加密,添加04前缀表示未压缩公钥
const encrypted = sm2.doEncrypt(plainText, publicKey, 1);
// 转换为十六进制格式(与后端保持一致,添加04前缀)
const result = "04" + encrypted;
return result;
}
/**
* SM2私钥解密
* @param {string} privateKey 私钥(十六进制格式)
* @param {string} cipherText 密文(十六进制格式)
* @returns {string} 解密后的明文
*/
export function sm2Decrypt(privateKey: string, cipherText: string): string {
const sm2 = smCrypto.sm2;
// 移除04前缀(与后端保持一致)
const dataWithoutPrefix = cipherText.startsWith("04") ? cipherText.substring(2) : cipherText;
// SM2解密
return sm2.doDecrypt(dataWithoutPrefix, privateKey, 1);
}
+43 -5
View File
@@ -10,13 +10,16 @@
"dependencies": { "dependencies": {
"core-js": "^3.41.0", "core-js": "^3.41.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"element-ui": "^2.15.14", "element-ui": "^2.15.14",
"flyio": "^0.6.14", "flyio": "^0.6.14",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7", "opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5", "opus-recorder": "^8.0.5",
"sm-crypto": "^0.3.13",
"vue": "^2.6.14", "vue": "^2.6.14",
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-i18n": "^8.28.2",
"vue-router": "^3.6.5", "vue-router": "^3.6.5",
"vuex": "^3.6.2", "vuex": "^3.6.2",
"xiaozhi": "file:" "xiaozhi": "file:"
@@ -2540,6 +2543,16 @@
} }
} }
}, },
"node_modules/@vue/cli-service/node_modules/dotenv": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-10.0.0.tgz",
"integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=10"
}
},
"node_modules/@vue/cli-shared-utils": { "node_modules/@vue/cli-shared-utils": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmmirror.com/@vue/cli-shared-utils/-/cli-shared-utils-5.0.8.tgz", "resolved": "https://registry.npmmirror.com/@vue/cli-shared-utils/-/cli-shared-utils-5.0.8.tgz",
@@ -4776,12 +4789,15 @@
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "10.0.0", "version": "16.6.1",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-10.0.0.tgz", "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true, "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=10" "node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
} }
}, },
"node_modules/dotenv-expand": { "node_modules/dotenv-expand": {
@@ -10114,6 +10130,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sm-crypto": {
"version": "0.3.13",
"resolved": "https://registry.npmmirror.com/sm-crypto/-/sm-crypto-0.3.13.tgz",
"integrity": "sha512-ztNF+pZq6viCPMA1A6KKu3bgpkmYti5avykRHbcFIdSipFdkVmfUw2CnpM2kBJyppIalqvczLNM3wR8OQ0pT5w==",
"license": "MIT",
"dependencies": {
"jsbn": "^1.1.0"
}
},
"node_modules/sm-crypto/node_modules/jsbn": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"license": "MIT"
},
"node_modules/smob": { "node_modules/smob": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmmirror.com/smob/-/smob-1.5.0.tgz", "resolved": "https://registry.npmmirror.com/smob/-/smob-1.5.0.tgz",
@@ -11131,6 +11162,13 @@
"integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
"dev": true "dev": true
}, },
"node_modules/vue-i18n": {
"version": "8.28.2",
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-8.28.2.tgz",
"integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA==",
"deprecated": "Vue I18n v8.x has reached EOL and is no longer actively maintained. About maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
"license": "MIT"
},
"node_modules/vue-loader": { "node_modules/vue-loader": {
"version": "17.4.2", "version": "17.4.2",
"resolved": "https://registry.npmmirror.com/vue-loader/-/vue-loader-17.4.2.tgz", "resolved": "https://registry.npmmirror.com/vue-loader/-/vue-loader-17.4.2.tgz",
+1
View File
@@ -16,6 +16,7 @@
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7", "opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5", "opus-recorder": "^8.0.5",
"sm-crypto": "^0.3.13",
"vue": "^2.6.14", "vue": "^2.6.14",
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-i18n": "^8.28.2", "vue-i18n": "^8.28.2",
+123 -6
View File
@@ -97,6 +97,24 @@ export default {
}); });
}).send(); }).send();
}, },
// 新增:获取智能体模板分页列表
getAgentTemplatesPage(params, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/template/page`)
.method('GET')
.data(params)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
console.error('获取模板分页列表失败:', err);
RequestService.reAjaxFun(() => {
this.getAgentTemplatesPage(params, callback);
});
}).send();
},
// 获取智能体会话列表 // 获取智能体会话列表
getAgentSessions(agentId, params, callback) { getAgentSessions(agentId, params, callback) {
RequestService.sendRequest() RequestService.sendRequest()
@@ -190,7 +208,7 @@ export default {
}).send(); }).send();
}, },
// 获取指定智能体声纹列表 // 获取指定智能体声纹列表
getAgentVoicePrintList(id,callback) { getAgentVoicePrintList(id, callback) {
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/voice-print/list/${id}`) .url(`${getServiceUrl()}/agent/voice-print/list/${id}`)
.method('GET') .method('GET')
@@ -200,7 +218,7 @@ export default {
}) })
.networkFail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getAgentVoicePrintList(id,callback); this.getAgentVoicePrintList(id, callback);
}); });
}).send(); }).send();
}, },
@@ -236,7 +254,7 @@ export default {
}).send(); }).send();
}, },
// 获取指定智能体用户类型聊天记录 // 获取指定智能体用户类型聊天记录
getRecentlyFiftyByAgentId(id,callback) { getRecentlyFiftyByAgentId(id, callback) {
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${id}/chat-history/user`) .url(`${getServiceUrl()}/agent/${id}/chat-history/user`)
.method('GET') .method('GET')
@@ -246,12 +264,12 @@ export default {
}) })
.networkFail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getRecentlyFiftyByAgentId(id,callback); this.getRecentlyFiftyByAgentId(id, callback);
}); });
}).send(); }).send();
}, },
// 获取指定智能体用户类型聊天记录 // 获取指定智能体用户类型聊天记录
getContentByAudioId(id,callback) { getContentByAudioId(id, callback) {
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${id}/chat-history/audio`) .url(`${getServiceUrl()}/agent/${id}/chat-history/audio`)
.method('GET') .method('GET')
@@ -261,7 +279,106 @@ export default {
}) })
.networkFail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getContentByAudioId(id,callback); this.getContentByAudioId(id, callback);
});
}).send();
},
// 在文件末尾(在最后一个方法后,大括号前)添加以下方法:
// 新增智能体模板
addAgentTemplate(templateData, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/template`)
.method('POST')
.data(templateData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.addAgentTemplate(templateData, callback);
});
}).send();
},
// 更新智能体模板
updateAgentTemplate(templateData, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/template`)
.method('PUT')
.data(templateData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.updateAgentTemplate(templateData, callback);
});
}).send();
},
// 删除智能体模板
deleteAgentTemplate(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/template/${id}`)
.method('DELETE')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.deleteAgentTemplate(id, callback);
});
}).send();
},
// 批量删除智能体模板
batchDeleteAgentTemplate(ids, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/template/batch-remove`) // 修改为新的URL
.method('POST')
.data(Array.isArray(ids) ? ids : [ids]) // 确保是数组格式
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.batchDeleteAgentTemplate(ids, callback);
});
}).send();
},
// 在getAgentTemplate方法后添加获取单个模板的方法
getAgentTemplateById(templateId, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/template/${templateId}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
console.error('获取单个模板失败:', err);
RequestService.reAjaxFun(() => {
this.getAgentTemplateById(templateId, callback);
});
}).send();
},
// 获取聊天记录下载链接UUID
getDownloadUrl(agentId, sessionId, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/chat-history/getDownloadUrl/${agentId}/${sessionId}`)
.method('POST')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getDownloadUrl(agentId, sessionId, callback);
}); });
}).send(); }).send();
}, },
+12 -4
View File
@@ -154,7 +154,7 @@ export default {
}).send() }).send()
}, },
// 获取公共配置 // 获取公共配置
getPubConfig(callback) { getPubConfig(callback, failCallback) {
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/user/pub-config`) .url(`${getServiceUrl()}/user/pub-config`)
.method('GET') .method('GET')
@@ -162,10 +162,16 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => {
RequestService.clearRequestTime();
if (failCallback) {
failCallback(err);
}
})
.networkFail((err) => { .networkFail((err) => {
console.error('获取公共配置失败:', err); console.error('获取公共配置失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getPubConfig(callback); this.getPubConfig(callback, failCallback);
}); });
}).send(); }).send();
}, },
@@ -177,7 +183,8 @@ export default {
.data({ .data({
phone: passwordData.phone, phone: passwordData.phone,
code: passwordData.code, code: passwordData.code,
password: passwordData.password password: passwordData.password,
captchaId: passwordData.captchaId
}) })
.success((res) => { .success((res) => {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
@@ -192,5 +199,6 @@ export default {
this.retrievePassword(passwordData, callback, failCallback); this.retrievePassword(passwordData, callback, failCallback);
}); });
}).send() }).send()
} },
} }
@@ -3,8 +3,8 @@
custom-class="custom-dialog" :show-close="false" class="center-dialog"> 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="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 style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
{{ $t('modelConfigDialog.addModel') }} {{ $t('modelConfigDialog.addModel') }}
</div> </div>
<button class="custom-close-btn" @click="handleClose"> <button class="custom-close-btn" @click="handleClose">
× ×
@@ -29,37 +29,43 @@
<el-form :model="formData" label-width="100px" label-position="left" class="custom-form"> <el-form :model="formData" label-width="100px" label-position="left" class="custom-form">
<div style="display: flex; gap: 20px; margin-bottom: 0;"> <div style="display: flex; gap: 20px; margin-bottom: 0;">
<el-form-item :label="$t('modelConfigDialog.modelName')" prop="modelName" style="flex: 1;"> <el-form-item :label="$t('modelConfigDialog.modelName')" prop="modelName" style="flex: 1;">
<el-input v-model="formData.modelName" :placeholder="$t('modelConfigDialog.enterModelName')" class="custom-input-bg"></el-input> <el-input v-model="formData.modelName" :placeholder="$t('modelConfigDialog.enterModelName')"
class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
<el-form-item :label="$t('modelConfigDialog.modelCode')" prop="modelCode" style="flex: 1;"> <el-form-item :label="$t('modelConfigDialog.modelCode')" prop="modelCode" style="flex: 1;">
<el-input v-model="formData.modelCode" :placeholder="$t('modelConfigDialog.enterModelCode')" class="custom-input-bg"></el-input> <el-input v-model="formData.modelCode" :placeholder="$t('modelConfigDialog.enterModelCode')"
class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</div> </div>
<div style="display: flex; gap: 20px; margin-bottom: 0;"> <div style="display: flex; gap: 20px; margin-bottom: 0;">
<el-form-item :label="$t('modelConfigDialog.supplier')" prop="supplier" style="flex: 1;"> <el-form-item :label="$t('modelConfigDialog.supplier')" prop="supplier" style="flex: 1;">
<el-select v-model="formData.supplier" :placeholder="$t('modelConfigDialog.selectSupplier')" class="custom-select custom-input-bg" <el-select v-model="formData.supplier" :placeholder="$t('modelConfigDialog.selectSupplier')"
style="width: 100%;" @focus="loadProviders" filterable> class="custom-select custom-input-bg" style="width: 100%;" @focus="loadProviders" filterable>
<el-option v-for="item in providers" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in providers" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="$t('modelConfigDialog.sortOrder')" prop="sortOrder" style="flex: 1;"> <el-form-item :label="$t('modelConfigDialog.sortOrder')" prop="sortOrder" style="flex: 1;">
<el-input v-model="formData.sort" type="number" :placeholder="$t('modelConfigDialog.enterSortOrder')" class="custom-input-bg"></el-input> <el-input v-model="formData.sort" type="number" :placeholder="$t('modelConfigDialog.enterSortOrder')"
class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</div> </div>
<el-form-item :label="$t('modelConfigDialog.docLink')" prop="docLink" style="margin-bottom: 27px;"> <el-form-item :label="$t('modelConfigDialog.docLink')" prop="docLink" style="margin-bottom: 27px;">
<el-input v-model="formData.docLink" :placeholder="$t('modelConfigDialog.enterDocLink')" class="custom-input-bg"></el-input> <el-input v-model="formData.docLink" :placeholder="$t('modelConfigDialog.enterDocLink')"
class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
<el-form-item :label="$t('modelConfigDialog.remark')" prop="remark" class="prop-remark"> <el-form-item :label="$t('modelConfigDialog.remark')" prop="remark" class="prop-remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" :placeholder="$t('modelConfigDialog.enterRemark')" :autosize="{ minRows: 3, maxRows: 5 }" <el-input v-model="formData.remark" type="textarea" :rows="3"
:placeholder="$t('modelConfigDialog.enterRemark')" :autosize="{ minRows: 3, maxRows: 5 }"
class="custom-input-bg"></el-input> class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px;">{{ $t('modelConfigDialog.callInfo') }}</div> <div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px;">{{
$t('modelConfigDialog.callInfo') }}</div>
<div style="height: 2px; background: #e9e9e9; margin-bottom: 22px;"></div> <div style="height: 2px; background: #e9e9e9; margin-bottom: 22px;"></div>
<el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form"> <el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form">
@@ -77,12 +83,7 @@
</div> </div>
<div style="display: flex;justify-content: center;"> <div style="display: flex;justify-content: center;">
<el-button <el-button type="primary" @click="confirm" class="save-btn" :loading="saving" :disabled="saving">
type="primary"
@click="confirm"
class="save-btn"
:loading="saving"
:disabled="saving">
{{ $t('modelConfigDialog.save') }} {{ $t('modelConfigDialog.save') }}
</el-button> </el-button>
</div> </div>
@@ -159,7 +160,7 @@ export default {
label: f.label, label: f.label,
prop: f.key, prop: f.key,
type: f.type === 'password' ? 'password' : 'text', type: f.type === 'password' ? 'password' : 'text',
placeholder: `请输入${f.label}` placeholder: `请输入${f.key}`
})) }))
})) }))
this.providersLoaded = true this.providersLoaded = true
@@ -1,5 +1,6 @@
<template> <template>
<el-dialog :title="$t('chatHistory.with') + agentName + $t('chatHistory.dialogTitle') + (currentMacAddress ? '[' + currentMacAddress + ']' : '')" <el-dialog
:title="$t('chatHistory.with') + agentName + $t('chatHistory.dialogTitle') + (currentMacAddress ? '[' + currentMacAddress + ']' : '')"
:visible.sync="dialogVisible" width="80%" :before-close="handleClose" custom-class="chat-history-dialog"> :visible.sync="dialogVisible" width="80%" :before-close="handleClose" custom-class="chat-history-dialog">
<div class="chat-container"> <div class="chat-container">
<div class="session-list" @scroll="handleScroll"> <div class="session-list" @scroll="handleScroll">
@@ -36,6 +37,14 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="currentSessionId" class="download-buttons">
<el-button type="primary" plain size="small" @click="downloadCurrentSessionWithPrevious">
{{ $t('chatHistory.downloadCurrentWithPreviousSessions') }}
</el-button>
<el-button type="primary" plain size="small" @click="downloadCurrentSession">
{{ $t('chatHistory.downloadCurrentSession') }}
</el-button>
</div>
</el-dialog> </el-dialog>
</template> </template>
@@ -292,6 +301,30 @@ export default {
// 返回对应的头像图片 // 返回对应的头像图片
return require(`@/assets/user-avatar${avatarIndex}.png`); return require(`@/assets/user-avatar${avatarIndex}.png`);
},
// 下载本会话聊天记录
downloadCurrentSession() {
Api.agent.getDownloadUrl(this.agentId, this.currentSessionId, (res) => {
if (res && res.data && res.data.code === 0 && res.data.data) {
const uuid = res.data.data;
window.open(`${Api.getServiceUrl()}/agent/chat-history/download/${uuid}/current`, '_blank');
} else {
this.$message.error(this.$t('chatHistory.downloadLinkFailed'));
}
});
},
// 下载本会话及前20条会话聊天记录
downloadCurrentSessionWithPrevious() {
Api.agent.getDownloadUrl(this.agentId, this.currentSessionId, (res) => {
if (res && res.data && res.data.code === 0 && res.data.data) {
const uuid = res.data.data;
window.open(`${Api.getServiceUrl()}/agent/chat-history/download/${uuid}/previous`, '_blank');
} else {
this.$message.error(this.$t('chatHistory.downloadLinkFailed'));
}
});
} }
} }
}; };
@@ -441,6 +474,22 @@ export default {
vertical-align: middle; vertical-align: middle;
margin: 0 10px; margin: 0 10px;
} }
.download-buttons {
padding: 20px;
display: flex;
gap: 10px;
border-top: 1px solid #eee;
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: white;
}
.download-buttons .el-button {
flex: 1;
}
</style> </style>
<style> <style>
@@ -453,7 +502,7 @@ export default {
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
height: 90vh; height: 98vh;
max-width: 85vw; max-width: 85vw;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
+194 -8
View File
@@ -35,11 +35,11 @@
<span class="nav-text">{{ $t('header.otaManagement') }}</span> <span class="nav-text">{{ $t('header.otaManagement') }}</span>
</div> </div>
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown" <el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' }" :class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' }"
@visible-change="handleParamDropdownVisibleChange"> @visible-change="handleParamDropdownVisibleChange">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png" <img loading="lazy" alt="" src="@/assets/header/param_management.png"
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' ? 'brightness(0) invert(1)' : 'None' }" /> :style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' ? 'brightness(0) invert(1)' : 'None' }" />
<span class="nav-text">{{ $t('header.paramDictionary') }}</span> <span class="nav-text">{{ $t('header.paramDictionary') }}</span>
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': paramDropdownVisible }"></i> <i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': paramDropdownVisible }"></i>
</span> </span>
@@ -53,6 +53,9 @@
<el-dropdown-item @click.native="goProviderManagement"> <el-dropdown-item @click.native="goProviderManagement">
{{ $t('header.providerManagement') }} {{ $t('header.providerManagement') }}
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item @click.native="goAgentTemplateManagement">
{{ $t('header.agentTemplate') }}
</el-dropdown-item>
<el-dropdown-item @click.native="goServerSideManagement"> <el-dropdown-item @click.native="goServerSideManagement">
{{ $t('header.serverSideManagement') }} {{ $t('header.serverSideManagement') }}
</el-dropdown-item> </el-dropdown-item>
@@ -63,10 +66,29 @@
<!-- 右侧元素 --> <!-- 右侧元素 -->
<div class="header-right"> <div class="header-right">
<div class="search-container" v-if="$route.path === '/home' && !(isSuperAdmin && isSmallScreen)"> <div class="search-container" v-if="$route.path === '/home' && !(isSuperAdmin && isSmallScreen)">
<el-input v-model="search" :placeholder="$t('header.searchPlaceholder')" class="custom-search-input" <div class="search-wrapper">
@keyup.enter.native="handleSearch"> <el-input v-model="search" :placeholder="$t('header.searchPlaceholder')" class="custom-search-input"
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i> @keyup.enter.native="handleSearch" @focus="showSearchHistory" @blur="hideSearchHistory" clearable
</el-input> ref="searchInput">
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
</el-input>
<!-- 搜索历史下拉框 -->
<div v-if="showHistory && searchHistory.length > 0" class="search-history-dropdown">
<div class="search-history-header">
<span>{{ $t('header.searchHistory') }}</span>
<el-button type="text" size="small" class="clear-history-btn" @click="clearSearchHistory">
{{ $t('header.clearHistory') }}
</el-button>
</div>
<div class="search-history-list">
<div v-for="(item, index) in searchHistory" :key="index" class="search-history-item"
@click.stop="selectSearchHistory(item)">
<span class="history-text">{{ item }}</span>
<i class="el-icon-close clear-item-icon" @click.stop="removeSearchHistory(index)"></i>
</div>
</div>
</div>
</div>
</div> </div>
<!-- 语言切换下拉菜单 --> <!-- 语言切换下拉菜单 -->
@@ -96,7 +118,7 @@
</span> </span>
<el-dropdown-menu slot="dropdown"> <el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="showChangePasswordDialog">{{ $t('header.changePassword') <el-dropdown-item @click.native="showChangePasswordDialog">{{ $t('header.changePassword')
}}</el-dropdown-item> }}</el-dropdown-item>
<el-dropdown-item @click.native="handleLogout">{{ $t('header.logout') }}</el-dropdown-item> <el-dropdown-item @click.native="handleLogout">{{ $t('header.logout') }}</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
@@ -131,7 +153,12 @@ export default {
userDropdownVisible: false, userDropdownVisible: false,
paramDropdownVisible: false, paramDropdownVisible: false,
languageDropdownVisible: false, languageDropdownVisible: false,
isSmallScreen: false isSmallScreen: false,
// 搜索历史相关
searchHistory: [],
showHistory: false,
SEARCH_HISTORY_KEY: 'xiaozhi_search_history',
MAX_HISTORY_COUNT: 3
} }
}, },
computed: { computed: {
@@ -162,6 +189,8 @@ export default {
this.fetchUserInfo(); this.fetchUserInfo();
this.checkScreenSize(); this.checkScreenSize();
window.addEventListener('resize', this.checkScreenSize); window.addEventListener('resize', this.checkScreenSize);
// 从localStorage加载搜索历史
this.loadSearchHistory();
}, },
//移除事件监听器 //移除事件监听器
beforeDestroy() { beforeDestroy() {
@@ -193,6 +222,10 @@ export default {
goServerSideManagement() { goServerSideManagement() {
this.$router.push('/server-side-management') this.$router.push('/server-side-management')
}, },
// 添加默认角色模板管理导航方法
goAgentTemplateManagement() {
this.$router.push('/agent-template-management')
},
// 获取用户信息 // 获取用户信息
fetchUserInfo() { fetchUserInfo() {
userApi.getUserInfo(({ data }) => { userApi.getUserInfo(({ data }) => {
@@ -215,6 +248,9 @@ export default {
return; return;
} }
// 保存搜索历史
this.saveSearchHistory(searchValue);
try { try {
// 创建不区分大小写的正则表达式 // 创建不区分大小写的正则表达式
const regex = new RegExp(searchValue, 'i'); const regex = new RegExp(searchValue, 'i');
@@ -227,6 +263,85 @@ export default {
showClose: true showClose: true
}); });
} }
// 搜索完成后让输入框失去焦点,从而触发blur事件隐藏搜索历史
if (this.$refs.searchInput) {
this.$refs.searchInput.blur();
}
},
// 显示搜索历史
showSearchHistory() {
this.showHistory = true;
},
// 隐藏搜索历史
hideSearchHistory() {
// 延迟隐藏,以便点击事件能够执行
setTimeout(() => {
this.showHistory = false;
}, 200);
},
// 加载搜索历史
loadSearchHistory() {
try {
const history = localStorage.getItem(this.SEARCH_HISTORY_KEY);
if (history) {
this.searchHistory = JSON.parse(history);
}
} catch (error) {
console.error('加载搜索历史失败:', error);
this.searchHistory = [];
}
},
// 保存搜索历史
saveSearchHistory(keyword) {
if (!keyword || this.searchHistory.includes(keyword)) {
return;
}
// 添加到历史记录开头
this.searchHistory.unshift(keyword);
// 限制历史记录数量
if (this.searchHistory.length > this.MAX_HISTORY_COUNT) {
this.searchHistory = this.searchHistory.slice(0, this.MAX_HISTORY_COUNT);
}
// 保存到localStorage
try {
localStorage.setItem(this.SEARCH_HISTORY_KEY, JSON.stringify(this.searchHistory));
} catch (error) {
console.error('保存搜索历史失败:', error);
}
},
// 选择搜索历史项
selectSearchHistory(keyword) {
this.search = keyword;
this.handleSearch();
},
// 移除单个搜索历史项
removeSearchHistory(index) {
this.searchHistory.splice(index, 1);
try {
localStorage.setItem(this.SEARCH_HISTORY_KEY, JSON.stringify(this.searchHistory));
} catch (error) {
console.error('更新搜索历史失败:', error);
}
},
// 清空所有搜索历史
clearSearchHistory() {
this.searchHistory = [];
try {
localStorage.removeItem(this.SEARCH_HISTORY_KEY);
} catch (error) {
console.error('清空搜索历史失败:', error);
}
}, },
// 显示修改密码弹窗 // 显示修改密码弹窗
showChangePasswordDialog() { showChangePasswordDialog() {
@@ -364,6 +479,77 @@ export default {
max-width: none; max-width: none;
} }
.search-wrapper {
position: relative;
}
.search-history-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #e4e6ef;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 1000;
margin-top: 2px;
}
.search-history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
color: #909399;
}
.clear-history-btn {
color: #909399;
font-size: 11px;
padding: 0;
height: auto;
}
.clear-history-btn:hover {
color: #606266;
}
.search-history-list {
max-height: 200px;
overflow-y: auto;
}
.search-history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: #606266;
}
.search-history-item:hover {
background-color: #f5f7fa;
}
.clear-item-icon {
font-size: 10px;
color: #909399;
visibility: hidden;
}
.search-history-item:hover .clear-item-icon {
visibility: visible;
}
.clear-item-icon:hover {
color: #ff4949;
}
.custom-search-input>>>.el-input__inner { .custom-search-input>>>.el-input__inner {
height: 18px; height: 18px;
border-radius: 9px; border-radius: 9px;
@@ -1,98 +1,132 @@
<template> <template>
<el-dialog :visible.sync="dialogVisible" :close-on-click-modal="false" width="57%" center custom-class="custom-dialog" :show-close="false" <el-dialog :visible.sync="dialogVisible" :close-on-click-modal="false" width="57%" center custom-class="custom-dialog"
class="center-dialog" > :show-close="false" class="center-dialog">
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;"> <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 style="
{{ modelData.duplicateMode ? $t('modelConfigDialog.duplicateModel') : $t('modelConfigDialog.editModel') }} font-size: 30px;
color: #3d4566;
margin-top: -10px;
margin-bottom: 10px;
text-align: center;
">
{{
modelData.duplicateMode
? $t("modelConfigDialog.duplicateModel")
: $t("modelConfigDialog.editModel")
}}
</div> </div>
<button class="custom-close-btn" @click="dialogVisible = false"> <button class="custom-close-btn" @click="dialogVisible = false">×</button>
×
</button>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <div style="
<div style="font-size: 20px; font-weight: bold; color: #3d4566;">{{ $t('modelConfigDialog.modelInfo') }}</div> display: flex;
<div style="display: flex; align-items: center; gap: 20px;"> justify-content: space-between;
<div style="display: flex; align-items: center;"> align-items: center;
<span style="margin-right: 8px;">{{ $t('modelConfigDialog.enable') }}</span> margin-bottom: 10px;
">
<div style="font-size: 20px; font-weight: bold; color: #3d4566">
{{ $t("modelConfigDialog.modelInfo") }}
</div>
<div style="display: flex; align-items: center; gap: 20px">
<div style="display: flex; align-items: center">
<span style="margin-right: 8px">{{ $t("modelConfigDialog.enable") }}</span>
<el-switch v-model="form.isEnabled" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch> <el-switch v-model="form.isEnabled" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch>
</div> </div>
<div style="display: none; align-items: center;"> <div style="display: none; align-items: center">
<span style="margin-right: 8px;">{{ $t('modelConfigDialog.setDefault') }}</span> <span style="margin-right: 8px">{{
$t("modelConfigDialog.setDefault")
}}</span>
<el-switch v-model="form.isDefault" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch> <el-switch v-model="form.isDefault" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch>
</div> </div>
</div> </div>
</div> </div>
<div style="height: 2px; background: #e9e9e9; margin-bottom: 22px;"></div> <div style="height: 2px; background: #e9e9e9; margin-bottom: 22px"></div>
<el-form :model="form" ref="form" label-width="auto" label-position="left" class="custom-form"> <el-form :model="form" ref="form" label-width="auto" label-position="left" class="custom-form">
<div style="display: flex; gap: 20px; margin-bottom: 0;"> <div style="display: flex; gap: 20px; margin-bottom: 0">
<el-form-item :label="$t('modelConfigDialog.modelName')" prop="name" style="flex: 1;"> <el-form-item :label="$t('modelConfigDialog.modelName')" prop="name" style="flex: 1">
<el-input v-model="form.modelName" :placeholder="$t('modelConfigDialog.enterModelName')" class="custom-input-bg"></el-input> <el-input v-model="form.modelName" :placeholder="$t('modelConfigDialog.enterModelName')"
class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
<el-form-item :label="$t('modelConfigDialog.modelCode')" prop="code" style="flex: 1;"> <el-form-item :label="$t('modelConfigDialog.modelCode')" prop="code" style="flex: 1">
<el-input v-model="form.modelCode" :placeholder="$t('modelConfigDialog.enterModelCode')" class="custom-input-bg"></el-input> <el-input v-model="form.modelCode" :placeholder="$t('modelConfigDialog.enterModelCode')"
class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</div> </div>
<div style="display: flex; gap: 20px; margin-bottom: 0;"> <div style="display: flex; gap: 20px; margin-bottom: 0">
<el-form-item :label="$t('modelConfigDialog.supplier')" prop="supplier" style="flex: 1;"> <el-form-item :label="$t('modelConfigDialog.supplier')" prop="supplier" style="flex: 1">
<el-select v-model="form.configJson.type" :placeholder="$t('modelConfigDialog.selectSupplier')" class="custom-select custom-input-bg" <el-select v-model="form.configJson.type" :placeholder="$t('modelConfigDialog.selectSupplier')"
style="width: 100%;" @focus="loadProviders" filterable> class="custom-select custom-input-bg" style="width: 100%" @focus="loadProviders" filterable>
<el-option v-for="item in providers" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in providers" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="$t('modelConfigDialog.sortOrder')" prop="sort" style="flex: 1;"> <el-form-item :label="$t('modelConfigDialog.sortOrder')" prop="sort" style="flex: 1">
<el-input v-model.number="form.sort" type="number" :placeholder="$t('modelConfigDialog.enterSortOrder')" class="custom-input-bg"></el-input> <el-input v-model.number="form.sort" type="number" :placeholder="$t('modelConfigDialog.enterSortOrder')"
class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</div> </div>
<el-form-item :label="$t('modelConfigDialog.docLink')" prop="docUrl" style="margin-bottom: 27px;"> <el-form-item :label="$t('modelConfigDialog.docLink')" prop="docUrl" style="margin-bottom: 27px">
<el-input v-model="form.docLink" :placeholder="$t('modelConfigDialog.enterDocLink')" class="custom-input-bg"></el-input> <el-input v-model="form.docLink" :placeholder="$t('modelConfigDialog.enterDocLink')"
class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
<el-form-item :label="$t('modelConfigDialog.remark')" prop="remark" class="prop-remark"> <el-form-item :label="$t('modelConfigDialog.remark')" prop="remark" class="prop-remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="$t('modelConfigDialog.enterRemark')" :autosize="{ minRows: 3, maxRows: 5 }" <el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="$t('modelConfigDialog.enterRemark')"
class="custom-input-bg"></el-input> :autosize="{ minRows: 3, maxRows: 5 }" class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px;">{{ $t('modelConfigDialog.callInfo') }}</div> <div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px">
<div style="height: 2px; background: #e9e9e9; margin-bottom: 22px;"></div> {{ $t("modelConfigDialog.callInfo") }}
</div>
<div style="height: 2px; background: #e9e9e9; margin-bottom: 22px"></div>
<el-form :model="form.configJson" ref="callInfoForm" label-width="auto" class="custom-form"> <el-form :model="form.configJson" ref="callInfoForm" label-width="auto" class="custom-form">
<template v-for="(row, rowIndex) in chunkedCallInfoFields"> <template v-for="(row, rowIndex) in chunkedCallInfoFields">
<div :key="rowIndex" style="display: flex; gap: 20px; margin-bottom: 0;"> <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;"> style="flex: 1">
<template v-if="field.type === 'json-textarea'"> <template v-if="field.type === 'json-textarea'">
<el-input v-model="fieldJsonMap[field.prop]" type="textarea" :rows="3" :placeholder="$t('modelConfigDialog.enterJsonExample')" <el-input v-model="fieldJsonMap[field.prop]" type="textarea" :rows="3"
class="custom-input-bg" @change="(val) => handleJsonChange(field.prop, val)"></el-input> :placeholder="$t('modelConfigDialog.enterJsonExample')" class="custom-input-bg"
@change="(val) => handleJsonChange(field.prop, val)" @focus="
isSensitiveField(field.prop)
? handleJsonInputFocus(field.prop, fieldJsonMap[field.prop])
: undefined
" @blur="
isSensitiveField(field.prop)
? handleJsonInputBlur(field.prop)
: undefined
"></el-input>
</template> </template>
<el-input v-else v-model="form.configJson[field.prop]" :placeholder="field.placeholder" :type="field.type" <el-input v-else v-model="form.configJson[field.prop]" :placeholder="field.placeholder" :type="field.type"
class="custom-input-bg" :show-password="field.type === 'password'"></el-input> class="custom-input-bg" :show-password="field.type === 'password'" @focus="
isSensitiveField(field.prop)
? handleInputFocus(field.prop, form.configJson[field.prop])
: undefined
" @blur="
isSensitiveField(field.prop) ? handleInputBlur(field.prop) : undefined
"></el-input>
</el-form-item> </el-form-item>
</div> </div>
</template> </template>
</el-form> </el-form>
</div> </div>
<div style="display: flex;justify-content: center;"> <div style="display: flex; justify-content: center">
<el-button <el-button type="primary" @click="handleSave" class="save-btn" :loading="saving" :disabled="saving">
type="primary" {{ $t("modelConfigDialog.save") }}
@click="handleSave"
class="save-btn"
:loading="saving"
:disabled="saving">
{{ $t('modelConfigDialog.save') }}
</el-button> </el-button>
</div> </div>
</el-dialog> </el-dialog>
</template> </template>
<script> <script>
import Api from '@/apis/api'; import Api from "@/apis/api";
export default { export default {
name: "ModelEditDialog", name: "ModelEditDialog",
@@ -101,9 +135,9 @@ export default {
modelData: { modelData: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
validator: value => typeof value === 'object' && !Array.isArray(value) validator: (value) => typeof value === "object" && !Array.isArray(value),
}, },
modelType: { type: String, required: true } modelType: { type: String, required: true },
}, },
data() { data() {
return { return {
@@ -116,6 +150,16 @@ export default {
pendingModelData: null, pendingModelData: null,
dynamicCallInfoFields: [], dynamicCallInfoFields: [],
fieldJsonMap: {}, // 用于存储JSON字段的字符串形式 fieldJsonMap: {}, // 用于存储JSON字段的字符串形式
sensitive_keys: [
"api_key",
"personal_access_token",
"access_token",
"token",
"secret",
"access_key_secret",
"secret_key",
],
originalValues: {}, // 存储原始值,用于失焦时恢复
form: { form: {
id: "", id: "",
modelType: "", modelType: "",
@@ -126,8 +170,8 @@ export default {
docLink: "", docLink: "",
remark: "", remark: "",
sort: 0, sort: 0,
configJson: {} configJson: {},
} },
}; };
}, },
computed: { computed: {
@@ -146,7 +190,7 @@ export default {
this.loadProviders(); this.loadProviders();
}, },
dialogVisible(val) { dialogVisible(val) {
this.$emit('update:visible', val); this.$emit("update:visible", val);
if (!val) { if (!val) {
this.resetForm(); this.resetForm();
} else if (val && this.modelData.id) { } else if (val && this.modelData.id) {
@@ -159,11 +203,11 @@ export default {
this.loadProviders(); this.loadProviders();
} }
}, },
'form.configJson.type'(newVal) { "form.configJson.type"(newVal) {
if (newVal && this.providersLoaded) { if (newVal && this.providersLoaded) {
this.loadProviderFields(newVal); this.loadProviderFields(newVal);
} }
} },
}, },
methods: { methods: {
resetForm() { resetForm() {
@@ -177,7 +221,7 @@ export default {
docLink: "", docLink: "",
remark: "", remark: "",
sort: 0, sort: 0,
configJson: {} configJson: {},
}; };
this.fieldJsonMap = {}; this.fieldJsonMap = {};
}, },
@@ -189,11 +233,23 @@ export default {
if (this.modelData.id) { if (this.modelData.id) {
Api.model.getModelConfig(this.modelData.id, ({ data }) => { Api.model.getModelConfig(this.modelData.id, ({ data }) => {
if (data.code === 0 && data.data) { if (data.code === 0 && data.data) {
const model = data.data; let model = data.data;
if (this.modelData.duplicateMode) { if (this.modelData.duplicateMode) {
model.modelName = this.modelData.modelName + this.$t('modelConfigDialog.copySuffix'); model.modelName =
model.modelCode = this.modelData.modelCode + this.$t('modelConfigDialog.copySuffix'); this.modelData.modelName + this.$t("modelConfigDialog.copySuffix");
model.modelCode =
this.modelData.modelCode + this.$t("modelConfigDialog.copySuffix");
// 处理敏感字段
if (model.configJson) {
Object.keys(model.configJson).forEach((key) => {
if (this.isSensitiveField(key) && model.configJson[key]) {
const sensitiveName = this.getSensitiveFieldName(key);
model.configJson[key] = `你的${sensitiveName}`;
}
});
}
} }
this.pendingProviderType = model.configJson.type; this.pendingProviderType = model.configJson.type;
this.pendingModelData = model; this.pendingModelData = model;
@@ -211,7 +267,7 @@ export default {
this.saving = true; // 开始保存加载 this.saving = true; // 开始保存加载
// 处理所有JSON字段 // 处理所有JSON字段
Object.keys(this.fieldJsonMap).forEach(key => { Object.keys(this.fieldJsonMap).forEach((key) => {
const parsed = this.validateJson(this.fieldJsonMap[key]); const parsed = this.validateJson(this.fieldJsonMap[key]);
if (parsed !== null) { if (parsed !== null) {
this.form.configJson[key] = parsed; this.form.configJson[key] = parsed;
@@ -227,7 +283,7 @@ export default {
docLink: this.form.docLink, docLink: this.form.docLink,
remark: this.form.remark, remark: this.form.remark,
sort: this.form.sort || 0, sort: this.form.sort || 0,
configJson: { ...this.form.configJson } configJson: { ...this.form.configJson },
}; };
this.$emit("save", { this.$emit("save", {
@@ -235,7 +291,7 @@ export default {
formData, formData,
done: () => { done: () => {
this.saving = false; // 保存完成后回调 this.saving = false; // 保存完成后回调
} },
}); });
// 如果父组件不处理done回调,3秒后自动关闭加载状态 // 如果父组件不处理done回调,3秒后自动关闭加载状态
@@ -247,9 +303,9 @@ export default {
if (this.providersLoaded) return; if (this.providersLoaded) return;
Api.model.getModelProviders(this.modelType, (data) => { Api.model.getModelProviders(this.modelType, (data) => {
this.providers = data.map(item => ({ this.providers = data.map((item) => ({
label: item.name, label: item.name,
value: String(item.providerCode) value: String(item.providerCode),
})); }));
this.providersLoaded = true; this.providersLoaded = true;
this.allProvidersData = data; this.allProvidersData = data;
@@ -261,13 +317,20 @@ export default {
}, },
loadProviderFields(providerCode) { loadProviderFields(providerCode) {
if (this.allProvidersData) { if (this.allProvidersData) {
const provider = this.allProvidersData.find(p => p.providerCode === providerCode); const provider = this.allProvidersData.find(
(p) => p.providerCode === providerCode
);
if (provider) { if (provider) {
this.dynamicCallInfoFields = JSON.parse(provider.fields || '[]').map(f => ({ this.dynamicCallInfoFields = JSON.parse(provider.fields || "[]").map((f) => ({
label: f.label, label: f.label,
prop: f.key, prop: f.key,
type: f.type === 'dict' ? 'json-textarea' : (f.type === 'password' ? 'password' : 'text'), type:
placeholder: `请输入${f.label}` f.type === "dict"
? "json-textarea"
: f.type === "password"
? "password"
: "text",
placeholder: `请输入${f.key}`,
})); }));
if (this.pendingModelData && this.pendingProviderType === providerCode) { if (this.pendingModelData && this.pendingProviderType === providerCode) {
@@ -280,13 +343,17 @@ export default {
}, },
processModelData(model) { processModelData(model) {
let configJson = model.configJson || {}; let configJson = model.configJson || {};
this.dynamicCallInfoFields.forEach(field => { this.dynamicCallInfoFields.forEach((field) => {
if (!configJson.hasOwnProperty(field.prop)) { if (!configJson.hasOwnProperty(field.prop)) {
configJson[field.prop] = ''; configJson[field.prop] = "";
} else if (field.type === 'json-textarea') { } else if (field.type === "json-textarea") {
this.$set(this.fieldJsonMap, field.prop, this.formatJson(configJson[field.prop])); this.$set(
this.fieldJsonMap,
field.prop,
this.formatJson(configJson[field.prop])
);
configJson[field.prop] = this.ensureObject(configJson[field.prop]); configJson[field.prop] = this.ensureObject(configJson[field.prop]);
} else if (typeof configJson[field.prop] !== 'string') { } else if (typeof configJson[field.prop] !== "string") {
configJson[field.prop] = String(configJson[field.prop]); configJson[field.prop] = String(configJson[field.prop]);
} }
}); });
@@ -301,7 +368,7 @@ export default {
docLink: model.docLink, docLink: model.docLink,
remark: model.remark, remark: model.remark,
sort: Number(model.sort) || 0, sort: Number(model.sort) || 0,
configJson: { ...configJson } configJson: { ...configJson },
}; };
}, },
handleJsonChange(field, value) { handleJsonChange(field, value) {
@@ -313,18 +380,18 @@ export default {
validateJson(value) { validateJson(value) {
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed; return parsed;
} }
this.$message.error({ this.$message.error({
message: '必须输入字典格式(如 {"key":"value"}),保存则使用原数据', message: '必须输入字典格式(如 {"key":"value"}),保存则使用原数据',
showClose: true showClose: true,
}); });
return null; return null;
} catch (e) { } catch (e) {
this.$message.error({ this.$message.error({
message: 'JSON格式错误(如 {"key":"value"}),保存则使用原数据', message: 'JSON格式错误(如 {"key":"value"}),保存则使用原数据',
showClose: true showClose: true,
}); });
return null; return null;
} }
@@ -333,13 +400,82 @@ export default {
try { try {
return JSON.stringify(obj, null, 2); return JSON.stringify(obj, null, 2);
} catch { } catch {
return ''; return "";
} }
}, },
ensureObject(value) { ensureObject(value) {
return typeof value === 'object' ? value : {}; return typeof value === "object" ? value : {};
} },
}
// 检测字段是否为敏感字段
isSensitiveField(fieldName) {
// 将字段名转换为小写进行比较
const lowerFieldName = fieldName.toLowerCase();
// 精确匹配keyMap中定义的7个敏感词
return this.sensitive_keys.includes(lowerFieldName);
},
// 获取敏感字段对应的中文名称
getSensitiveFieldName(fieldName) {
const keyMap = {
api_key: "API密钥",
personal_access_token: "个人访问令牌",
access_token: "访问令牌",
token: "令牌",
secret: "密钥",
access_key_secret: "访问密钥",
secret_key: "密钥",
};
for (const [key, value] of Object.entries(keyMap)) {
if (fieldName.toLowerCase().includes(key)) {
return value;
}
}
return "敏感信息";
},
// 处理input聚焦事件
handleInputFocus(field, value) {
// 如果值包含星号,清空显示
if (value && value.includes("*")) {
// 存储原始值,用于失焦时恢复
this.$set(this.originalValues, field, this.form.configJson[field]);
this.$set(this.form.configJson, field, "");
}
},
// 处理input失焦事件
handleInputBlur(field) {
// 检查是否为敏感字段
if (this.isSensitiveField(field)) {
// 如果值为空,恢复掩码值
if (!this.form.configJson[field] || this.form.configJson[field].trim() === "") {
// 如果有原始值,则恢复原始值;否则设置为掩码提示
if (this.originalValues[field]) {
this.$set(this.form.configJson, field, this.originalValues[field]);
} else {
const sensitiveName = this.getSensitiveFieldName(field);
this.$set(this.form.configJson, field, `你的${sensitiveName}`);
}
// 清除临时存储的原始值
this.$delete(this.originalValues, field);
}
}
},
// 处理JSON字段的聚焦事件
handleJsonInputFocus(field, value) {
if (value && value.includes("*")) {
this.$set(this.fieldJsonMap, field, "");
}
},
// 处理JSON字段的失焦事件
handleJsonInputBlur(field) {
// JSON字段不做特殊处理,因为它们通常不包含简单的敏感信息
},
},
}; };
</script> </script>
@@ -385,8 +521,8 @@ export default {
} }
.custom-close-btn:hover { .custom-close-btn:hover {
color: #409EFF; color: #409eff;
border-color: #409EFF; border-color: #409eff;
} }
.custom-select .el-input__suffix { .custom-select .el-input__suffix {
@@ -475,7 +611,7 @@ export default {
background-color: white; background-color: white;
top: 3px; top: 3px;
left: 4px; left: 4px;
transition: all .3s; transition: all 0.3s;
} }
.custom-switch.is-checked .el-switch__core { .custom-switch.is-checked .el-switch__core {
@@ -508,4 +644,4 @@ export default {
text-align: right; text-align: right;
padding-right: 20px; padding-right: 20px;
} }
</style> </style>
+120 -54
View File
@@ -5,7 +5,7 @@ export default {
'login.requiredCaptcha': 'Captcha cannot be empty', 'login.requiredCaptcha': 'Captcha cannot be empty',
'login.requiredMobile': 'Please enter a valid mobile phone number', 'login.requiredMobile': 'Please enter a valid mobile phone number',
'login.loginSuccess': 'Login successful!', 'login.loginSuccess': 'Login successful!',
// HeaderBar组件文本 // HeaderBar组件文本
'header.smartManagement': 'Agents', 'header.smartManagement': 'Agents',
'header.modelConfig': 'Models', 'header.modelConfig': 'Models',
@@ -14,6 +14,9 @@ export default {
'header.paramDictionary': 'More', 'header.paramDictionary': 'More',
'header.paramManagement': 'Params Management', 'header.paramManagement': 'Params Management',
'header.dictManagement': 'Dict Management', 'header.dictManagement': 'Dict Management',
'header.agentTemplate': 'Default Role Templates', // 添加这一行
'header.searchHistory': 'Search History',
'header.clearHistory': 'Clear History',
// McpToolCallDialog component text // McpToolCallDialog component text
'mcpToolCall.title': 'Tool Call', 'mcpToolCall.title': 'Tool Call',
@@ -37,7 +40,7 @@ export default {
'mcpToolCall.copyResult': 'Copy Result', 'mcpToolCall.copyResult': 'Copy Result',
'mcpToolCall.noResultYet': 'No result yet', 'mcpToolCall.noResultYet': 'No result yet',
'mcpToolCall.loadingToolList': 'Loading tool list...', 'mcpToolCall.loadingToolList': 'Loading tool list...',
// Tool names // Tool names
'mcpToolCall.toolName.getDeviceStatus': 'View Device Status', 'mcpToolCall.toolName.getDeviceStatus': 'View Device Status',
'mcpToolCall.toolName.setVolume': 'Set Volume', 'mcpToolCall.toolName.setVolume': 'Set Volume',
@@ -51,7 +54,7 @@ export default {
'mcpToolCall.toolName.snapshot': 'Screen Snapshot', 'mcpToolCall.toolName.snapshot': 'Screen Snapshot',
'mcpToolCall.toolName.previewImage': 'Preview Image', 'mcpToolCall.toolName.previewImage': 'Preview Image',
'mcpToolCall.toolName.setDownloadUrl': 'Set Download URL', 'mcpToolCall.toolName.setDownloadUrl': 'Set Download URL',
// Tool categories // Tool categories
'mcpToolCall.category.audio': 'Audio', 'mcpToolCall.category.audio': 'Audio',
'mcpToolCall.category.display': 'Display', 'mcpToolCall.category.display': 'Display',
@@ -59,7 +62,7 @@ export default {
'mcpToolCall.category.system': 'System', 'mcpToolCall.category.system': 'System',
'mcpToolCall.category.assets': 'Assets', 'mcpToolCall.category.assets': 'Assets',
'mcpToolCall.category.deviceInfo': 'Device Info', 'mcpToolCall.category.deviceInfo': 'Device Info',
// Table categories and properties // Table categories and properties
'mcpToolCall.table.audioSpeaker': 'Audio Speaker', 'mcpToolCall.table.audioSpeaker': 'Audio Speaker',
'mcpToolCall.table.screen': 'Screen', 'mcpToolCall.table.screen': 'Screen',
@@ -79,7 +82,7 @@ export default {
'mcpToolCall.table.component': 'Component', 'mcpToolCall.table.component': 'Component',
'mcpToolCall.table.property': 'Property', 'mcpToolCall.table.property': 'Property',
'mcpToolCall.table.value': 'Value', 'mcpToolCall.table.value': 'Value',
'mcpToolCall.prop.volume': 'Volume', 'mcpToolCall.prop.volume': 'Volume',
'mcpToolCall.prop.brightness': 'Brightness', 'mcpToolCall.prop.brightness': 'Brightness',
'mcpToolCall.prop.theme': 'Theme', 'mcpToolCall.prop.theme': 'Theme',
@@ -111,7 +114,7 @@ export default {
'mcpToolCall.prop.url': 'URL', 'mcpToolCall.prop.url': 'URL',
'mcpToolCall.prop.quality': 'Quality', 'mcpToolCall.prop.quality': 'Quality',
'mcpToolCall.prop.question': 'Question', 'mcpToolCall.prop.question': 'Question',
// Tool help texts // Tool help texts
'mcpToolCall.help.getDeviceStatus': 'View the current running status of the device, including volume, screen, battery and other information.', 'mcpToolCall.help.getDeviceStatus': 'View the current running status of the device, including volume, screen, battery and other information.',
'mcpToolCall.help.setVolume': 'Adjust the volume of the device, please enter a value between 0-100.', 'mcpToolCall.help.setVolume': 'Adjust the volume of the device, please enter a value between 0-100.',
@@ -125,7 +128,7 @@ export default {
'mcpToolCall.help.snapshot': 'Take a screenshot of the current screen and upload it to the specified URL.', 'mcpToolCall.help.snapshot': 'Take a screenshot of the current screen and upload it to the specified URL.',
'mcpToolCall.help.previewImage': 'Preview images from the specified URL on the device screen.', 'mcpToolCall.help.previewImage': 'Preview images from the specified URL on the device screen.',
'mcpToolCall.help.setDownloadUrl': 'Set the download address for device resource files.', 'mcpToolCall.help.setDownloadUrl': 'Set the download address for device resource files.',
// Other text // Other text
'mcpToolCall.text.strong': 'Strong', 'mcpToolCall.text.strong': 'Strong',
'mcpToolCall.text.medium': 'Medium', 'mcpToolCall.text.medium': 'Medium',
@@ -230,7 +233,7 @@ export default {
// AddModelDialog component related // AddModelDialog component related
'addModelDialog.requiredSupplier': 'Please select a supplier', 'addModelDialog.requiredSupplier': 'Please select a supplier',
// Register page related // Register page related
'register.title': 'Create Account', 'register.title': 'Create Account',
'register.welcome': 'Welcome to XiaoZhi AI', 'register.welcome': 'Welcome to XiaoZhi AI',
@@ -383,10 +386,12 @@ export default {
'register.registerSuccess': 'Registration successful!', 'register.registerSuccess': 'Registration successful!',
'register.registerFailed': 'Registration failed', 'register.registerFailed': 'Registration failed',
'register.passwordsNotMatch': 'The two passwords do not match', 'register.passwordsNotMatch': 'The two passwords do not match',
'register.secondsLater': 'seconds later',
// Retrieve password page text // Retrieve password page text
'retrievePassword.title': 'Reset Password', 'retrievePassword.title': 'Reset Password',
'retrievePassword.welcome': 'PASSWORD RETRIEVE', 'retrievePassword.welcome': 'PASSWORD RETRIEVE',
'retrievePassword.subtitle': 'Retrieve Password',
'retrievePassword.mobile': 'Mobile', 'retrievePassword.mobile': 'Mobile',
'retrievePassword.mobilePlaceholder': 'Please enter mobile number', 'retrievePassword.mobilePlaceholder': 'Please enter mobile number',
'retrievePassword.captcha': 'Verification Code', 'retrievePassword.captcha': 'Verification Code',
@@ -398,6 +403,7 @@ export default {
'retrievePassword.confirmNewPassword': 'Confirm New Password', 'retrievePassword.confirmNewPassword': 'Confirm New Password',
'retrievePassword.confirmNewPasswordPlaceholder': 'Please confirm new password', 'retrievePassword.confirmNewPasswordPlaceholder': 'Please confirm new password',
'retrievePassword.getMobileCaptcha': 'Get Verification Code', 'retrievePassword.getMobileCaptcha': 'Get Verification Code',
'retrievePassword.resetButton': 'Reset Password',
'retrievePassword.updateButton': 'Update Now', 'retrievePassword.updateButton': 'Update Now',
'retrievePassword.goToLogin': 'Back to Login', 'retrievePassword.goToLogin': 'Back to Login',
'retrievePassword.inputCorrectMobile': 'Please enter correct mobile number', 'retrievePassword.inputCorrectMobile': 'Please enter correct mobile number',
@@ -410,6 +416,7 @@ export default {
'retrievePassword.newPasswordRequired': 'New password cannot be empty', 'retrievePassword.newPasswordRequired': 'New password cannot be empty',
'retrievePassword.confirmNewPasswordRequired': 'Confirm new password cannot be empty', 'retrievePassword.confirmNewPasswordRequired': 'Confirm new password cannot be empty',
'retrievePassword.passwordUpdateSuccess': 'Password updated successfully', 'retrievePassword.passwordUpdateSuccess': 'Password updated successfully',
'retrievePassword.agreeTo': 'Reset means agree',
// Change password page text // Change password page text
'changePassword.title': 'Change Password', 'changePassword.title': 'Change Password',
@@ -495,6 +502,9 @@ export default {
'chatHistory.selectSession': 'Please select a session to view chat history', 'chatHistory.selectSession': 'Please select a session to view chat history',
'chatHistory.today': 'Today', 'chatHistory.today': 'Today',
'chatHistory.yesterday': 'Yesterday', 'chatHistory.yesterday': 'Yesterday',
'chatHistory.downloadCurrentSession': 'Download current session chat history',
'chatHistory.downloadCurrentWithPreviousSessions': 'Download current and previous 20 sessions chat history',
'chatHistory.downloadLinkFailed': 'Failed to get download link',
'cache.status': 'Cache Status', 'cache.status': 'Cache Status',
'cache.cdnEnabled': 'CDN Mode Enabled', 'cache.cdnEnabled': 'CDN Mode Enabled',
'cache.cdnDisabled': 'CDN Mode Disabled', 'cache.cdnDisabled': 'CDN Mode Disabled',
@@ -586,42 +596,6 @@ export default {
'user.info': 'User Information', 'user.info': 'User Information',
'user.username': 'Username', 'user.username': 'Username',
'user.mobile': 'Mobile Phone', 'user.mobile': 'Mobile Phone',
// Provider Management Page Text
'providerManagement.categoryFilter': 'Category Filter',
'providerManagement.searchPlaceholder': 'Please enter provider name to search',
'providerManagement.category': 'Category',
'providerManagement.providerCode': 'Provider Code',
'providerManagement.fieldConfig': 'Field Configuration',
'providerManagement.selectToDelete': 'Please select providers to delete first',
'providerManagement.confirmDelete': 'Are you sure to delete the selected {count} providers?',
'providerManagement.viewFields': 'View Fields',
// Common Text
'common.all': 'All',
'common.search': 'Search',
'common.name': 'Name',
'common.sort': 'Sort',
'common.action': 'Action',
'common.edit': 'Edit',
'common.delete': 'Delete',
'common.selectAll': 'Select All',
'common.deselectAll': 'Deselect All',
'common.add': 'Add',
'common.perPage': '{number}/page',
'common.firstPage': 'First Page',
'common.prevPage': 'Previous Page',
'common.nextPage': 'Next Page',
'common.totalRecords': 'Total {number} records',
'common.addProvider': 'Add Provider',
'common.editProvider': 'Edit Provider',
'common.updateSuccess': 'Update Success',
'common.addSuccess': 'Add Success',
'common.deleteSuccess': 'Delete Success',
'common.deleteFailure': 'Delete Failed, Please Try Again',
'common.deleteCancelled': 'Delete Cancelled',
'common.warning': 'Warning',
'common.confirm': 'Confirm',
'common.cancel': 'Cancel',
'common.sensitive': 'Sensitive',
'user.userid': 'User ID', 'user.userid': 'User ID',
'user.deviceCount': 'Device Count', 'user.deviceCount': 'Device Count',
'user.createDate': 'Registration Time', 'user.createDate': 'Registration Time',
@@ -647,6 +621,7 @@ export default {
'user.deleteCancelled': 'Deletion cancelled', 'user.deleteCancelled': 'Deletion cancelled',
'user.confirmResetPassword': 'A new password will be generated after reset. Continue?', 'user.confirmResetPassword': 'A new password will be generated after reset. Continue?',
'user.resetPasswordSuccess': 'Password has been reset, please notify the user to log in with the new password', 'user.resetPasswordSuccess': 'Password has been reset, please notify the user to log in with the new password',
'user.generatedPassword': 'Generated Default Password',
'user.confirmDeleteUser': 'Are you sure you want to delete this user?', 'user.confirmDeleteUser': 'Are you sure you want to delete this user?',
'user.deleteUserSuccess': 'Deletion successful', 'user.deleteUserSuccess': 'Deletion successful',
'user.operationFailed': 'Operation failed, please try again', 'user.operationFailed': 'Operation failed, please try again',
@@ -656,6 +631,46 @@ export default {
'user.searchPhone': 'Please enter mobile phone number to search', 'user.searchPhone': 'Please enter mobile phone number to search',
'user.search': 'Search', 'user.search': 'Search',
// Provider Management Page Text
'providerManagement.categoryFilter': 'Category Filter',
'providerManagement.searchPlaceholder': 'Please enter provider name to search',
'providerManagement.category': 'Category',
'providerManagement.providerCode': 'Provider Code',
'providerManagement.fieldConfig': 'Field Configuration',
'providerManagement.selectToDelete': 'Please select providers to delete first',
'providerManagement.confirmDelete': 'Are you sure to delete the selected {count} providers?',
'providerManagement.viewFields': 'View Fields',
// Common Text
'common.all': 'All',
'common.search': 'Search',
'common.name': 'Name',
'common.sort': 'Sort',
'common.action': 'Action',
'common.edit': 'Edit',
'common.delete': 'Delete',
'common.selectAll': 'Select All',
'common.deselectAll': 'Deselect All',
'common.add': 'Add',
'common.perPage': '{number}/page',
'common.firstPage': 'First Page',
'common.prevPage': 'Previous Page',
'common.nextPage': 'Next Page',
'common.totalRecords': 'Total {number} records',
'common.addProvider': 'Add Provider',
'common.success': 'Success',
'common.editProvider': 'Edit Provider',
'common.updateSuccess': 'Update Success',
'common.addSuccess': 'Add Success',
'common.deleteSuccess': 'Delete Success',
'common.deleteFailure': 'Delete Failed, Please Try Again',
'common.deleteCancelled': 'Delete Cancelled',
'common.warning': 'Warning',
'common.tip': 'Tip',
'common.confirm': 'Confirm',
'common.cancel': 'Cancel',
'common.sensitive': 'Sensitive',
// Language switch // Language switch
'language.zhCN': '中文简体', 'language.zhCN': '中文简体',
'language.zhTW': '中文繁體', 'language.zhTW': '中文繁體',
@@ -672,7 +687,14 @@ export default {
'home.deviceManagement': 'Devices', 'home.deviceManagement': 'Devices',
'home.chatHistory': 'Chat History', 'home.chatHistory': 'Chat History',
'home.lastConversation': 'Last Conversation', 'home.lastConversation': 'Last Conversation',
'home.noConversation': 'No conversation yet', 'home.noConversation': 'No conversation',
'home.justNow': 'Just now',
'home.minutesAgo': '{minutes} minutes ago',
'home.hoursAgo': '{hours} hours {minutes} minutes ago',
'home.confirmDeleteAgent': 'Are you sure you want to delete this agent?',
'home.deleteSuccess': 'Delete successful',
'home.deleteFailed': 'Delete failed',
'home.enableMemory': 'Please enable memory in the "Config Role" page first',
// Parameter management page text // Parameter management page text
'paramManagement.pageTitle': 'Parameter Management', 'paramManagement.pageTitle': 'Parameter Management',
@@ -708,14 +730,6 @@ export default {
'paramManagement.addSuccess': 'Add successful', 'paramManagement.addSuccess': 'Add successful',
'paramManagement.updateFailed': 'Update failed', 'paramManagement.updateFailed': 'Update failed',
'paramManagement.addFailed': 'Add failed', 'paramManagement.addFailed': 'Add failed',
'home.noConversation': 'No conversation',
'home.justNow': 'Just now',
'home.minutesAgo': '{minutes} minutes ago',
'home.hoursAgo': '{hours} hours {minutes} minutes ago',
'home.confirmDeleteAgent': 'Are you sure you want to delete this agent?',
'home.deleteSuccess': 'Delete successful',
'home.deleteFailed': 'Delete failed',
'home.enableMemory': 'Please enable memory in the "Config Role" page first',
// Server side management page text // Server side management page text
'serverSideManager.pageTitle': 'Server Management', 'serverSideManager.pageTitle': 'Server Management',
@@ -998,4 +1012,56 @@ export default {
'providerDialog.selectFieldsToDelete': 'Please select fields to delete first', 'providerDialog.selectFieldsToDelete': 'Please select fields to delete first',
'providerDialog.confirmBatchDeleteFields': 'Are you sure to delete {count} selected fields?', 'providerDialog.confirmBatchDeleteFields': 'Are you sure to delete {count} selected fields?',
'providerDialog.batchDeleteFieldsSuccess': 'Successfully deleted {count} fields', 'providerDialog.batchDeleteFieldsSuccess': 'Successfully deleted {count} fields',
};
// agentTemplateManagement
'agentTemplateManagement.title': 'Default Role Management',
'agentTemplateManagement.templateName': 'Template Name',
'agentTemplateManagement.action': 'Action',
'agentTemplateManagement.createTemplate': 'Create Template',
'templateQuickConfig.newTemplate': 'New Template',
'agentTemplateManagement.editTemplate': 'Edit Template',
'agentTemplateManagement.deleteTemplate': 'Delete Template',
'agentTemplateManagement.deleteSuccess': 'Template deleted successfully',
'agentTemplateManagement.batchDelete': 'Batch Delete',
'agentTemplateManagement.batchDeleteSuccess': 'Batch deletion successful',
'agentTemplateManagement.selectTemplate': 'Please select a template',
'agentTemplateManagement.select': 'Select',
'agentTemplateManagement.searchPlaceholder': 'Please enter template name to search',
'agentTemplateManagement.search': 'Search',
'agentTemplateManagement.serialNumber': 'Serial Number',
'agentTemplateManagement.selectAll': 'Select All',
'agentTemplateManagement.deselectAll': 'Deselect All',
'agentTemplateManagement.loading': 'Loading...',
'agentTemplateManagement.confirmSingleDelete': 'Are you sure you want to delete this template?',
'agentTemplateManagement.confirmBatchDelete': 'Are you sure you want to delete the selected {count} templates?',
'agentTemplateManagement.deleteFailed': 'Template deletion failed',
'agentTemplateManagement.batchDeleteFailed': 'Template batch deletion failed',
'agentTemplateManagement.deleteBackendError': 'Deletion failed, please check if the backend service is normal',
'agentTemplateManagement.deleteCancelled': 'Deletion cancelled',
// templateQuickConfig
'templateQuickConfig.title': 'Module Quick Configuration',
'templateQuickConfig.agentSettings.agentName': 'Nickname',
'templateQuickConfig.agentSettings.agentNamePlaceholder': 'Please enter nickname',
'templateQuickConfig.agentSettings.systemPrompt': 'Introduction',
'templateQuickConfig.agentSettings.systemPromptPlaceholder': 'Please enter ntroduction',
'templateQuickConfig.saveConfig': 'Save Configuration',
'templateQuickConfig.resetConfig': 'Reset Configuration',
'templateQuickConfig.configSaved': 'Configuration saved successfully',
'templateQuickConfig.configReset': 'Configuration has been reset',
'templateQuickConfig.confirmReset': 'Are you sure you want to reset the configuration?',
'templateQuickConfig.saveFailed': 'Configuration save failed',
'templateQuickConfig.confirm': 'Confirm',
'templateQuickConfig.cancel': 'Cancel',
'templateQuickConfig.templateNotFound': 'Template not found',
'warning': 'Warning',
'info': 'Info',
'common.networkError': 'Network request failed',
'sm2.publicKeyNotConfigured': 'SM2 public key not configured, please contact administrator',
'sm2.encryptionFailed': 'Password encryption failed',
'sm2.keyGenerationFailed': 'Key pair generation failed',
'sm2.invalidPublicKey': 'Invalid public key format',
'sm2.encryptionError': 'Error occurred during encryption',
'sm2.publicKeyRetry': 'Retrying to get public key...',
'sm2.publicKeyRetryFailed': 'Public key retrieval retry failed'
}
+121 -54
View File
@@ -5,7 +5,7 @@ export default {
'login.requiredCaptcha': '验证码不能为空', 'login.requiredCaptcha': '验证码不能为空',
'login.requiredMobile': '请输入正确的手机号码', 'login.requiredMobile': '请输入正确的手机号码',
'login.loginSuccess': '登录成功!', 'login.loginSuccess': '登录成功!',
// HeaderBar组件文本 // HeaderBar组件文本
'header.smartManagement': '智能体管理', 'header.smartManagement': '智能体管理',
'header.modelConfig': '模型配置', 'header.modelConfig': '模型配置',
@@ -14,6 +14,9 @@ export default {
'header.paramDictionary': '参数字典', 'header.paramDictionary': '参数字典',
'header.paramManagement': '参数管理', 'header.paramManagement': '参数管理',
'header.dictManagement': '字典管理', 'header.dictManagement': '字典管理',
'header.agentTemplate': '默认角色模板',
'header.searchHistory': '搜索历史',
'header.clearHistory': '清空历史',
// McpToolCallDialog组件文本 // McpToolCallDialog组件文本
'mcpToolCall.title': '工具调用', 'mcpToolCall.title': '工具调用',
@@ -37,7 +40,7 @@ export default {
'mcpToolCall.copyResult': '复制结果', 'mcpToolCall.copyResult': '复制结果',
'mcpToolCall.noResultYet': '暂无执行结果', 'mcpToolCall.noResultYet': '暂无执行结果',
'mcpToolCall.loadingToolList': '正在获取工具列表...', 'mcpToolCall.loadingToolList': '正在获取工具列表...',
// 工具名称 // 工具名称
'mcpToolCall.toolName.getDeviceStatus': '查看设备状态', 'mcpToolCall.toolName.getDeviceStatus': '查看设备状态',
'mcpToolCall.toolName.setVolume': '设置音量', 'mcpToolCall.toolName.setVolume': '设置音量',
@@ -51,7 +54,7 @@ export default {
'mcpToolCall.toolName.snapshot': '屏幕截图', 'mcpToolCall.toolName.snapshot': '屏幕截图',
'mcpToolCall.toolName.previewImage': '预览图片', 'mcpToolCall.toolName.previewImage': '预览图片',
'mcpToolCall.toolName.setDownloadUrl': '设置下载地址', 'mcpToolCall.toolName.setDownloadUrl': '设置下载地址',
// 工具分类 // 工具分类
'mcpToolCall.category.audio': '音频', 'mcpToolCall.category.audio': '音频',
'mcpToolCall.category.display': '显示', 'mcpToolCall.category.display': '显示',
@@ -59,7 +62,7 @@ export default {
'mcpToolCall.category.system': '系统', 'mcpToolCall.category.system': '系统',
'mcpToolCall.category.assets': '资源', 'mcpToolCall.category.assets': '资源',
'mcpToolCall.category.deviceInfo': '设备信息', 'mcpToolCall.category.deviceInfo': '设备信息',
// 表格分类和属性 // 表格分类和属性
'mcpToolCall.table.audioSpeaker': '音频扬声器', 'mcpToolCall.table.audioSpeaker': '音频扬声器',
'mcpToolCall.table.screen': '屏幕', 'mcpToolCall.table.screen': '屏幕',
@@ -79,7 +82,7 @@ export default {
'mcpToolCall.table.component': '组件', 'mcpToolCall.table.component': '组件',
'mcpToolCall.table.property': '属性', 'mcpToolCall.table.property': '属性',
'mcpToolCall.table.value': '值', 'mcpToolCall.table.value': '值',
'mcpToolCall.prop.volume': '音量', 'mcpToolCall.prop.volume': '音量',
'mcpToolCall.prop.brightness': '亮度', 'mcpToolCall.prop.brightness': '亮度',
'mcpToolCall.prop.theme': '主题', 'mcpToolCall.prop.theme': '主题',
@@ -111,7 +114,7 @@ export default {
'mcpToolCall.prop.url': 'URL', 'mcpToolCall.prop.url': 'URL',
'mcpToolCall.prop.quality': '质量', 'mcpToolCall.prop.quality': '质量',
'mcpToolCall.prop.question': '问题', 'mcpToolCall.prop.question': '问题',
// 工具帮助文本 // 工具帮助文本
'mcpToolCall.help.getDeviceStatus': '查看设备的当前运行状态,包括音量、屏幕、电池等信息。', 'mcpToolCall.help.getDeviceStatus': '查看设备的当前运行状态,包括音量、屏幕、电池等信息。',
'mcpToolCall.help.setVolume': '调整设备的音量大小,请输入0-100之间的数值。', 'mcpToolCall.help.setVolume': '调整设备的音量大小,请输入0-100之间的数值。',
@@ -125,7 +128,7 @@ export default {
'mcpToolCall.help.snapshot': '对当前屏幕进行截图并上传到指定URL。', 'mcpToolCall.help.snapshot': '对当前屏幕进行截图并上传到指定URL。',
'mcpToolCall.help.previewImage': '在设备屏幕上预览指定URL的图片。', 'mcpToolCall.help.previewImage': '在设备屏幕上预览指定URL的图片。',
'mcpToolCall.help.setDownloadUrl': '设置设备资源文件的下载地址。', 'mcpToolCall.help.setDownloadUrl': '设置设备资源文件的下载地址。',
// 其他文本 // 其他文本
'mcpToolCall.text.strong': '强', 'mcpToolCall.text.strong': '强',
'mcpToolCall.text.medium': '中', 'mcpToolCall.text.medium': '中',
@@ -230,7 +233,7 @@ export default {
// AddModelDialog组件相关 // AddModelDialog组件相关
'addModelDialog.requiredSupplier': '请选择供应器', 'addModelDialog.requiredSupplier': '请选择供应器',
// 注册页面相关 // 注册页面相关
'register.title': '创建账号', 'register.title': '创建账号',
'register.welcome': '欢迎使用小智AI', 'register.welcome': '欢迎使用小智AI',
@@ -258,7 +261,7 @@ export default {
'register.requiredPassword': '密码不能为空', 'register.requiredPassword': '密码不能为空',
'register.requiredCaptcha': '验证码不能为空', 'register.requiredCaptcha': '验证码不能为空',
'register.requiredMobileCaptcha': '请输入短信验证码', 'register.requiredMobileCaptcha': '请输入短信验证码',
'manualAddDeviceDialog.deviceType': '设备型号', 'manualAddDeviceDialog.deviceType': '设备型号',
'manualAddDeviceDialog.deviceTypePlaceholder': '请选择设备型号', 'manualAddDeviceDialog.deviceTypePlaceholder': '请选择设备型号',
'manualAddDeviceDialog.firmwareVersion': '固件版本', 'manualAddDeviceDialog.firmwareVersion': '固件版本',
@@ -383,10 +386,12 @@ export default {
'register.registerSuccess': '注册成功!', 'register.registerSuccess': '注册成功!',
'register.registerFailed': '注册失败', 'register.registerFailed': '注册失败',
'register.passwordsNotMatch': '两次输入的密码不一致', 'register.passwordsNotMatch': '两次输入的密码不一致',
'register.secondsLater': '秒后',
// 忘记密码页面文本 // 忘记密码页面文本
'retrievePassword.title': '重置密码', 'retrievePassword.title': '重置密码',
'retrievePassword.welcome': '密码找回', 'retrievePassword.welcome': '密码找回',
'retrievePassword.subtitle': '找回密码',
'retrievePassword.mobile': '手机号', 'retrievePassword.mobile': '手机号',
'retrievePassword.mobilePlaceholder': '请输入手机号码', 'retrievePassword.mobilePlaceholder': '请输入手机号码',
'retrievePassword.captcha': '验证码', 'retrievePassword.captcha': '验证码',
@@ -398,6 +403,7 @@ export default {
'retrievePassword.confirmNewPassword': '确认新密码', 'retrievePassword.confirmNewPassword': '确认新密码',
'retrievePassword.confirmNewPasswordPlaceholder': '请确认新密码', 'retrievePassword.confirmNewPasswordPlaceholder': '请确认新密码',
'retrievePassword.getMobileCaptcha': '获取验证码', 'retrievePassword.getMobileCaptcha': '获取验证码',
'retrievePassword.resetButton': '重置密码',
'retrievePassword.updateButton': '立即修改', 'retrievePassword.updateButton': '立即修改',
'retrievePassword.goToLogin': '返回登录', 'retrievePassword.goToLogin': '返回登录',
'retrievePassword.inputCorrectMobile': '请输入正确的手机号码', 'retrievePassword.inputCorrectMobile': '请输入正确的手机号码',
@@ -410,6 +416,7 @@ export default {
'retrievePassword.newPasswordRequired': '新密码不能为空', 'retrievePassword.newPasswordRequired': '新密码不能为空',
'retrievePassword.confirmNewPasswordRequired': '确认新密码不能为空', 'retrievePassword.confirmNewPasswordRequired': '确认新密码不能为空',
'retrievePassword.passwordUpdateSuccess': '密码修改成功', 'retrievePassword.passwordUpdateSuccess': '密码修改成功',
'retrievePassword.agreeTo': '重置即同意',
// 修改密码页面文本 // 修改密码页面文本
'changePassword.title': '修改密码', 'changePassword.title': '修改密码',
@@ -443,7 +450,6 @@ export default {
'device.input6DigitCode': '请输入6位数字验证码', 'device.input6DigitCode': '请输入6位数字验证码',
'device.bindSuccess': '设备绑定成功', 'device.bindSuccess': '设备绑定成功',
'device.bindFailed': '绑定失败', 'device.bindFailed': '绑定失败',
// DeviceManagement页面扩展翻译
'device.searchPlaceholder': '请输入设备型号或Mac地址查询', 'device.searchPlaceholder': '请输入设备型号或Mac地址查询',
'device.model': '设备型号', 'device.model': '设备型号',
'device.macAddress': 'Mac地址', 'device.macAddress': 'Mac地址',
@@ -496,6 +502,9 @@ export default {
'chatHistory.selectSession': '请选择会话查看聊天记录', 'chatHistory.selectSession': '请选择会话查看聊天记录',
'chatHistory.today': '今天', 'chatHistory.today': '今天',
'chatHistory.yesterday': '昨天', 'chatHistory.yesterday': '昨天',
'chatHistory.downloadCurrentSession': '下载本会话聊天记录',
'chatHistory.downloadCurrentWithPreviousSessions': '下载本会话及前20条会话聊天记录',
'chatHistory.downloadLinkFailed': '获取下载链接失败',
'cache.status': '缓存状态', 'cache.status': '缓存状态',
'cache.cdnEnabled': 'CDN模式已启用', 'cache.cdnEnabled': 'CDN模式已启用',
'cache.cdnDisabled': 'CDN模式已禁用', 'cache.cdnDisabled': 'CDN模式已禁用',
@@ -587,42 +596,6 @@ export default {
'user.info': '用户信息', 'user.info': '用户信息',
'user.username': '用户名', 'user.username': '用户名',
'user.mobile': '手机号', 'user.mobile': '手机号',
// 字段管理页面文本
'providerManagement.categoryFilter': '类别筛选',
'providerManagement.searchPlaceholder': '请输入供应器名称查询',
'providerManagement.category': '类别',
'providerManagement.providerCode': '供应器编码',
'providerManagement.fieldConfig': '字段配置',
'providerManagement.selectToDelete': '请先选择需要删除的供应器',
'providerManagement.confirmDelete': '确定要删除选中的{count}个供应器吗?',
'providerManagement.viewFields': '查看字段',
// 公共文本
'common.all': '全部',
'common.search': '搜索',
'common.name': '名称',
'common.sort': '排序',
'common.action': '操作',
'common.edit': '编辑',
'common.delete': '删除',
'common.selectAll': '全选',
'common.deselectAll': '取消全选',
'common.add': '新增',
'common.perPage': '{number}条/页',
'common.firstPage': '首页',
'common.prevPage': '上一页',
'common.nextPage': '下一页',
'common.totalRecords': '共{number}条记录',
'common.addProvider': '新增供应器',
'common.editProvider': '编辑供应器',
'common.updateSuccess': '修改成功',
'common.addSuccess': '新增成功',
'common.deleteSuccess': '删除成功',
'common.deleteFailure': '删除失败,请重试',
'common.deleteCancelled': '已取消删除',
'common.warning': '警告',
'common.confirm': '确定',
'common.cancel': '取消',
'common.sensitive': '敏感',
'user.userid': '用户Id', 'user.userid': '用户Id',
'user.deviceCount': '设备数量', 'user.deviceCount': '设备数量',
'user.createDate': '注册时间', 'user.createDate': '注册时间',
@@ -648,6 +621,7 @@ export default {
'user.deleteCancelled': '已取消删除', 'user.deleteCancelled': '已取消删除',
'user.confirmResetPassword': '重置后将会生成新密码,是否继续?', 'user.confirmResetPassword': '重置后将会生成新密码,是否继续?',
'user.resetPasswordSuccess': '密码已重置,请通知用户使用新密码登录', 'user.resetPasswordSuccess': '密码已重置,请通知用户使用新密码登录',
'user.generatedPassword': '生成的默认密码',
'user.confirmDeleteUser': '确定要删除该用户吗?', 'user.confirmDeleteUser': '确定要删除该用户吗?',
'user.deleteUserSuccess': '删除成功', 'user.deleteUserSuccess': '删除成功',
'user.operationFailed': '操作失败,请重试', 'user.operationFailed': '操作失败,请重试',
@@ -657,6 +631,46 @@ export default {
'user.searchPhone': '请输入手机号码查询', 'user.searchPhone': '请输入手机号码查询',
'user.search': '搜索', 'user.search': '搜索',
// 字段管理页面文本
'providerManagement.categoryFilter': '类别筛选',
'providerManagement.searchPlaceholder': '请输入供应器名称查询',
'providerManagement.category': '类别',
'providerManagement.providerCode': '供应器编码',
'providerManagement.fieldConfig': '字段配置',
'providerManagement.selectToDelete': '请先选择需要删除的供应器',
'providerManagement.confirmDelete': '确定要删除选中的{count}个供应器吗?',
'providerManagement.viewFields': '查看字段',
// 公共文本
'common.all': '全部',
'common.search': '搜索',
'common.name': '名称',
'common.sort': '排序',
'common.action': '操作',
'common.edit': '编辑',
'common.delete': '删除',
'common.selectAll': '全选',
'common.deselectAll': '取消全选',
'common.add': '新增',
'common.perPage': '{number}条/页',
'common.firstPage': '首页',
'common.prevPage': '上一页',
'common.nextPage': '下一页',
'common.totalRecords': '共{number}条记录',
'common.addProvider': '新增供应器',
'common.success': '成功',
'common.editProvider': '编辑供应器',
'common.updateSuccess': '修改成功',
'common.addSuccess': '新增成功',
'common.deleteSuccess': '删除成功',
'common.deleteFailure': '删除失败,请重试',
'common.deleteCancelled': '已取消删除',
'common.warning': '警告',
'common.tip': '提示',
'common.confirm': '确定',
'common.cancel': '取消',
'common.sensitive': '敏感',
// 语言切换 // 语言切换
'language.zhCN': '中文简体', 'language.zhCN': '中文简体',
'language.zhTW': '中文繁體', 'language.zhTW': '中文繁體',
@@ -674,6 +688,13 @@ export default {
'home.chatHistory': '聊天记录', 'home.chatHistory': '聊天记录',
'home.lastConversation': '最近对话', 'home.lastConversation': '最近对话',
'home.noConversation': '暂未对话', 'home.noConversation': '暂未对话',
'home.justNow': '刚刚',
'home.minutesAgo': '{minutes}分钟前',
'home.hoursAgo': '{hours}小时{minutes}分钟前',
'home.confirmDeleteAgent': '确定要删除该智能体吗?',
'home.deleteSuccess': '删除成功',
'home.deleteFailed': '删除失败',
'home.enableMemory': '请先在“配置角色”界面开启记忆',
// 参数管理页面文本 // 参数管理页面文本
'paramManagement.pageTitle': '参数管理', 'paramManagement.pageTitle': '参数管理',
@@ -709,13 +730,6 @@ export default {
'paramManagement.addSuccess': '新增成功', 'paramManagement.addSuccess': '新增成功',
'paramManagement.updateFailed': '更新失败', 'paramManagement.updateFailed': '更新失败',
'paramManagement.addFailed': '新增失败', 'paramManagement.addFailed': '新增失败',
'home.justNow': '刚刚',
'home.minutesAgo': '{minutes}分钟前',
'home.hoursAgo': '{hours}小时{minutes}分钟前',
'home.confirmDeleteAgent': '确定要删除该智能体吗?',
'home.deleteSuccess': '删除成功',
'home.deleteFailed': '删除失败',
'home.enableMemory': '请先在“配置角色”界面开启记忆',
// 服务端管理页面文本 // 服务端管理页面文本
'serverSideManager.pageTitle': '服务端管理', 'serverSideManager.pageTitle': '服务端管理',
@@ -998,4 +1012,57 @@ export default {
'providerDialog.selectFieldsToDelete': '请先选择要删除的字段', 'providerDialog.selectFieldsToDelete': '请先选择要删除的字段',
'providerDialog.confirmBatchDeleteFields': '确定要删除选中的{count}个字段吗?', 'providerDialog.confirmBatchDeleteFields': '确定要删除选中的{count}个字段吗?',
'providerDialog.batchDeleteFieldsSuccess': '成功删除{count}个字段', 'providerDialog.batchDeleteFieldsSuccess': '成功删除{count}个字段',
};
// 默认角色模版页面文本
'agentTemplateManagement.title': '默认角色管理',
'agentTemplateManagement.templateName': '模板名称',
'agentTemplateManagement.action': '操作',
'templateQuickConfig.saveSuccess': '配置保存成功',
'templateQuickConfig.saveFailed': '配置保存失败',
'agentTemplateManagement.createTemplate': '创建模板',
'agentTemplateManagement.editTemplate': '编辑模板',
'agentTemplateManagement.deleteTemplate': '删除模板',
'agentTemplateManagement.deleteSuccess': '模板删除成功',
'agentTemplateManagement.batchDelete': '批量删除',
'agentTemplateManagement.batchDeleteSuccess': '批量删除成功',
'agentTemplateManagement.selectTemplate': '请选择模板',
'agentTemplateManagement.select': '选择',
'agentTemplateManagement.searchPlaceholder': '请输入模板名称搜索',
'agentTemplateManagement.search': '搜索',
'agentTemplateManagement.serialNumber': '序号',
'agentTemplateManagement.selectAll': '全选',
'agentTemplateManagement.deselectAll': '取消全选',
'agentTemplateManagement.loading': '拼命加载中',
'agentTemplateManagement.confirmSingleDelete': '确定要删除这个模板吗?',
'agentTemplateManagement.confirmBatchDelete': '确定要删除选中的 {count} 个模板吗?',
'agentTemplateManagement.deleteFailed': '模板删除失败',
'agentTemplateManagement.batchDeleteFailed': '模板批量删除失败',
'agentTemplateManagement.deleteBackendError': '删除失败,请检查后端服务是否正常',
// 模板快速配置页面文本
'templateQuickConfig.title': '模块快速配置',
'templateQuickConfig.agentSettings.agentName': '助手昵称',
'templateQuickConfig.agentSettings.agentNamePlaceholder': '请输入助手昵称',
'templateQuickConfig.agentSettings.systemPrompt': '角色介绍',
'templateQuickConfig.agentSettings.systemPromptPlaceholder': '请输入角色介绍',
'templateQuickConfig.saveConfig': '保存配置',
'templateQuickConfig.resetConfig': '重置配置',
'templateQuickConfig.confirmReset': '确定要重置配置吗?',
'templateQuickConfig.confirm': '确定',
'templateQuickConfig.cancel': '取消',
'templateQuickConfig.templateNotFound': '未找到指定模板',
'templateQuickConfig.newTemplate': '新模板',
'templateQuickConfig.saveSuccess': '保存成功',
'templateQuickConfig.resetSuccess': '重置成功',
'warning': '警告',
'info': '提示',
'common.networkError': '网络请求失败',
// SM2加密相关错误消息
'sm2.publicKeyNotConfigured': 'SM2公钥未配置,请联系管理员',
'sm2.encryptionFailed': '密码加密失败',
'sm2.keyGenerationFailed': '密钥对生成失败',
'sm2.invalidPublicKey': '无效的公钥格式',
'sm2.encryptionError': '加密过程中发生错误',
'sm2.publicKeyRetry': '正在重试获取公钥...',
'sm2.publicKeyRetryFailed': '公钥获取重试失败'
}
+158 -90
View File
@@ -5,7 +5,7 @@ export default {
'login.requiredCaptcha': '驗證碼不能為空', 'login.requiredCaptcha': '驗證碼不能為空',
'login.requiredMobile': '請輸入正確的手機號碼', 'login.requiredMobile': '請輸入正確的手機號碼',
'login.loginSuccess': '登錄成功!', 'login.loginSuccess': '登錄成功!',
// HeaderBar组件文本 // HeaderBar组件文本
'header.smartManagement': '智能體管理', 'header.smartManagement': '智能體管理',
'header.modelConfig': '模型配置', 'header.modelConfig': '模型配置',
@@ -14,6 +14,9 @@ export default {
'header.paramDictionary': '參數字典', 'header.paramDictionary': '參數字典',
'header.paramManagement': '參數管理', 'header.paramManagement': '參數管理',
'header.dictManagement': '字典管理', 'header.dictManagement': '字典管理',
'header.agentTemplate': '預設角色模板', // 添加这一行
'header.searchHistory': '搜索歷史',
'header.clearHistory': '清空歷史',
// McpToolCallDialog组件文本 // McpToolCallDialog组件文本
'mcpToolCall.title': '工具調用', 'mcpToolCall.title': '工具調用',
@@ -37,7 +40,7 @@ export default {
'mcpToolCall.copyResult': '複製結果', 'mcpToolCall.copyResult': '複製結果',
'mcpToolCall.noResultYet': '暫無執行結果', 'mcpToolCall.noResultYet': '暫無執行結果',
'mcpToolCall.loadingToolList': '正在獲取工具列表...', 'mcpToolCall.loadingToolList': '正在獲取工具列表...',
// 工具名稱 // 工具名稱
'mcpToolCall.toolName.getDeviceStatus': '查看設備狀態', 'mcpToolCall.toolName.getDeviceStatus': '查看設備狀態',
'mcpToolCall.toolName.setVolume': '設置音量', 'mcpToolCall.toolName.setVolume': '設置音量',
@@ -51,7 +54,7 @@ export default {
'mcpToolCall.toolName.snapshot': '螢幕截圖', 'mcpToolCall.toolName.snapshot': '螢幕截圖',
'mcpToolCall.toolName.previewImage': '預覽圖片', 'mcpToolCall.toolName.previewImage': '預覽圖片',
'mcpToolCall.toolName.setDownloadUrl': '設置下載地址', 'mcpToolCall.toolName.setDownloadUrl': '設置下載地址',
// 工具分類 // 工具分類
'mcpToolCall.category.audio': '音頻', 'mcpToolCall.category.audio': '音頻',
'mcpToolCall.category.display': '顯示', 'mcpToolCall.category.display': '顯示',
@@ -59,7 +62,7 @@ export default {
'mcpToolCall.category.system': '系統', 'mcpToolCall.category.system': '系統',
'mcpToolCall.category.assets': '資源', 'mcpToolCall.category.assets': '資源',
'mcpToolCall.category.deviceInfo': '設備資訊', 'mcpToolCall.category.deviceInfo': '設備資訊',
// 表格分類和屬性 // 表格分類和屬性
'mcpToolCall.table.audioSpeaker': '音頻揚聲器', 'mcpToolCall.table.audioSpeaker': '音頻揚聲器',
'mcpToolCall.table.screen': '螢幕', 'mcpToolCall.table.screen': '螢幕',
@@ -79,7 +82,7 @@ export default {
'mcpToolCall.table.component': '組件', 'mcpToolCall.table.component': '組件',
'mcpToolCall.table.property': '屬性', 'mcpToolCall.table.property': '屬性',
'mcpToolCall.table.value': '值', 'mcpToolCall.table.value': '值',
'mcpToolCall.prop.volume': '音量', 'mcpToolCall.prop.volume': '音量',
'mcpToolCall.prop.brightness': '亮度', 'mcpToolCall.prop.brightness': '亮度',
'mcpToolCall.prop.theme': '主題', 'mcpToolCall.prop.theme': '主題',
@@ -111,7 +114,7 @@ export default {
'mcpToolCall.prop.url': 'URL', 'mcpToolCall.prop.url': 'URL',
'mcpToolCall.prop.quality': '品質', 'mcpToolCall.prop.quality': '品質',
'mcpToolCall.prop.question': '問題', 'mcpToolCall.prop.question': '問題',
// 工具幫助文本 // 工具幫助文本
'mcpToolCall.help.getDeviceStatus': '查看設備的當前運行狀態,包括音量、螢幕、電池等資訊。', 'mcpToolCall.help.getDeviceStatus': '查看設備的當前運行狀態,包括音量、螢幕、電池等資訊。',
'mcpToolCall.help.setVolume': '調整設備的音量大小,請輸入0-100之間的數值。', 'mcpToolCall.help.setVolume': '調整設備的音量大小,請輸入0-100之間的數值。',
@@ -125,7 +128,7 @@ export default {
'mcpToolCall.help.snapshot': '對當前螢幕進行截圖並上傳到指定URL。', 'mcpToolCall.help.snapshot': '對當前螢幕進行截圖並上傳到指定URL。',
'mcpToolCall.help.previewImage': '在設備螢幕上預覽指定URL的圖片。', 'mcpToolCall.help.previewImage': '在設備螢幕上預覽指定URL的圖片。',
'mcpToolCall.help.setDownloadUrl': '設置設備資源文件的下載地址。', 'mcpToolCall.help.setDownloadUrl': '設置設備資源文件的下載地址。',
// 其他文本 // 其他文本
'mcpToolCall.text.strong': '強', 'mcpToolCall.text.strong': '強',
'mcpToolCall.text.medium': '中', 'mcpToolCall.text.medium': '中',
@@ -230,7 +233,7 @@ export default {
// AddModelDialog組件相關 // AddModelDialog組件相關
'addModelDialog.requiredSupplier': '請選擇供應器', 'addModelDialog.requiredSupplier': '請選擇供應器',
// 註冊頁面相關 // 註冊頁面相關
'register.title': '建立帳號', 'register.title': '建立帳號',
'register.welcome': '歡迎使用小智慧AI', 'register.welcome': '歡迎使用小智慧AI',
@@ -252,13 +255,14 @@ export default {
'register.captchaSendSuccess': '驗證碼發送成功', 'register.captchaSendSuccess': '驗證碼發送成功',
'register.captchaSendFailed': '驗證碼發送失敗', 'register.captchaSendFailed': '驗證碼發送失敗',
'register.passwordsNotMatch': '兩次輸入的密碼不一致', 'register.passwordsNotMatch': '兩次輸入的密碼不一致',
'register.secondsLater': '秒後',
'register.registerSuccess': '註冊成功!', 'register.registerSuccess': '註冊成功!',
'register.registerFailed': '註冊失敗', 'register.registerFailed': '註冊失敗',
'register.requiredUsername': '用戶名不能為空', 'register.requiredUsername': '用戶名不能為空',
'register.requiredPassword': '密碼不能為空', 'register.requiredPassword': '密碼不能為空',
'register.requiredCaptcha': '驗證碼不能為空', 'register.requiredCaptcha': '驗證碼不能為空',
'register.requiredMobileCaptcha': '請輸入簡訊驗證碼', 'register.requiredMobileCaptcha': '請輸入簡訊驗證碼',
'manualAddDeviceDialog.deviceType': '設備型號', 'manualAddDeviceDialog.deviceType': '設備型號',
'manualAddDeviceDialog.deviceTypePlaceholder': '請選擇設備型號', 'manualAddDeviceDialog.deviceTypePlaceholder': '請選擇設備型號',
'manualAddDeviceDialog.firmwareVersion': '固件版本', 'manualAddDeviceDialog.firmwareVersion': '固件版本',
@@ -387,6 +391,7 @@ export default {
// 忘记密码页面文本 // 忘记密码页面文本
'retrievePassword.title': '重置密碼', 'retrievePassword.title': '重置密碼',
'retrievePassword.welcome': '密碼找回', 'retrievePassword.welcome': '密碼找回',
'retrievePassword.subtitle': '找回密碼',
'retrievePassword.mobile': '手機號', 'retrievePassword.mobile': '手機號',
'retrievePassword.mobilePlaceholder': '請輸入手機號碼', 'retrievePassword.mobilePlaceholder': '請輸入手機號碼',
'retrievePassword.captcha': '驗證碼', 'retrievePassword.captcha': '驗證碼',
@@ -398,6 +403,7 @@ export default {
'retrievePassword.confirmNewPassword': '確認新密碼', 'retrievePassword.confirmNewPassword': '確認新密碼',
'retrievePassword.confirmNewPasswordPlaceholder': '請確認新密碼', 'retrievePassword.confirmNewPasswordPlaceholder': '請確認新密碼',
'retrievePassword.getMobileCaptcha': '獲取驗證碼', 'retrievePassword.getMobileCaptcha': '獲取驗證碼',
'retrievePassword.resetButton': '重置密碼',
'retrievePassword.updateButton': '立即修改', 'retrievePassword.updateButton': '立即修改',
'retrievePassword.goToLogin': '返回登錄', 'retrievePassword.goToLogin': '返回登錄',
'retrievePassword.inputCorrectMobile': '請輸入正確的手機號碼', 'retrievePassword.inputCorrectMobile': '請輸入正確的手機號碼',
@@ -410,6 +416,7 @@ export default {
'retrievePassword.newPasswordRequired': '新密碼不能為空', 'retrievePassword.newPasswordRequired': '新密碼不能為空',
'retrievePassword.confirmNewPasswordRequired': '確認新密碼不能為空', 'retrievePassword.confirmNewPasswordRequired': '確認新密碼不能為空',
'retrievePassword.passwordUpdateSuccess': '密碼修改成功', 'retrievePassword.passwordUpdateSuccess': '密碼修改成功',
'retrievePassword.agreeTo': '重置即同意',
// 修改密码页面文本 // 修改密码页面文本
'changePassword.title': '修改密碼', 'changePassword.title': '修改密碼',
@@ -495,6 +502,9 @@ export default {
'chatHistory.selectSession': '請選擇會話查看聊天記錄', 'chatHistory.selectSession': '請選擇會話查看聊天記錄',
'chatHistory.today': '今天', 'chatHistory.today': '今天',
'chatHistory.yesterday': '昨天', 'chatHistory.yesterday': '昨天',
'chatHistory.downloadCurrentSession': '下載本會話聊天記錄',
'chatHistory.downloadCurrentWithPreviousSessions': '下載本會話及前20條會話聊天記錄',
'chatHistory.downloadLinkFailed': '獲取下載鏈接失敗',
'cache.status': '緩存狀態', 'cache.status': '緩存狀態',
'cache.cdnEnabled': 'CDN模式已啟用', 'cache.cdnEnabled': 'CDN模式已啟用',
'cache.cdnDisabled': 'CDN模式已禁用', 'cache.cdnDisabled': 'CDN模式已禁用',
@@ -546,6 +556,42 @@ export default {
'voiceprint.add': '添加聲紋', 'voiceprint.add': '添加聲紋',
'voiceprint.delete': '刪除聲紋', 'voiceprint.delete': '刪除聲紋',
// 字典管理頁面文本
'dictManagement.pageTitle': '字典管理',
'dictManagement.searchPlaceholder': '請輸入字典值標籤查詢',
'dictManagement.search': '搜索',
'dictManagement.dictTypeName': '字典類型名稱',
'dictManagement.operation': '操作',
'dictManagement.edit': '編輯',
'dictManagement.dictLabel': '字典標籤',
'dictManagement.dictValue': '字典值',
'dictManagement.sort': '排序',
'dictManagement.delete': '刪除',
'dictManagement.selectAll': '全選',
'dictManagement.deselectAll': '取消全選',
'dictManagement.addDictType': '新增字典類型',
'dictManagement.batchDeleteDictType': '批量刪除字典類型',
'dictManagement.addDictData': '新增字典數據',
'dictManagement.batchDeleteDictData': '批量刪除字典數據',
'dictManagement.itemsPerPage': '{items}條/頁',
'dictManagement.firstPage': '首頁',
'dictManagement.prevPage': '上一頁',
'dictManagement.nextPage': '下一頁',
'dictManagement.totalRecords': '共{total}條記錄',
'dictManagement.editDictType': '編輯字典類型',
'dictManagement.editDictData': '編輯字典數據',
'dictManagement.saveSuccess': '保存成功',
'dictManagement.deleteSuccess': '刪除成功',
'dictManagement.selectDictTypeToDelete': '請選擇要刪除的字典類型',
'dictManagement.confirmDeleteDictType': '確定要刪除選中的字典類型嗎?',
'dictManagement.confirm': '確定',
'dictManagement.cancel': '取消',
'dictManagement.selectDictTypeFirst': '請先選擇字典類型',
'dictManagement.confirmDeleteDictData': '確定要刪除該字典數據嗎?',
'dictManagement.selectDictDataToDelete': '請選擇要刪除的字典數據',
'dictManagement.confirmBatchDeleteDictData': '確定要刪除選中的{count}個字典數據嗎?',
'dictManagement.getDictDataFailed': '獲取字典數據失敗',
// 用户信息 // 用户信息
'user.info': '用戶信息', 'user.info': '用戶信息',
'user.username': '用戶名', 'user.username': '用戶名',
@@ -575,6 +621,7 @@ export default {
'user.deleteCancelled': '已取消刪除', 'user.deleteCancelled': '已取消刪除',
'user.confirmResetPassword': '重置後將會生成新密碼,是否繼續?', 'user.confirmResetPassword': '重置後將會生成新密碼,是否繼續?',
'user.resetPasswordSuccess': '密碼已重置,請通知用戶使用新密碼登錄', 'user.resetPasswordSuccess': '密碼已重置,請通知用戶使用新密碼登錄',
'user.generatedPassword': '生成的默認密碼',
'user.confirmDeleteUser': '確定要刪除該用戶嗎?', 'user.confirmDeleteUser': '確定要刪除該用戶嗎?',
'user.deleteUserSuccess': '刪除成功', 'user.deleteUserSuccess': '刪除成功',
'user.operationFailed': '操作失敗,請重試', 'user.operationFailed': '操作失敗,請重試',
@@ -584,6 +631,46 @@ export default {
'user.searchPhone': '請輸入手機號碼查詢', 'user.searchPhone': '請輸入手機號碼查詢',
'user.search': '搜索', 'user.search': '搜索',
// 字段管理頁面文本
'providerManagement.categoryFilter': '類別篩選',
'providerManagement.searchPlaceholder': '請輸入供應器名稱查詢',
'providerManagement.category': '類別',
'providerManagement.providerCode': '供應器編碼',
'providerManagement.fieldConfig': '字段配置',
'providerManagement.selectToDelete': '請先選擇需要刪除的供應器',
'providerManagement.confirmDelete': '確定要刪除選中的{count}個供應器嗎?',
'providerManagement.viewFields': '查看字段',
// 公共文本
'common.all': '全部',
'common.search': '搜索',
'common.name': '名稱',
'common.sort': '排序',
'common.action': '操作',
'common.edit': '編輯',
'common.delete': '刪除',
'common.selectAll': '全選',
'common.deselectAll': '取消全選',
'common.add': '新增',
'common.perPage': '{number}條/頁',
'common.firstPage': '首頁',
'common.prevPage': '上一頁',
'common.nextPage': '下一頁',
'common.totalRecords': '共{number}條記錄',
'common.addProvider': '新增供應器',
'common.success': '成功',
'common.editProvider': '編輯供應器',
'common.updateSuccess': '修改成功',
'common.addSuccess': '新增成功',
'common.deleteSuccess': '刪除成功',
'common.deleteFailure': '刪除失敗,請重試',
'common.deleteCancelled': '已取消刪除',
'common.warning': '警告',
'common.tip': '提示',
'common.confirm': '確定',
'common.cancel': '取消',
'common.sensitive': '敏感',
// 語言切換 // 語言切換
'language.zhCN': '中文简体', 'language.zhCN': '中文简体',
'language.zhTW': '中文繁體', 'language.zhTW': '中文繁體',
@@ -601,6 +688,13 @@ export default {
'home.chatHistory': '聊天記錄', 'home.chatHistory': '聊天記錄',
'home.lastConversation': '最近對話', 'home.lastConversation': '最近對話',
'home.noConversation': '暫未對話', 'home.noConversation': '暫未對話',
'home.justNow': '剛剛',
'home.minutesAgo': '{minutes}分鐘前',
'home.hoursAgo': '{hours}小時{minutes}分鐘前',
'home.confirmDeleteAgent': '確定要刪除該智能體嗎?',
'home.deleteSuccess': '刪除成功',
'home.deleteFailed': '刪除失敗',
'home.enableMemory': '請先在「配置角色」介面開啟記憶',
// 參數管理頁面文本 // 參數管理頁面文本
'paramManagement.pageTitle': '參數管理', 'paramManagement.pageTitle': '參數管理',
@@ -621,43 +715,6 @@ export default {
'paramManagement.firstPage': '首頁', 'paramManagement.firstPage': '首頁',
'paramManagement.prevPage': '上一頁', 'paramManagement.prevPage': '上一頁',
'paramManagement.nextPage': '下一頁', 'paramManagement.nextPage': '下一頁',
// 字段管理頁面文本
'providerManagement.categoryFilter': '類別篩選',
'providerManagement.searchPlaceholder': '請輸入供應器名稱查詢',
'providerManagement.category': '類別',
'providerManagement.providerCode': '供應器編碼',
'providerManagement.fieldConfig': '字段配置',
'providerManagement.selectToDelete': '請先選擇需要刪除的供應器',
'providerManagement.confirmDelete': '確定要刪除選中的{count}個供應器嗎?',
'providerManagement.viewFields': '查看字段',
// 公共文本
'common.all': '全部',
'common.search': '搜索',
'common.name': '名稱',
'common.sort': '排序',
'common.action': '操作',
'common.edit': '編輯',
'common.delete': '刪除',
'common.selectAll': '全選',
'common.deselectAll': '取消全選',
'common.add': '新增',
'common.perPage': '{number}條/頁',
'common.firstPage': '首頁',
'common.prevPage': '上一頁',
'common.nextPage': '下一頁',
'common.totalRecords': '共{number}條記錄',
'common.addProvider': '新增供應器',
'common.editProvider': '編輯供應器',
'common.updateSuccess': '修改成功',
'common.addSuccess': '新增成功',
'common.deleteSuccess': '刪除成功',
'common.deleteFailure': '刪除失敗,請重試',
'common.deleteCancelled': '已取消刪除',
'common.warning': '警告',
'common.confirm': '確定',
'common.cancel': '取消',
'common.sensitive': '敏感',
'paramManagement.totalRecords': '共{total}條記錄', 'paramManagement.totalRecords': '共{total}條記錄',
'paramManagement.addParam': '新增參數', 'paramManagement.addParam': '新增參數',
'paramManagement.editParam': '編輯參數', 'paramManagement.editParam': '編輯參數',
@@ -673,49 +730,6 @@ export default {
'paramManagement.addSuccess': '新增成功', 'paramManagement.addSuccess': '新增成功',
'paramManagement.updateFailed': '更新失敗', 'paramManagement.updateFailed': '更新失敗',
'paramManagement.addFailed': '新增失敗', 'paramManagement.addFailed': '新增失敗',
'home.justNow': '剛剛',
'home.minutesAgo': '{minutes}分鐘前',
'home.hoursAgo': '{hours}小時{minutes}分鐘前',
'home.confirmDeleteAgent': '確定要刪除該智能體嗎?',
'home.deleteSuccess': '刪除成功',
'home.deleteFailed': '刪除失敗',
'home.enableMemory': '請先在「配置角色」介面開啟記憶',
// 字典管理頁面文本
'dictManagement.pageTitle': '字典管理',
'dictManagement.searchPlaceholder': '請輸入字典名稱搜索',
'dictManagement.search': '搜索',
'dictManagement.addDictType': '新增字典類型',
'dictManagement.batchDeleteDictType': '批量刪除字典類型',
'dictManagement.dictName': '字典名稱',
'dictManagement.dictCode': '字典代碼',
'dictManagement.description': '描述',
'dictManagement.operation': '操作',
'dictManagement.edit': '修改',
'dictManagement.delete': '刪除',
'dictManagement.dictLabel': '字典標籤',
'dictManagement.dictValue': '字典值',
'dictManagement.sortOrder': '排序號',
'dictManagement.status': '狀態',
'dictManagement.itemsPerPage': '{items}條/頁',
'dictManagement.firstPage': '首頁',
'dictManagement.prevPage': '上一頁',
'dictManagement.nextPage': '下一頁',
'dictManagement.totalRecords': '共{total}條記錄',
'dictManagement.addDictData': '新增字典數據',
'dictManagement.editDictType': '修改字典類型',
'dictManagement.confirmDelete': '確定要刪除此字典類型嗎?',
'dictManagement.confirmBatchDelete': '確定要刪除選中的{count}個字典類型嗎?',
'dictManagement.selectFirst': '請先選擇需要刪除的字典類型',
'dictManagement.deleteSuccess': '刪除成功',
'dictManagement.deleteFailed': '刪除失敗',
'dictManagement.addSuccess': '新增成功',
'dictManagement.editSuccess': '修改成功',
'dictManagement.getDictListFailed': '獲取字典列表失敗',
'dictManagement.getDictDataListFailed': '獲取字典數據列表失敗',
'dictManagement.saveFailed': '保存失敗',
'dictManagement.loading': '拼命加載中',
'dictManagement.select': '選擇',
// 服務端管理頁面文本 // 服務端管理頁面文本
'serverSideManager.pageTitle': '服務端管理', 'serverSideManager.pageTitle': '服務端管理',
@@ -998,4 +1012,58 @@ export default {
'providerDialog.selectFieldsToDelete': '請先選擇要刪除的字段', 'providerDialog.selectFieldsToDelete': '請先選擇要刪除的字段',
'providerDialog.confirmBatchDeleteFields': '確定要刪除選中的{count}個字段嗎?', 'providerDialog.confirmBatchDeleteFields': '確定要刪除選中的{count}個字段嗎?',
'providerDialog.batchDeleteFieldsSuccess': '成功刪除{count}個字段', 'providerDialog.batchDeleteFieldsSuccess': '成功刪除{count}個字段',
};
// 預設角色管理頁面文本
'agentTemplateManagement.title': '預設角色管理',
'agentTemplateManagement.templateName': '模板名稱',
'agentTemplateManagement.action': '操作',
'templateQuickConfig.saveSuccess': '配置保存成功',
'templateQuickConfig.saveFailed': '配置保存失敗',
'agentTemplateManagement.createTemplate': '建立模板',
'agentTemplateManagement.editTemplate': '編輯模板',
'agentTemplateManagement.deleteTemplate': '刪除模板',
'agentTemplateManagement.deleteSuccess': '模板刪除成功',
'agentTemplateManagement.batchDelete': '批次刪除',
'agentTemplateManagement.batchDeleteSuccess': '批次刪除成功',
'agentTemplateManagement.selectTemplate': '請選擇模板',
'agentTemplateManagement.select': '選擇',
'agentTemplateManagement.searchPlaceholder': '請輸入模板名稱搜尋',
'agentTemplateManagement.search': '搜尋',
'agentTemplateManagement.serialNumber': '序號',
'agentTemplateManagement.selectAll': '全選',
'agentTemplateManagement.deselectAll': '取消全選',
'agentTemplateManagement.loading': '拼命加載中',
'agentTemplateManagement.confirmSingleDelete': '確定要刪除這個模板嗎?',
'agentTemplateManagement.confirmBatchDelete': '確定要刪除選中的 {count} 個模板嗎?',
'agentTemplateManagement.deleteFailed': '模板刪除失敗',
'agentTemplateManagement.batchDeleteFailed': '模板批次刪除失敗',
'agentTemplateManagement.deleteBackendError': '刪除失敗,請檢查後端服務是否正常',
// 模板快速配置
'templateQuickConfig.title': '模組快速設定',
'templateQuickConfig.agentSettings.agentName': '助手暱稱',
'templateQuickConfig.agentSettings.agentNamePlaceholder': '請輸入助手暱稱',
'templateQuickConfig.agentSettings.systemPrompt': '角色介紹',
'templateQuickConfig.agentSettings.systemPromptPlaceholder': '請輸入角色介紹',
'templateQuickConfig.saveConfig': '保存配置',
'templateQuickConfig.resetConfig': '重置配置',
'templateQuickConfig.configSaved': '配置保存成功',
'templateQuickConfig.configReset': '配置已重置',
'templateQuickConfig.confirmReset': '確定要重置配置嗎?',
'templateQuickConfig.confirm': '確定',
'templateQuickConfig.cancel': '取消',
'templateQuickConfig.templateNotFound': '未找到指定模板',
'templateQuickConfig.newTemplate': '新模板',
'warning': '警告',
'info': '提示',
'common.networkError': '網路請求失敗',
// SM2加密相關錯誤消息
'sm2.publicKeyNotConfigured': 'SM2公鑰未配置,請聯繫管理員',
'sm2.encryptionFailed': '密碼加密失敗',
'sm2.keyGenerationFailed': '金鑰對生成失敗',
'sm2.invalidPublicKey': '無效的公鑰格式',
'sm2.encryptionError': '加密過程中發生錯誤',
'sm2.publicKeyRetry': '正在重試獲取公鑰...',
'sm2.publicKeyRetryFailed': '公鑰獲取重試失敗'
}
+23
View File
@@ -124,6 +124,29 @@ const routes = [
return import('../views/ProviderManagement.vue') return import('../views/ProviderManagement.vue')
} }
}, },
// 添加默认角色管理路由
{
path: '/agent-template-management',
name: 'AgentTemplateManagement',
component: function () {
return import('../views/AgentTemplateManagement.vue')
}
},
// 添加模板快速配置路由
{
path: '/template-quick-config',
name: 'TemplateQuickConfig',
component: function () {
return import('../views/TemplateQuickConfig.vue')
}
},
{
path: '/provider-management',
name: 'ProviderManagement',
component: function () {
return import('../views/ProviderManagement.vue')
}
},
] ]
const router = new VueRouter({ const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/', base: process.env.VUE_APP_PUBLIC_PATH || '/',
+2 -1
View File
@@ -15,7 +15,8 @@ export default new Vuex.Store({
version: '', version: '',
beianIcpNum: 'null', beianIcpNum: 'null',
beianGaNum: 'null', beianGaNum: 'null',
allowUserRegister: false allowUserRegister: false,
sm2PublicKey: ''
} }
}, },
getters: { getters: {
+4
View File
@@ -11,4 +11,8 @@ select:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px transparent inset; -webkit-box-shadow: 0 0 0px 1000px transparent inset;
transition: background-color 5000s ease-in-out 0s; transition: background-color 5000s ease-in-out 0s;
background-color: transparent; background-color: transparent;
}
.el-footer {
height: 35px !important; /* 使用 !important 确保覆盖默认样式 */
} }
+56
View File
@@ -200,3 +200,59 @@ export function validateMobile(mobile, areaCode) {
} }
} }
/**
* 生成SM2密钥对(十六进制格式)
* @returns {Object} 包含公钥和私钥的对象
*/
export function generateSm2KeyPairHex() {
// 使用sm-crypto库生成SM2密钥对
const sm2 = require('sm-crypto').sm2;
const keypair = sm2.generateKeyPairHex();
return {
publicKey: keypair.publicKey,
privateKey: keypair.privateKey,
clientPublicKey: keypair.publicKey, // 客户端公钥
clientPrivateKey: keypair.privateKey // 客户端私钥
};
}
/**
* SM2公钥加密
* @param {string} publicKey 公钥(十六进制格式)
* @param {string} plainText 明文
* @returns {string} 加密后的密文(十六进制格式)
*/
export function sm2Encrypt(publicKey, plainText) {
if (!publicKey) {
throw new Error('公钥不能为null或undefined');
}
if (!plainText) {
throw new Error('明文不能为空');
}
const sm2 = require('sm-crypto').sm2;
// SM2加密,添加04前缀表示未压缩公钥
const encrypted = sm2.doEncrypt(plainText, publicKey, 1);
// 转换为十六进制格式(与后端保持一致,添加04前缀)
const result = "04" + encrypted;
return result;
}
/**
* SM2私钥解密
* @param {string} privateKey 私钥(十六进制格式)
* @param {string} cipherText 密文(十六进制格式)
* @returns {string} 解密后的明文
*/
export function sm2Decrypt(privateKey, cipherText) {
const sm2 = require('sm-crypto').sm2;
// 移除04前缀(与后端保持一致)
const dataWithoutPrefix = cipherText.startsWith("04") ? cipherText.substring(2) : cipherText;
// SM2解密
return sm2.doDecrypt(dataWithoutPrefix, privateKey, 1);
}
@@ -0,0 +1,633 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">{{ $t("agentTemplateManagement.title") }}</h2>
<div class="right-operations">
<el-input
:placeholder="$t('agentTemplateManagement.searchPlaceholder')"
v-model="search"
class="search-input"
clearable
@keyup.enter.native="handleSearch"
style="width: 240px"
/>
<el-button class="btn-search" @click="handleSearch">
{{ $t("agentTemplateManagement.search") }}
</el-button>
</div>
</div>
<!-- 主体内容 -->
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="template-card" shadow="never">
<el-table
ref="templateTable"
:data="templateList"
style="width: 100%"
v-loading="templateLoading"
:element-loading-text="$t('agentTemplateManagement.loading')"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
class="transparent-table"
:header-cell-style="{ padding: '10px 20px' }"
:cell-style="{ padding: '10px 20px' }"
>
<!-- 移除@row-click="handleRowClick" -->
<!-- 自定义选择列实现表头是"选择"文字数据行是小方框 -->
<el-table-column
:label="$t('agentTemplateManagement.select')"
align="center"
min-width="100"
>
<template slot-scope="scope">
<el-checkbox
v-model="scope.row.selected"
@change="handleRowSelectionChange(scope.row)"
@click.stop
></el-checkbox>
</template>
</el-table-column>
<!-- 模板名称 -->
<el-table-column
:label="$t('agentTemplateManagement.templateName')"
prop="agentName"
min-width="250"
show-overflow-tooltip
>
<template slot-scope="scope">
<span>{{ scope.row.agentName }}</span>
</template>
</el-table-column>
<!-- 修改为序号列并移动到此处 -->
<el-table-column
:label="$t('agentTemplateManagement.serialNumber')"
min-width="120"
align="center"
>
<template slot-scope="scope">
<span>{{ (currentPage - 1) * pageSize + scope.$index + 1 }}</span>
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column
:label="$t('agentTemplateManagement.action')"
min-width="250"
align="center"
>
<template slot-scope="scope">
<div style="display: flex; justify-content: center; gap: 15px">
<el-button type="text" @click="editTemplate(scope.row)">{{
$t("agentTemplateManagement.editTemplate")
}}</el-button>
<el-button type="text" @click="deleteTemplate(scope.row)">{{
$t("agentTemplateManagement.deleteTemplate")
}}</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 表格底部操作栏 -->
<div class="table_bottom">
<div class="ctrl_btn">
<el-button
type="primary"
@click="handleSelectAll"
size="mini"
class="select-all-btn"
>
{{
isAllSelected
? $t("agentTemplateManagement.deselectAll")
: $t("agentTemplateManagement.selectAll")
}}
</el-button>
<el-button type="success" @click="showAddTemplateDialog" size="mini">
{{ $t("agentTemplateManagement.createTemplate") }}
</el-button>
<el-button
type="danger"
@click="batchDeleteTemplate"
:disabled="!hasSelected"
size="mini"
>
{{ $t("agentTemplateManagement.batchDelete") }}
</el-button>
</div>
<!-- 分页 -->
<div class="custom-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="pageSizeOptions"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</el-card>
</div>
</div>
</div>
</div>
</template>
<script>
import HeaderBar from "@/components/HeaderBar";
import agentApi from "@/apis/module/agent";
export default {
name: "AgentTemplateManagement",
components: {
HeaderBar,
},
data() {
return {
// 模板相关
templateList: [],
templateLoading: false,
selectedTemplates: [],
isAllSelected: false, // 添加全选状态
search: "",
// 分页相关数据
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1,
pageSize: 10,
total: 0,
};
},
created() {
this.loadTemplateList();
},
// 在computed部分添加hasSelected属性
computed: {
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
return this.getVisiblePages();
},
hasSelected() {
return this.selectedTemplates.length > 0;
},
},
methods: {
// 加载模板列表
// 改进loadTemplateList方法的错误处理逻辑
loadTemplateList() {
this.templateLoading = true;
const params = {
page: this.currentPage,
limit: this.pageSize,
};
if (this.search) {
params.agentName = this.search;
}
try {
agentApi.getAgentTemplatesPage(
params,
(res) => {
// 更健壮的响应处理逻辑
if (res && typeof res === "object") {
if (res.data && res.data.code === 0) {
const responseData = res.data.data || {};
// 为每个模板添加selected属性
this.templateList = Array.isArray(responseData.list)
? responseData.list.map((item) => ({ ...item, selected: false }))
: [];
this.total =
typeof responseData.total === "number" ? responseData.total : 0;
} else {
this.templateList = [];
this.total = 0;
this.$message.error(
res?.data?.msg || this.$t("agentTemplateManagement.fetchTemplateFailed")
);
}
} else {
this.templateList = [];
this.total = 0;
this.$message.error(
this.$t("agentTemplateManagement.fetchTemplateBackendError")
);
}
this.templateLoading = false;
},
(error) => {
this.templateList = [];
this.total = 0;
this.templateLoading = false;
this.$message.error(this.$t("common.networkError"));
}
);
} catch (error) {
this.templateList = [];
this.total = 0;
this.templateLoading = false;
this.$message.error(this.$t("agentTemplateManagement.fetchTemplateBackendError"));
}
},
// 搜索模板
handleSearch() {
if (this.search) {
const searchValue = this.search.toLowerCase();
const filteredList = this.templateList.filter((template) =>
template.agentName.toLowerCase().includes(searchValue)
);
this.templateList = filteredList;
this.total = filteredList.length;
} else {
this.loadTemplateList();
}
},
// 修改showAddTemplateDialog方法,使其跳转到与编辑页面相同的页面
// 显示新增模板弹窗
showAddTemplateDialog() {
// 跳转到模板快速配置页面,不传递templateId参数表示新增
this.$router.push({
path: "/template-quick-config",
});
},
// 编辑模板
editTemplate(row) {
// 跳转到模板快速配置页面,并传递模板ID参数
this.$router.push({
path: "/template-quick-config",
query: { templateId: row.id },
});
},
// 删除模板
deleteTemplate(row) {
this.$confirm(
this.$t("agentTemplateManagement.confirmSingleDelete"),
this.$t("common.warning"),
{
confirmButtonText: this.$t("common.confirm"),
cancelButtonText: this.$t("common.cancel"),
type: "warning",
}
)
.then(() => {
agentApi.deleteAgentTemplate(row.id, (res) => {
if (res && typeof res === "object") {
// 检查res.data是否存在且包含code=0
if (res.data && res.data.code === 0) {
this.$message.success(this.$t("agentTemplateManagement.deleteSuccess"));
this.loadTemplateList();
} else {
this.$message.error(
res?.data?.msg || this.$t("agentTemplateManagement.deleteFailed")
);
}
} else {
this.$message.error(this.$t("agentTemplateManagement.deleteBackendError"));
}
});
})
.catch(() => {
this.$message.info(this.$t("common.deleteCancelled"));
});
},
// 批量删除模板
batchDeleteTemplate() {
if (this.selectedTemplates.length === 0) {
this.$message.warning(this.$t("agentTemplateManagement.selectTemplate"));
return;
}
this.$confirm(
this.$t("agentTemplateManagement.confirmBatchDelete", {
count: this.selectedTemplates.length,
}),
this.$t("common.warning"),
{
confirmButtonText: this.$t("common.confirm"),
cancelButtonText: this.$t("common.cancel"),
type: "warning",
}
)
.then(() => {
// 确保参数格式正确 - 将id数组作为请求体
const ids = this.selectedTemplates.map((template) => template.id);
agentApi.batchDeleteAgentTemplate(ids, (res) => {
if (res && typeof res === "object") {
if (res.data && res.data.code === 0) {
this.$message.success(
this.$t("agentTemplateManagement.batchDeleteSuccess")
);
// 重新加载模板列表
this.loadTemplateList();
// 清空选中状态
this.selectedTemplates = [];
this.isAllSelected = false;
} else {
this.$message.error(
res?.data?.msg || this.$t("agentTemplateManagement.batchDeleteFailed")
);
}
} else {
this.$message.error(this.$t("agentTemplateManagement.deleteBackendError"));
}
});
})
.catch(() => {
this.$message.info(this.$t("common.deleteCancelled"));
});
},
// 完善分页相关方法
handlePageChange(page) {
this.currentPage = page;
this.loadTemplateList();
},
handlePageSizeChange(size) {
this.pageSize = size;
this.currentPage = 1;
this.loadTemplateList();
},
goFirst() {
this.currentPage = 1;
},
goPrev() {
this.currentPage--;
},
goNext() {
this.currentPage++;
},
goToPage(page) {
this.currentPage = page;
},
getVisiblePages() {
const pages = [];
const totalPages = this.pageCount;
const currentPage = this.currentPage;
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 4) {
for (let i = 1; i <= 5; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
} else if (currentPage >= totalPages - 3) {
pages.push(1);
pages.push("...");
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push("...");
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
}
}
return pages;
},
// 修改handleSelectAll方法
handleSelectAll() {
this.isAllSelected = !this.isAllSelected;
this.templateList.forEach((row) => {
row.selected = this.isAllSelected;
});
// 更新选中的模板列表
this.selectedTemplates = this.isAllSelected ? [...this.templateList] : [];
},
// 处理行选择变化
handleRowSelectionChange(row) {
// 查找选中的模板
this.selectedTemplates = this.templateList.filter((template) => template.selected);
// 更新全选状态
this.isAllSelected =
this.templateList.length > 0 &&
this.selectedTemplates.length === this.templateList.length;
},
},
};
</script>
<style scoped lang="scss">
/* 基础背景和布局设置 */
.welcome {
min-height: 100vh;
display: flex;
flex-direction: column;
position: relative;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
background-size: cover;
overflow: hidden;
width: 100%;
}
/* 操作栏样式 */
.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;
align-items: center;
gap: 10px;
}
.search-input {
width: 200px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
/* 主容器样式 */
.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;
}
.content-panel {
width: 100%;
flex: 1;
display: flex;
overflow: hidden;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.content-area {
flex: 1;
min-width: 600px;
overflow-x: auto;
background-color: white;
display: flex;
flex-direction: column;
position: relative;
}
/* 模板卡片样式 */
.template-card {
border: none;
box-shadow: none;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
:deep(.el-card__body) {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
/* 表格样式 - 优化整合版 */
.transparent-table {
width: 100%;
flex: 1;
min-height: 0;
}
:deep(.el-table) {
height: 100%;
display: flex;
flex-direction: column;
--table-max-height: calc(100vh - 42vh);
max-height: var(--table-max-height);
/* 表格头部样式 */
.el-table__header th {
padding: 8px 0 !important;
height: 40px !important;
}
.el-table__header th .cell {
color: #303133 !important;
font-weight: 600;
}
/* 表格主体样式 */
.el-table__body {
.el-table__row td {
padding: 12px 0 !important;
border-bottom: 1px solid #ebeef5;
}
.el-table__row:hover {
background-color: #f5f7fa;
}
}
/* 表格按钮样式 */
.el-button--text {
color: #7079aa;
}
.el-button--text:hover {
color: #5a64b5;
}
/* 单元格文本样式 */
.cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* 表格底部操作栏 */
.table_bottom {
display: flex;
justify-content: space-between !important;
align-items: center;
margin-top: auto;
padding: 0 20px 15px !important;
width: 100% !important;
box-sizing: border-box !important;
}
/* 控制按钮样式 */
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 0 !important;
margin-left: 0 !important;
.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--success {
background: #5bc98c;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
</style>
+306 -191
View File
@@ -3,13 +3,19 @@
<HeaderBar /> <HeaderBar />
<div class="operation-bar"> <div class="operation-bar">
<h2 class="page-title">{{ $t('modelConfig.' + activeTab) }}</h2> <h2 class="page-title">{{ $t("modelConfig." + activeTab) }}</h2>
<div class="action-group"> <div class="action-group">
<div class="search-group"> <div class="search-group">
<el-input :placeholder="$t('modelConfig.searchPlaceholder')" v-model="search" class="search-input" clearable <el-input
@keyup.enter.native="handleSearch" style="width: 240px" /> :placeholder="$t('modelConfig.searchPlaceholder')"
v-model="search"
class="search-input"
clearable
@keyup.enter.native="handleSearch"
style="width: 240px"
/>
<el-button class="btn-search" @click="handleSearch"> <el-button class="btn-search" @click="handleSearch">
{{ $t('modelConfig.search') }} {{ $t("modelConfig.search") }}
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -19,77 +25,140 @@
<div class="main-wrapper"> <div class="main-wrapper">
<div class="content-panel"> <div class="content-panel">
<!-- 左侧导航 --> <!-- 左侧导航 -->
<el-menu :default-active="activeTab" class="nav-panel" @select="handleMenuSelect" <el-menu
style="background-size: cover; background-position: center;"> :default-active="activeTab"
class="nav-panel"
@select="handleMenuSelect"
style="background-size: cover; background-position: center"
>
<el-menu-item index="vad"> <el-menu-item index="vad">
<span class="menu-text">{{ $t('modelConfig.vad') }}</span> <span class="menu-text">{{ $t("modelConfig.vad") }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="asr"> <el-menu-item index="asr">
<span class="menu-text">{{ $t('modelConfig.asr') }}</span> <span class="menu-text">{{ $t("modelConfig.asr") }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="llm"> <el-menu-item index="llm">
<span class="menu-text">{{ $t('modelConfig.llm') }}</span> <span class="menu-text">{{ $t("modelConfig.llm") }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="vllm"> <el-menu-item index="vllm">
<span class="menu-text">{{ $t('modelConfig.vllm') }}</span> <span class="menu-text">{{ $t("modelConfig.vllm") }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="intent"> <el-menu-item index="intent">
<span class="menu-text">{{ $t('modelConfig.intent') }}</span> <span class="menu-text">{{ $t("modelConfig.intent") }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="tts"> <el-menu-item index="tts">
<span class="menu-text">{{ $t('modelConfig.tts') }}</span> <span class="menu-text">{{ $t("modelConfig.tts") }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="memory"> <el-menu-item index="memory">
<span class="menu-text">{{ $t('modelConfig.memory') }}</span> <span class="menu-text">{{ $t("modelConfig.memory") }}</span>
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
<!-- 右侧内容 --> <!-- 右侧内容 -->
<div class="content-area"> <div class="content-area">
<el-card class="model-card" shadow="never"> <el-card class="model-card" shadow="never">
<el-table ref="modelTable" style="width: 100%" v-loading="loading" <el-table
:element-loading-text="$t('modelConfig.loading')" element-loading-spinner="el-icon-loading" ref="modelTable"
element-loading-background="rgba(255, 255, 255, 0.7)" :header-cell-style="{ background: 'transparent' }" style="width: 100%"
:data="modelList" class="data-table" header-row-class-name="table-header" v-loading="loading"
:header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange"> :element-loading-text="$t('modelConfig.loading')"
<el-table-column type="selection" width="55" align="center" element-loading-spinner="el-icon-loading"
:cell-class-name="selectionCellClassName"></el-table-column> element-loading-background="rgba(255, 255, 255, 0.7)"
<el-table-column :label="$t('modelConfig.modelId')" prop="id" align="center"></el-table-column> :header-cell-style="{ background: 'transparent' }"
<el-table-column :label="$t('modelConfig.modelName')" prop="modelName" align="center"></el-table-column> :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"
:cell-class-name="selectionCellClassName"
></el-table-column>
<el-table-column
:label="$t('modelConfig.modelId')"
prop="id"
align="center"
></el-table-column>
<el-table-column
:label="$t('modelConfig.modelName')"
prop="modelName"
align="center"
></el-table-column>
<el-table-column :label="$t('modelConfig.provider')" align="center"> <el-table-column :label="$t('modelConfig.provider')" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
{{ scope.row.configJson.type || $t('modelConfig.unknown') }} {{ scope.row.configJson.type || $t("modelConfig.unknown") }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="$t('modelConfig.isEnabled')" align="center"> <el-table-column :label="$t('modelConfig.isEnabled')" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-switch v-model="scope.row.isEnabled" class="custom-switch" :active-value="1" :inactive-value="0" <el-switch
@change="handleStatusChange(scope.row)" /> v-model="scope.row.isEnabled"
class="custom-switch"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row)"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="$t('modelConfig.isDefault')" align="center"> <el-table-column :label="$t('modelConfig.isDefault')" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-switch v-model="scope.row.isDefault" class="custom-switch" :active-value="1" :inactive-value="0" <el-switch
@change="handleDefaultChange(scope.row)" /> v-model="scope.row.isDefault"
class="custom-switch"
:active-value="1"
:inactive-value="0"
@change="handleDefaultChange(scope.row)"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="activeTab === 'tts'" :label="$t('modelConfig.voiceManagement')" align="center"> <el-table-column
v-if="activeTab === 'tts'"
:label="$t('modelConfig.voiceManagement')"
align="center"
>
<template slot-scope="scope"> <template slot-scope="scope">
<el-button type="text" size="mini" @click="openTtsDialog(scope.row)" class="voice-management-btn"> <el-button
{{ $t('modelConfig.voiceManagement') }} type="text"
size="mini"
@click="openTtsDialog(scope.row)"
class="voice-management-btn"
>
{{ $t("modelConfig.voiceManagement") }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="$t('modelConfig.action')" align="center" width="210px"> <el-table-column
:label="$t('modelConfig.action')"
align="center"
width="210px"
>
<template slot-scope="scope"> <template slot-scope="scope">
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn"> <el-button
{{ $t('modelConfig.edit') }} type="text"
size="mini"
@click="editModel(scope.row)"
class="edit-btn"
>
{{ $t("modelConfig.edit") }}
</el-button> </el-button>
<el-button type="text" size="mini" @click="duplicateModel(scope.row)" class="edit-btn"> <el-button
{{ $t('modelConfig.duplicate') }} type="text"
size="mini"
@click="duplicateModel(scope.row)"
class="edit-btn"
>
{{ $t("modelConfig.duplicate") }}
</el-button> </el-button>
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn"> <el-button
{{ $t('modelConfig.delete') }} type="text"
size="mini"
@click="deleteModel(scope.row)"
class="delete-btn"
>
{{ $t("modelConfig.delete") }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -97,50 +166,96 @@
<div class="table-footer"> <div class="table-footer">
<div class="batch-actions"> <div class="batch-actions">
<el-button size="mini" type="primary" @click="selectAll"> <el-button size="mini" type="primary" @click="selectAll">
{{ isAllSelected ? {{
$t('modelConfig.deselectAll') : $t('modelConfig.selectAll') }} isAllSelected
? $t("modelConfig.deselectAll")
: $t("modelConfig.selectAll")
}}
</el-button> </el-button>
<el-button type="success" size="mini" @click="addModel" class="add-btn"> <el-button type="success" size="mini" @click="addModel" class="add-btn">
{{ $t('modelConfig.add') }} {{ $t("modelConfig.add") }}
</el-button> </el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete"> <el-button
{{ $t('modelConfig.delete') }} size="mini"
type="danger"
icon="el-icon-delete"
@click="batchDelete"
>
{{ $t("modelConfig.delete") }}
</el-button> </el-button>
</div> </div>
<div class="custom-pagination"> <div class="custom-pagination">
<el-select
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select"> v-model="pageSize"
<el-option v-for="item in pageSizeOptions" :key="item" @change="handlePageSizeChange"
:label="$t('modelConfig.itemsPerPage', { items: item })" :value="item"> class="page-size-select"
>
<el-option
v-for="item in pageSizeOptions"
:key="item"
:label="$t('modelConfig.itemsPerPage', { items: item })"
:value="item"
>
</el-option> </el-option>
</el-select> </el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">{{ <button
$t('modelConfig.firstPage') class="pagination-btn"
}}</button> :disabled="currentPage === 1"
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">{{ @click="goFirst"
$t('modelConfig.prevPage') >
}}</button> {{ $t("modelConfig.firstPage") }}
</button>
<button
class="pagination-btn"
:disabled="currentPage === 1"
@click="goPrev"
>
{{ $t("modelConfig.prevPage") }}
</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn" <button
:class="{ active: page === currentPage }" @click="goToPage(page)"> v-for="page in visiblePages"
:key="page"
class="pagination-btn"
:class="{ active: page === currentPage }"
@click="goToPage(page)"
>
{{ page }} {{ page }}
</button> </button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">{{ <button
$t('modelConfig.nextPage') class="pagination-btn"
}}</button> :disabled="currentPage === pageCount"
<span class="total-text">{{ $t('modelConfig.totalRecords', { total }) }}</span> @click="goNext"
>
{{ $t("modelConfig.nextPage") }}
</button>
<span class="total-text">{{
$t("modelConfig.totalRecords", { total })
}}</span>
</div> </div>
</div> </div>
</el-card> </el-card>
</div> </div>
</div> </div>
<ModelEditDialog :modelType="activeTab" :visible.sync="editDialogVisible" :modelData="editModelData" <ModelEditDialog
@save="handleModelSave" /> :modelType="activeTab"
<TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" :modelConfig="selectedModelConfig" /> :visible.sync="editDialogVisible"
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" /> :modelData="editModelData"
@save="handleModelSave"
/>
<TtsModel
:visible.sync="ttsDialogVisible"
:ttsModelId="selectedTtsModelId"
:modelConfig="selectedModelConfig"
/>
<AddModelDialog
:modelType="activeTab"
:visible.sync="addDialogVisible"
@confirm="handleAddConfirm"
/>
</div> </div>
<el-footer> <el-footer>
<version-footer /> <version-footer />
@@ -160,12 +275,12 @@ export default {
data() { data() {
return { return {
addDialogVisible: false, addDialogVisible: false,
activeTab: 'llm', activeTab: "llm",
search: '', search: "",
editDialogVisible: false, editDialogVisible: false,
editModelData: {}, editModelData: {},
ttsDialogVisible: false, ttsDialogVisible: false,
selectedTtsModelId: '', selectedTtsModelId: "",
modelList: [], modelList: [],
pageSizeOptions: [10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
currentPage: 1, currentPage: 1,
@@ -174,7 +289,7 @@ export default {
selectedModels: [], selectedModels: [],
isAllSelected: false, isAllSelected: false,
loading: false, loading: false,
selectedModelConfig: {} selectedModelConfig: {},
}; };
}, },
@@ -196,7 +311,9 @@ export default {
computed: { computed: {
modelTypeText() { modelTypeText() {
return this.$t('modelConfig.' + this.activeTab) || this.$t('modelConfig.modelConfig') return (
this.$t("modelConfig." + this.activeTab) || this.$t("modelConfig.modelConfig")
);
}, },
pageCount() { pageCount() {
return Math.ceil(this.total / this.pageSize); return Math.ceil(this.total / this.pageSize);
@@ -215,7 +332,7 @@ export default {
pages.push(i); pages.push(i);
} }
return pages; return pages;
} },
}, },
methods: { methods: {
@@ -223,7 +340,7 @@ export default {
updateSelectionHeaderText() { updateSelectionHeaderText() {
const thElement = document.querySelector(`.el-table__header th:nth-child(1) .cell`); const thElement = document.querySelector(`.el-table__header th:nth-child(1) .cell`);
if (thElement) { if (thElement) {
thElement.setAttribute('data-content', this.$t('modelConfig.select')); thElement.setAttribute("data-content", this.$t("modelConfig.select"));
} }
}, },
handlePageSizeChange(val) { handlePageSizeChange(val) {
@@ -238,27 +355,29 @@ export default {
}, },
headerCellClassName({ column, columnIndex }) { headerCellClassName({ column, columnIndex }) {
if (columnIndex === 0) { if (columnIndex === 0) {
return 'custom-selection-header'; return "custom-selection-header";
} }
return ''; return "";
}, },
selectionCellClassName({ row, column, rowIndex, columnIndex }) { selectionCellClassName({ row, column, rowIndex, columnIndex }) {
// 只对表头行设置data-content // 只对表头行设置data-content
if (rowIndex === undefined) { if (rowIndex === undefined) {
// 使用setTimeout确保DOM已经渲染完成 // 使用setTimeout确保DOM已经渲染完成
setTimeout(() => { setTimeout(() => {
const thElement = document.querySelector(`.el-table__header th:nth-child(1) .cell`); const thElement = document.querySelector(
`.el-table__header th:nth-child(1) .cell`
);
if (thElement) { if (thElement) {
thElement.setAttribute('data-content', this.$t('modelConfig.select')); thElement.setAttribute("data-content", this.$t("modelConfig.select"));
} }
}, 0); }, 0);
} }
return ''; return "";
}, },
handleMenuSelect(index) { handleMenuSelect(index) {
this.activeTab = index; this.activeTab = index;
this.currentPage = 1; // 重置到第一页 this.currentPage = 1; // 重置到第一页
this.pageSize = 10; // 可选:重置每页条数 this.pageSize = 10; // 可选:重置每页条数
this.loadData(); this.loadData();
}, },
handleSearch() { handleSearch() {
@@ -268,41 +387,41 @@ export default {
// 批量删除 // 批量删除
batchDelete() { batchDelete() {
if (this.selectedModels.length === 0) { if (this.selectedModels.length === 0) {
this.$message.warning(this.$t('modelConfig.selectModelsFirst')) this.$message.warning(this.$t("modelConfig.selectModelsFirst"));
return return;
} }
this.$confirm(this.$t('modelConfig.confirmBatchDelete'), this.$t('message.info'), { this.$confirm(this.$t("modelConfig.confirmBatchDelete"), this.$t("message.info"), {
confirmButtonText: this.$t('common.confirm'), confirmButtonText: this.$t("common.confirm"),
cancelButtonText: this.$t('common.cancel'), cancelButtonText: this.$t("common.cancel"),
type: 'warning' type: "warning",
}).then(() => {
const deletePromises = this.selectedModels.map(model =>
new Promise(resolve => {
Api.model.deleteModel(
model.id,
({ data }) => resolve(data.code === 0)
)
})
)
Promise.all(deletePromises).then(results => {
if (results.every(Boolean)) {
this.$message.success({
message: this.$t('modelConfig.batchDeleteSuccess'),
showClose: true
})
this.loadData()
} else {
this.$message.error({
message: this.$t('modelConfig.partialDeleteFailed'),
showClose: true
})
}
})
}).catch(() => {
this.$message.info(this.$t('modelConfig.deleteCancelled'))
}) })
.then(() => {
const deletePromises = this.selectedModels.map(
(model) =>
new Promise((resolve) => {
Api.model.deleteModel(model.id, ({ data }) => resolve(data.code === 0));
})
);
Promise.all(deletePromises).then((results) => {
if (results.every(Boolean)) {
this.$message.success({
message: this.$t("modelConfig.batchDeleteSuccess"),
showClose: true,
});
this.loadData();
} else {
this.$message.error({
message: this.$t("modelConfig.partialDeleteFailed"),
showClose: true,
});
}
});
})
.catch(() => {
this.$message.info(this.$t("modelConfig.deleteCancelled"));
});
}, },
addModel() { addModel() {
this.addDialogVisible = true; this.addDialogVisible = true;
@@ -318,31 +437,30 @@ export default {
}, },
// 删除单个模型 // 删除单个模型
deleteModel(model) { deleteModel(model) {
this.$confirm(this.$t('modelConfig.confirmDelete'), this.$t('message.info'), { this.$confirm(this.$t("modelConfig.confirmDelete"), this.$t("message.info"), {
confirmButtonText: this.$t('common.confirm'), confirmButtonText: this.$t("common.confirm"),
cancelButtonText: this.$t('common.cancel'), cancelButtonText: this.$t("common.cancel"),
type: 'warning' type: "warning",
}).then(() => { })
Api.model.deleteModel( .then(() => {
model.id, Api.model.deleteModel(model.id, ({ data }) => {
({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success({ this.$message.success({
message: this.$t('modelConfig.deleteSuccess'), message: this.$t("modelConfig.deleteSuccess"),
showClose: true showClose: true,
}) });
this.loadData() this.loadData();
} else { } else {
this.$message.error({ this.$message.error({
message: data.msg || this.$t('modelConfig.deleteFailed'), message: data.msg || this.$t("modelConfig.deleteFailed"),
showClose: true showClose: true,
}) });
} }
} });
) })
}).catch(() => { .catch(() => {
this.$message.info(this.$t('modelConfig.deleteCancelled')) this.$message.info(this.$t("modelConfig.deleteCancelled"));
}) });
}, },
handleCurrentChange(page) { handleCurrentChange(page) {
this.currentPage = page; this.currentPage = page;
@@ -353,32 +471,27 @@ export default {
const id = formData.id; const id = formData.id;
if (this.editModelData.duplicateMode) { if (this.editModelData.duplicateMode) {
Api.model.addModel({ modelType, provideCode, formData }, Api.model.addModel({ modelType, provideCode, formData }, ({ data }) => {
({ data }) => { if (data.code === 0) {
if (data.code === 0) { this.$message.success(this.$t("modelConfig.duplicateSuccess"));
this.$message.success(this.$t('modelConfig.duplicateSuccess')); this.loadData();
this.loadData(); this.editDialogVisible = false;
this.editDialogVisible = false; } else {
} else { this.$message.error(data.msg || this.$t("modelConfig.duplicateFailed"));
this.$message.error(data.msg || this.$t('modelConfig.duplicateFailed'));
}
done && done(); // 调用done回调关闭加载状态
})
}
else {
Api.model.updateModel(
{ modelType, provideCode, id, formData },
({ data }) => {
if (data.code === 0) {
this.$message.success(this.$t('modelConfig.saveSuccess'));
this.loadData();
this.editDialogVisible = false;
} else {
this.$message.error(data.msg || this.$t('modelConfig.saveFailed'));
}
done && done(); // 调用done回调关闭加载状态
} }
); done && done(); // 调用done回调关闭加载状态
});
} else {
Api.model.updateModel({ modelType, provideCode, id, formData }, ({ data }) => {
if (data.code === 0) {
this.$message.success(this.$t("modelConfig.saveSuccess"));
this.loadData();
this.editDialogVisible = false;
} else {
this.$message.error(data.msg || this.$t("modelConfig.saveFailed"));
}
done && done(); // 调用done回调关闭加载状态
});
} }
}, },
selectAll() { selectAll() {
@@ -405,21 +518,21 @@ export default {
...newModel, ...newModel,
isDefault: newModel.isDefault ? 1 : 0, isDefault: newModel.isDefault ? 1 : 0,
isEnabled: newModel.isEnabled ? 1 : 0, isEnabled: newModel.isEnabled ? 1 : 0,
configJson: newModel.configJson configJson: newModel.configJson,
} },
}; };
Api.model.addModel(params, ({ data }) => { Api.model.addModel(params, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success({ this.$message.success({
message: this.$t('modelConfig.addSuccess'), message: this.$t("modelConfig.addSuccess"),
showClose: true showClose: true,
}); });
this.loadData(); this.loadData();
} else { } else {
this.$message.error({ this.$message.error({
message: data.msg || this.$t('modelConfig.addFailed'), message: data.msg || this.$t("modelConfig.addFailed"),
showClose: true showClose: true,
}); });
} }
}); });
@@ -454,7 +567,7 @@ export default {
modelType: this.activeTab, modelType: this.activeTab,
modelName: this.search, modelName: this.search,
page: this.currentPage, page: this.currentPage,
limit: this.pageSize limit: this.pageSize,
}; };
Api.model.getModelList(params, ({ data }) => { Api.model.getModelList(params, ({ data }) => {
@@ -463,41 +576,43 @@ export default {
this.modelList = data.data.list; this.modelList = data.data.list;
this.total = data.data.total; this.total = data.data.total;
} else { } else {
this.$message.error(data.msg || this.$t('modelConfig.fetchModelsFailed')); this.$message.error(data.msg || this.$t("modelConfig.fetchModelsFailed"));
} }
}); });
}, },
// 处理启用/禁用状态变更 // 处理启用/禁用状态变更
handleStatusChange(model) { handleStatusChange(model) {
const newStatus = model.isEnabled ? 1 : 0 const newStatus = model.isEnabled ? 1 : 0;
const originalStatus = model.isEnabled const originalStatus = model.isEnabled;
model.isEnabled = !model.isEnabled model.isEnabled = !model.isEnabled;
Api.model.updateModelStatus( Api.model.updateModelStatus(model.id, newStatus, ({ data }) => {
model.id, if (data.code === 0) {
newStatus, this.$message.success(
({ data }) => { newStatus === 1
if (data.code === 0) { ? this.$t("modelConfig.enableSuccess")
this.$message.success(newStatus === 1 ? this.$t('modelConfig.enableSuccess') : this.$t('modelConfig.disableSuccess')) : this.$t("modelConfig.disableSuccess")
// 保持新状态 );
model.isEnabled = newStatus // 保持新状态
} else { model.isEnabled = newStatus;
// 操作失败时恢复原状态 // 刷新表格数据
model.isEnabled = originalStatus this.loadData();
this.$message.error(data.msg || this.$t('modelConfig.operationFailed')) } else {
} // 操作失败时恢复原状态
model.isEnabled = originalStatus;
this.$message.error(data.msg || this.$t("modelConfig.operationFailed"));
} }
) });
}, },
handleDefaultChange(model) { handleDefaultChange(model) {
Api.model.setDefaultModel(model.id, ({ data }) => { Api.model.setDefaultModel(model.id, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success(this.$t('modelConfig.setDefaultSuccess')) this.$message.success(this.$t("modelConfig.setDefaultSuccess"));
this.loadData() this.loadData();
} }
}) });
} },
}, },
}; };
</script> </script>
@@ -561,11 +676,12 @@ export default {
min-width: 242px; min-width: 242px;
height: 100%; height: 100%;
border-right: 1px solid #ebeef5; border-right: 1px solid #ebeef5;
background: background: linear-gradient(
linear-gradient(120deg, 120deg,
rgba(107, 140, 255, 0.3) 0%, rgba(107, 140, 255, 0.3) 0%,
rgba(169, 102, 255, 0.3) 25%, rgba(169, 102, 255, 0.3) 25%,
transparent 60%), transparent 60%
),
url("../assets/model/model.png") no-repeat center / cover; url("../assets/model/model.png") no-repeat center / cover;
padding: 16px 0; padding: 16px 0;
flex-shrink: 0; flex-shrink: 0;
@@ -594,7 +710,7 @@ export default {
} }
.nav-panel .el-menu-item.is-active::before { .nav-panel .el-menu-item.is-active::before {
content: ''; content: "";
position: absolute; position: absolute;
left: 15px; left: 15px;
top: 50%; top: 50%;
@@ -653,7 +769,7 @@ export default {
::v-deep .search-input .el-input__inner { ::v-deep .search-input .el-input__inner {
border-radius: 4px; border-radius: 4px;
border: 1px solid #DCDFE6; border: 1px solid #dcdfe6;
background-color: white; background-color: white;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
@@ -773,13 +889,13 @@ export default {
} }
.batch-actions .el-button:first-child { .batch-actions .el-button:first-child {
background: linear-gradient(135deg, #409EFF, #6B8CFF); background: linear-gradient(135deg, #409eff, #6b8cff);
border: none; border: none;
color: white; color: white;
} }
.batch-actions .el-button:first-child:hover { .batch-actions .el-button:first-child:hover {
background: linear-gradient(135deg, #3A8EE6, #5A7CFF); background: linear-gradient(135deg, #3a8ee6, #5a7cff);
} }
.el-table th /deep/ .el-table__cell { .el-table th /deep/ .el-table__cell {
@@ -831,7 +947,6 @@ export default {
} }
::v-deep .data-table { ::v-deep .data-table {
&.el-table::before, &.el-table::before,
&.el-table::after, &.el-table::after,
&.el-table__inner-wrapper::before { &.el-table__inner-wrapper::before {
@@ -915,7 +1030,7 @@ export default {
padding: 0 12px; padding: 0 12px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
background: #DEE7FF; background: #dee7ff;
color: #606266; color: #606266;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
@@ -30,8 +30,9 @@
show-overflow-tooltip> show-overflow-tooltip>
<template slot-scope="scope"> <template slot-scope="scope">
<div v-if="isSensitiveParam(scope.row.paramCode)"> <div v-if="isSensitiveParam(scope.row.paramCode)">
<span v-if="!scope.row.showValue">{{ maskSensitiveValue(scope.row.paramValue) <span v-if="!scope.row.showValue">
}}</span> {{ maskSensitiveValue(scope.row.paramValue) }}
</span>
<span v-else>{{ scope.row.paramValue }}</span> <span v-else>{{ scope.row.paramValue }}</span>
<el-button size="mini" type="text" @click="toggleSensitiveValue(scope.row)"> <el-button size="mini" type="text" @click="toggleSensitiveValue(scope.row)">
{{ scope.row.showValue ? $t('paramManagement.hide') : {{ scope.row.showValue ? $t('paramManagement.hide') :
@@ -120,7 +121,7 @@ export default {
dialogVisible: false, dialogVisible: false,
dialogTitle: "新增参数", dialogTitle: "新增参数",
isAllSelected: false, isAllSelected: false,
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"], sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key", "password", "mqtt_signature_key", "private_key"],
paramForm: { paramForm: {
id: null, id: null,
paramCode: "", paramCode: "",
@@ -0,0 +1,478 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">{{ $t('templateQuickConfig.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>
<div class="header-actions">
<el-button type="primary" class="save-btn" @click="saveConfig">{{ $t('templateQuickConfig.saveConfig') }}</el-button>
<el-button class="reset-btn" @click="resetConfig">{{ $t('templateQuickConfig.resetConfig') }}</el-button>
<button class="custom-close-btn" @click="goToHome">
×
</button>
</div>
</div>
<div class="divider"></div>
<el-form ref="form" :model="form" label-width="72px" class="full-height-form">
<!-- 助手昵称 -->
<el-form-item :label="$t('templateQuickConfig.agentSettings.agentName')" prop="agentName" class="nickname-item">
<el-input
v-model="form.agentName"
:placeholder="$t('templateQuickConfig.agentSettings.agentNamePlaceholder')"
:validate-event="false"
class="form-input"
/>
</el-form-item>
<!-- 角色介绍 -->
<el-form-item :label="$t('templateQuickConfig.agentSettings.systemPrompt')" prop="systemPrompt" class="description-item">
<el-input
v-model="form.systemPrompt"
type="textarea"
:placeholder="$t('templateQuickConfig.agentSettings.systemPromptPlaceholder')"
:validate-event="false"
show-word-limit
maxlength="2000"
/>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</div>
</div>
</template>
<script>
import HeaderBar from "@/components/HeaderBar.vue";
import agentApi from '@/apis/module/agent';
// 默认模型配置常量
const DEFAULT_MODEL_CONFIG = {
ttsModelId: "TTS_EdgeTTS",
vadModelId: "VAD_SileroVAD",
asrModelId: "ASR_FunASR",
llmModelId: "LLM_ChatGLMLLM",
vllmModelId: "VLLM_ChatGLMVLLM",
memModelId: "Memory_nomem",
intentModelId: "Intent_function_call"
};
export default {
name: 'TemplateQuickConfig',
components: { HeaderBar },
data() {
return {
form: {
agentCode: "小智",
agentName: "",
systemPrompt: "",
sort: 0,
model: { ...DEFAULT_MODEL_CONFIG }
},
templateId: '',
originalForm: null
};
},
methods: {
// 返回模板管理页面
goToHome() {
this.$router.push('/agent-template-management');
},
// 保存配置
saveConfig() {
const configData = this.prepareConfigData();
if (this.templateId) {
this.updateExistingTemplate(configData);
} else {
this.createNewTemplate(configData);
}
},
// 准备配置数据
prepareConfigData() {
return {
id: this.templateId || '',
agentCode: this.form.agentCode,
agentName: this.form.agentName,
systemPrompt: this.form.systemPrompt,
sort: this.form.sort,
functions: [],
// 包含必要的模型字段以确保API调用成功
...this.form.model
};
},
// 更新现有模板
updateExistingTemplate(configData) {
agentApi.updateAgentTemplate(configData, (res) => {
if (res && res.data && res.data.code === 0) {
this.$message.success({
message: this.$t('templateQuickConfig.saveSuccess'),
showClose: true
});
this.originalForm = JSON.parse(JSON.stringify(this.form));
} else {
this.$message.error({
message: res?.data?.msg || this.$t('templateQuickConfig.saveFailed'),
showClose: true
});
}
});
},
// 创建新模板
createNewTemplate(configData) {
agentApi.addAgentTemplate(configData, (res) => {
if (res && res.data && res.data.code === 0) {
this.$message.success({
message: this.$t('templateQuickConfig.saveSuccess'),
showClose: true
});
this.goToHome();
} else {
this.$message.error({
message: res?.data?.msg || this.$t('templateQuickConfig.saveFailed'),
showClose: true
});
}
});
},
// 重置配置
resetConfig() {
this.$confirm(
this.$t('templateQuickConfig.confirmReset'),
this.$t('common.tip'),
{
confirmButtonText: this.$t('common.confirm'),
cancelButtonText: this.$t('common.cancel'),
type: 'warning'
}
).then(() => {
if (this.originalForm) {
this.form = JSON.parse(JSON.stringify(this.originalForm));
}
this.$message.success({
message: this.$t('templateQuickConfig.resetSuccess'),
showClose: true
});
}).catch(() => {});
},
// 根据ID获取模板
fetchTemplateById(templateId) {
agentApi.getAgentTemplateById(templateId, (res) => {
if (res && res.data && res.data.code === 0 && res.data.data) {
const template = res.data.data;
this.applyTemplateData(template);
this.templateId = templateId;
this.originalForm = JSON.parse(JSON.stringify(this.form));
} else {
this.$message.error(res?.data?.msg || this.$t('templateQuickConfig.templateNotFound'));
}
});
},
// 应用模板数据
applyTemplateData(templateData) {
this.form = {
...this.form,
agentName: templateData.agentName || this.form.agentName,
agentCode: templateData.agentCode || this.form.agentCode,
systemPrompt: templateData.systemPrompt || this.form.systemPrompt,
sort: templateData.sort || this.form.sort,
model: {
ttsModelId: templateData.ttsModelId || this.form.model.ttsModelId,
vadModelId: templateData.vadModelId || this.form.model.vadModelId,
asrModelId: templateData.asrModelId || this.form.model.asrModelId,
llmModelId: templateData.llmModelId || this.form.model.llmModelId,
vllmModelId: templateData.vllmModelId || this.form.model.vllmModelId,
memModelId: templateData.memModelId || this.form.model.memModelId,
intentModelId: templateData.intentModelId || this.form.model.intentModelId
}
};
},
// 设置默认模板值
setDefaultTemplateValues() {
this.form = {
...this.form,
agentName: this.$t('templateQuickConfig.newTemplate'),
agentCode: '小智',
systemPrompt: '',
sort: 1
};
this.originalForm = JSON.parse(JSON.stringify(this.form));
},
// 获取模板列表并设置排序号
fetchTemplateListForSort() {
agentApi.getAgentTemplate((res) => {
if (res && res.data && res.data.code === 0) {
const templateList = res.data.data || [];
if (templateList.length > 0) {
const maxSort = Math.max(...templateList.map(t => t.sort || 0));
this.form.sort = maxSort + 1;
} else {
this.form.sort = 1;
}
} else {
this.form.sort = 1;
}
this.originalForm = JSON.parse(JSON.stringify(this.form));
});
}
},
// 组件挂载时执行初始化
mounted() {
const templateId = this.$route.query.templateId;
if (templateId) {
// 编辑模式:加载现有模板
this.fetchTemplateById(templateId);
} else {
// 新建模式:设置默认值并获取排序号
this.form.agentName = this.$t('templateQuickConfig.newTemplate');
this.fetchTemplateListForSort();
}
}
};
</script>
<style scoped>
.welcome {
min-width: 900px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
background-size: cover;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5vh 24px;
}
.page-title {
font-size: 24px;
margin: 0;
color: #2c3e50;
}
.main-wrapper {
margin: 1vh 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;
padding: 0 !important;
}
.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%;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.config-card {
background: white !important;
border-radius: 15px !important;
overflow: hidden !important;
height: 100% !important;
margin: 0 !important;
border: none !important;
box-shadow: none !important;
}
.full-height-form {
display: flex;
flex-direction: column;
padding: 20px;
gap: 20px;
height: calc(100% - 120px);
}
.nickname-item {
margin-bottom: 0 !important;
}
.description-item {
margin-bottom: 0 !important;
display: flex;
flex-direction: column;
}
::v-deep .description-item .el-textarea {
height: 300px;
min-height: 200px;
max-height: 400px;
display: flex;
flex-direction: column;
}
::v-deep .description-item .el-textarea__inner {
height: 100% !important;
min-height: 200px !important;
max-height: 400px !important;
resize: vertical !important;
line-height: 1.6;
font-size: 14px;
padding: 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background-color: #fff;
color: #303133;
}
::v-deep .el-form-item__label {
font-size: 12px !important;
color: #3d4566 !important;
font-weight: 400;
line-height: 22px;
padding-bottom: 2px;
}
::v-deep .el-textarea .el-input__count {
color: #909399;
background: rgba(255, 255, 255, 0.8);
position: absolute;
font-size: 12px;
right: 10px;
bottom: 10px;
padding: 2px 5px;
border-radius: 3px;
}
.config-header {
padding: 20px;
display: flex;
align-items: center;
gap: 15px;
background: #f8f9ff;
position: relative;
}
.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;
}
.header-title {
font-size: 20px;
font-weight: 600;
color: #2c3e50;
}
.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;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
padding: 0;
outline: none;
}
.custom-close-btn:hover {
color: #409EFF;
border-color: #409EFF;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.header-actions .save-btn {
background: #5778ff;
color: white;
border: none;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
font-size: 14px;
}
.header-actions .reset-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
}
.header-actions .custom-close-btn {
position: static;
transform: none;
width: 32px;
height: 32px;
margin-left: 8px;
}
</style>
+26 -65
View File
@@ -257,11 +257,17 @@ export default {
cancelButtonText: this.$t('common.cancel'), cancelButtonText: this.$t('common.cancel'),
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
resetPassword(row.userId).then(() => { Api.admin.resetUserPassword(row.userid, ({ data }) => {
this.$message.success(this.$t('user.resetPasswordSuccess')); if (data.code === 0) {
this.getList(); // 显示生成的默认密码
}).catch(() => { this.$alert(this.$t('user.resetPasswordSuccess') + '\n\n' + this.$t('user.generatedPassword') + ': ' + data.data, this.$t('common.success'), {
this.$message.error(this.$t('user.operationFailed')); confirmButtonText: this.$t('common.confirm'),
dangerouslyUseHTMLString: true
});
this.fetchUsers();
} else {
this.$message.error(data.msg || this.$t('user.operationFailed'));
}
}); });
}).catch(() => { }).catch(() => {
this.$message.info(this.$t('common.deleteCancelled')); this.$message.info(this.$t('common.deleteCancelled'));
@@ -273,11 +279,13 @@ export default {
cancelButtonText: this.$t('common.cancel'), cancelButtonText: this.$t('common.cancel'),
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
deleteUser(row.userId).then(() => { Api.admin.deleteUser(row.userid, ({ data }) => {
this.$message.success(this.$t('user.deleteUserSuccess')); if (data.code === 0) {
this.getList(); this.$message.success(this.$t('user.deleteUserSuccess'));
}).catch(() => { this.fetchUsers();
this.$message.error(this.$t('user.operationFailed')); } else {
this.$message.error(data.msg || this.$t('user.operationFailed'));
}
}); });
}).catch(() => { }).catch(() => {
this.$message.info(this.$t('common.deleteCancelled')); this.$message.info(this.$t('common.deleteCancelled'));
@@ -338,67 +346,20 @@ export default {
// 用户取消操作 // 用户取消操作
}); });
}, },
// 这个方法已被batchDelete替代,保留用于向后兼容
handleBatchDelete() { handleBatchDelete() {
if (!this.selection || this.selection.length === 0) { this.batchDelete();
this.$message.warning(this.$t('user.selectUsersFirst'));
return;
}
const userIds = this.selection.map(item => item.userId);
this.$confirm(this.$t('user.confirmDeleteSelected', { count: this.selection.length }), this.$t('common.warning'), {
confirmButtonText: this.$t('common.confirm'),
cancelButtonText: this.$t('common.cancel'),
type: 'warning'
}).then(() => {
this.loading = true;
batchDeleteUsers(userIds).then(res => {
this.loading = false;
this.getList();
this.selection = [];
if (res.successCount === userIds.length) {
this.$message.success(this.$t('user.deleteSuccess', { count: res.successCount }));
} else if (res.successCount === 0) {
this.$message.error(this.$t('user.deleteFailed'));
} else {
this.$message.warning(this.$t('user.partialDelete', { successCount: res.successCount, failCount: res.failCount }));
}
}).catch(() => {
this.loading = false;
this.$message.error(this.$t('user.deleteError'));
});
}).catch(() => {
this.loading = false;
this.$message.info(this.$t('user.deleteCancelled'));
});
}, },
// This method has been fixed to use existing functionality
handleBatchStatusChange(status) { handleBatchStatusChange(status) {
if (!this.selection || this.selection.length === 0) { const selectedUsers = this.userList.filter(user => user.selected);
if (selectedUsers.length === 0) {
this.$message.warning(this.$t('user.selectUsersFirst')); this.$message.warning(this.$t('user.selectUsersFirst'));
return; return;
} }
const actionText = status === 1 ? this.$t('user.enable') : this.$t('user.disable'); // Call the existing handleChangeStatus method which already handles both single and multiple users
const userIds = this.selection.map(item => item.userId); this.handleChangeStatus(selectedUsers, status);
this.$confirm(this.$t('user.confirmStatusChange', { action: actionText, count: this.selection.length }), this.$t('common.warning'), {
confirmButtonText: this.$t('common.confirm'),
cancelButtonText: this.$t('common.cancel'),
type: 'warning'
}).then(() => {
this.loading = true;
updateUserStatus(userIds, status).then(() => {
this.loading = false;
this.getList();
this.selection = [];
this.$message.success(this.$t('user.statusChangeSuccess', { action: actionText, count: userIds.length }));
}).catch(() => {
this.loading = false;
this.$message.error(this.$t('user.operationFailed'));
});
}).catch(() => {
this.loading = false;
this.$message.info(this.$t('common.deleteCancelled'));
});
}, },
}, },
}; };
+27 -6
View File
@@ -148,7 +148,7 @@
import Api from "@/apis/api"; import Api from "@/apis/api";
import VersionFooter from "@/components/VersionFooter.vue"; import VersionFooter from "@/components/VersionFooter.vue";
import i18n, { changeLanguage } from "@/i18n"; import i18n, { changeLanguage } from "@/i18n";
import { getUUID, goToPage, showDanger, showSuccess, validateMobile } from "@/utils"; import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from "@/utils";
import { mapState } from "vuex"; import { mapState } from "vuex";
export default { export default {
@@ -161,6 +161,7 @@ export default {
allowUserRegister: (state) => state.pubConfig.allowUserRegister, allowUserRegister: (state) => state.pubConfig.allowUserRegister,
enableMobileRegister: (state) => state.pubConfig.enableMobileRegister, enableMobileRegister: (state) => state.pubConfig.enableMobileRegister,
mobileAreaList: (state) => state.pubConfig.mobileAreaList, mobileAreaList: (state) => state.pubConfig.mobileAreaList,
sm2PublicKey: (state) => state.pubConfig.sm2PublicKey,
}), }),
// 获取当前语言 // 获取当前语言
currentLanguage() { currentLanguage() {
@@ -284,10 +285,31 @@ export default {
if (!this.validateInput(this.form.captcha, 'login.requiredCaptcha')) { if (!this.validateInput(this.form.captcha, 'login.requiredCaptcha')) {
return; return;
} }
// 加密密码
let encryptedPassword;
try {
// 拼接验证码和密码
const captchaAndPassword = this.form.captcha + this.form.password;
encryptedPassword = sm2Encrypt(this.sm2PublicKey, captchaAndPassword);
} catch (error) {
console.error("密码加密失败:", error);
showDanger(this.$t('sm2.encryptionFailed'));
return;
}
const plainUsername = this.form.username;
this.form.captchaId = this.captchaUuid; this.form.captchaId = this.captchaUuid;
// 加密
const loginData = {
username: plainUsername,
password: encryptedPassword,
captchaId: this.form.captchaId
};
Api.user.login( Api.user.login(
this.form, loginData,
({ data }) => { ({ data }) => {
showSuccess(this.$t('login.loginSuccess')); showSuccess(this.$t('login.loginSuccess'));
this.$store.commit("setToken", JSON.stringify(data.data)); this.$store.commit("setToken", JSON.stringify(data.data));
@@ -296,7 +318,7 @@ export default {
(err) => { (err) => {
// 直接使用后端返回的国际化消息 // 直接使用后端返回的国际化消息
let errorMessage = err.data.msg || "登录失败"; let errorMessage = err.data.msg || "登录失败";
showDanger(errorMessage); showDanger(errorMessage);
if ( if (
err.data != null && err.data != null &&
@@ -319,7 +341,7 @@ export default {
}, },
goToForgetPassword() { goToForgetPassword() {
goToPage("/retrieve-password"); goToPage("/retrieve-password");
}, }
}, },
}; };
</script> </script>
@@ -329,8 +351,7 @@ export default {
.login-type-container { .login-type-container {
margin: 10px 20px; margin: 10px 20px;
display: flex; display: flex;
justify-content: space-between; justify-content: center;
align-items: center;
} }
.title-language-dropdown { .title-language-dropdown {
+40 -14
View File
@@ -45,9 +45,10 @@
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;"> <div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;"> <div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png" /> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png" />
<el-input v-model="form.captcha" :placeholder="$t('register.captchaPlaceholder')" style="flex: 1;" /> <el-input v-model="form.captcha" :placeholder="$t('register.captchaPlaceholder')"
style="flex: 1;" />
</div> </div>
<img loading="lazy" v-if="captchaUrl" :src="captchaUrl" alt="验证码" <img loading="lazy" v-if="captchaUrl" :src="captchaUrl" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" @click="fetchCaptcha" /> style="width: 150px; height: 40px; cursor: pointer;" @click="fetchCaptcha" />
</div> </div>
@@ -56,7 +57,8 @@
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;"> <div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;"> <div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/phone.png" /> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/phone.png" />
<el-input v-model="form.mobileCaptcha" :placeholder="$t('register.mobileCaptchaPlaceholder')" style="flex: 1;" maxlength="6" /> <el-input v-model="form.mobileCaptcha" :placeholder="$t('register.mobileCaptchaPlaceholder')"
style="flex: 1;" maxlength="6" />
</div> </div>
<el-button type="primary" class="send-captcha-btn" :disabled="!canSendMobileCaptcha" <el-button type="primary" class="send-captcha-btn" :disabled="!canSendMobileCaptcha"
@click="sendMobileCaptcha"> @click="sendMobileCaptcha">
@@ -70,13 +72,15 @@
<!-- 密码输入框 --> <!-- 密码输入框 -->
<div class="input-box"> <div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" /> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.password" :placeholder="$t('register.passwordPlaceholder')" type="password" show-password /> <el-input v-model="form.password" :placeholder="$t('register.passwordPlaceholder')" type="password"
show-password />
</div> </div>
<!-- 新增确认密码 --> <!-- 新增确认密码 -->
<div class="input-box"> <div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" /> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.confirmPassword" :placeholder="$t('register.confirmPasswordPlaceholder')" type="password" show-password /> <el-input v-model="form.confirmPassword" :placeholder="$t('register.confirmPasswordPlaceholder')"
type="password" show-password />
</div> </div>
<!-- 验证码部分保持相同 --> <!-- 验证码部分保持相同 -->
@@ -121,11 +125,10 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue'; import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess, validateMobile } from '@/utils'; import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from '@/utils';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
// 导入语言切换功能 // 导入语言切换功能
import { changeLanguage } from '@/i18n';
export default { export default {
name: 'register', name: 'register',
@@ -136,7 +139,8 @@ export default {
...mapState({ ...mapState({
allowUserRegister: state => state.pubConfig.allowUserRegister, allowUserRegister: state => state.pubConfig.allowUserRegister,
enableMobileRegister: state => state.pubConfig.enableMobileRegister, enableMobileRegister: state => state.pubConfig.enableMobileRegister,
mobileAreaList: state => state.pubConfig.mobileAreaList mobileAreaList: state => state.pubConfig.mobileAreaList,
sm2PublicKey: state => state.pubConfig.sm2PublicKey,
}), }),
canSendMobileCaptcha() { canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode); return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
@@ -156,7 +160,7 @@ export default {
}, },
captchaUrl: '', captchaUrl: '',
countdown: 0, countdown: 0,
timer: null timer: null,
} }
}, },
mounted() { mounted() {
@@ -240,7 +244,7 @@ export default {
}, },
// 注册逻辑 // 注册逻辑
register() { async register() {
if (this.enableMobileRegister) { if (this.enableMobileRegister) {
// 手机号注册验证 // 手机号注册验证
if (!validateMobile(this.form.mobile, this.form.areaCode)) { if (!validateMobile(this.form.mobile, this.form.areaCode)) {
@@ -270,12 +274,34 @@ export default {
if (!this.validateInput(this.form.captcha, this.$t('register.requiredCaptcha'))) { if (!this.validateInput(this.form.captcha, this.$t('register.requiredCaptcha'))) {
return; return;
} }
// 加密
if (this.enableMobileRegister) { let encryptedPassword;
this.form.username = this.form.areaCode + this.form.mobile try {
// 拼接验证码和密码
const captchaAndPassword = this.form.captcha + this.form.password;
encryptedPassword = sm2Encrypt(this.sm2PublicKey, captchaAndPassword);
} catch (error) {
console.error("密码加密失败:", error);
showDanger(this.$t('sm2.encryptionFailed'));
return;
} }
Api.user.register(this.form, ({ data }) => { let plainUsername;
if (this.enableMobileRegister) {
plainUsername = this.form.areaCode + this.form.mobile;
} else {
plainUsername = this.form.username;
}
// 准备注册数据
const registerData = {
username: plainUsername,
password: encryptedPassword,
captchaId: this.form.captchaId,
mobileCaptcha: this.form.mobileCaptcha
};
Api.user.register(registerData, ({ data }) => {
showSuccess(this.$t('register.registerSuccess')) showSuccess(this.$t('register.registerSuccess'))
goToPage('/login') goToPage('/login')
}, (err) => { }, (err) => {
+32 -18
View File
@@ -53,7 +53,7 @@
<el-button type="primary" class="send-captcha-btn" :disabled="!canSendMobileCaptcha" <el-button type="primary" class="send-captcha-btn" :disabled="!canSendMobileCaptcha"
@click="sendMobileCaptcha"> @click="sendMobileCaptcha">
<span> <span>
{{ countdown > 0 ? `${countdown}秒后重试` : $t('retrievePassword.sendCaptcha') }} {{ countdown > 0 ? `${countdown}${$t('register.secondsLater')}` : $t('retrievePassword.getMobileCaptcha') }}
</span> </span>
</el-button> </el-button>
</div> </div>
@@ -67,7 +67,7 @@
<!-- 确认新密码 --> <!-- 确认新密码 -->
<div class="input-box"> <div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" /> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.confirmPassword" :placeholder="$t('retrievePassword.confirmPasswordPlaceholder')" type="password" show-password /> <el-input v-model="form.confirmPassword" :placeholder="$t('retrievePassword.confirmNewPasswordPlaceholder')" type="password" show-password />
</div> </div>
<!-- 修改底部链接 --> <!-- 修改底部链接 -->
@@ -101,7 +101,7 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue'; import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess, validateMobile } from '@/utils'; import { getUUID, goToPage, showDanger, showSuccess, validateMobile, sm2Encrypt } from '@/utils';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
// 导入语言切换功能 // 导入语言切换功能
@@ -115,7 +115,8 @@ export default {
computed: { computed: {
...mapState({ ...mapState({
allowUserRegister: state => state.pubConfig.allowUserRegister, allowUserRegister: state => state.pubConfig.allowUserRegister,
mobileAreaList: state => state.pubConfig.mobileAreaList mobileAreaList: state => state.pubConfig.mobileAreaList,
sm2PublicKey: state => state.pubConfig.sm2PublicKey
}), }),
canSendMobileCaptcha() { canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode); return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
@@ -151,7 +152,7 @@ export default {
} else { } else {
console.error('验证码加载异常:', error); console.error('验证码加载异常:', error);
showDanger('验证码加载失败,点击刷新'); showDanger(this.$t('register.captchaLoadFailed'));
} }
}); });
}, },
@@ -168,12 +169,12 @@ export default {
// 发送手机验证码 // 发送手机验证码
sendMobileCaptcha() { sendMobileCaptcha() {
if (!validateMobile(this.form.mobile, this.form.areaCode)) { if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码'); showDanger(this.$t('retrievePassword.inputCorrectMobile'));
return; return;
} }
// 验证图形验证码 // 验证图形验证码
if (!this.validateInput(this.form.captcha, '请输入图形验证码')) { if (!this.validateInput(this.form.captcha, this.$t('retrievePassword.captchaRequired'))) {
this.fetchCaptcha(); this.fetchCaptcha();
return; return;
} }
@@ -201,9 +202,9 @@ export default {
captcha: this.form.captcha, captcha: this.form.captcha,
captchaId: this.form.captchaId captchaId: this.form.captchaId
}, (res) => { }, (res) => {
showSuccess('验证码发送成功'); showSuccess(this.$t('retrievePassword.captchaSendSuccess'));
}, (err) => { }, (err) => {
showDanger(err.data.msg || '验证码发送失败'); showDanger(err.data.msg || this.$t('register.captchaSendFailed'));
this.countdown = 0; this.countdown = 0;
this.fetchCaptcha(); this.fetchCaptcha();
}); });
@@ -213,32 +214,45 @@ export default {
retrievePassword() { retrievePassword() {
// 验证逻辑 // 验证逻辑
if (!validateMobile(this.form.mobile, this.form.areaCode)) { if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码'); showDanger(this.$t('retrievePassword.inputCorrectMobile'));
return; return;
} }
if (!this.form.captcha) { if (!this.form.captcha) {
showDanger('请输入图形验证码'); showDanger(this.$t('retrievePassword.captchaRequired'));
return; return;
} }
if (!this.form.mobileCaptcha) { if (!this.form.mobileCaptcha) {
showDanger('请输入短信验证码'); showDanger(this.$t('retrievePassword.mobileCaptchaRequired'));
return; return;
} }
if (this.form.newPassword !== this.form.confirmPassword) { if (this.form.newPassword !== this.form.confirmPassword) {
showDanger('两次输入的密码不一致'); showDanger(this.$t('retrievePassword.passwordsNotMatch'));
return;
}
// 加密密码
let encryptedPassword;
try {
// 拼接图形验证码和新密码进行加密
const captchaAndPassword = this.form.captcha + this.form.newPassword;
encryptedPassword = sm2Encrypt(this.sm2PublicKey, captchaAndPassword);
} catch (error) {
console.error("密码加密失败:", error);
showDanger(this.$t('sm2.encryptionFailed'));
return; return;
} }
Api.user.retrievePassword({ Api.user.retrievePassword({
phone: this.form.areaCode + this.form.mobile, phone: this.form.areaCode + this.form.mobile,
password: this.form.newPassword, password: encryptedPassword,
code: this.form.mobileCaptcha code: this.form.mobileCaptcha,
captchaId: this.form.captchaId
}, (res) => { }, (res) => {
showSuccess('密码重置成功'); showSuccess(this.$t('retrievePassword.passwordUpdateSuccess'));
goToPage('/login'); goToPage('/login');
}, (err) => { }, (err) => {
showDanger(err.data.msg || '重置失败'); showDanger(err.data.msg || this.$t('message.error'));
if (err.data != null && err.data.msg != null && err.data.msg.indexOf('图形验证码') > -1) { if (err.data != null && err.data.msg != null && (err.data.msg.indexOf('图形验证码') > -1 || err.data.msg.indexOf('Captcha') > -1)) {
this.fetchCaptcha() this.fetchCaptcha()
} }
}); });
+59 -2
View File
@@ -41,6 +41,12 @@ server:
# 可选:设备白名单,如果设置了白名单,那么白名单的机器无论是什么token都可以连接。 # 可选:设备白名单,如果设置了白名单,那么白名单的机器无论是什么token都可以连接。
#allowed_devices: #allowed_devices:
# - "24:0A:C4:1D:3B:F0" # MAC地址列表 # - "24:0A:C4:1D:3B:F0" # MAC地址列表
# MQTT网关配置,用于通过OTA下发到设备,根据mqtt_gateway的.env文件配置,格式为host:port
mqtt_gateway: null
# MQTT签名密钥,用于生成MQTT连接密码,根据mqtt_gateway的.env文件配置
mqtt_signature_key: null
# UDP网关配置
udp_gateway: null
log: log:
# 设置控制台输出的日志格式,时间、日志级别、标签、消息 # 设置控制台输出的日志格式,时间、日志级别、标签、消息
log_format: "<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>" log_format: "<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>"
@@ -427,7 +433,26 @@ ASR:
enable_itn: true # 逆文本归一化 enable_itn: true # 逆文本归一化
#language: "zh" # 语种,支持zh、en、ja、ko等 #language: "zh" # 语种,支持zh、en、ja、ko等
context: "" # 上下文信息,用于提高识别准确率,不超过10000 Token context: "" # 上下文信息,用于提高识别准确率,不超过10000 Token
XunfeiStreamASR:
# 讯飞流式语音识别服务
# 需要先在讯飞开放平台创建应用,获取以下认证信息
# 讯飞开放平台地址:https://www.xfyun.cn/
# 创建应用后,在"我的应用"中获取:
# - APPID
# - APISecret
# - APIKey
type: xunfei_stream
# 必填参数 - 讯飞开放平台应用信息
app_id: 你的APPID
api_key: 你的APIKey
api_secret: 你的APISecret
# 识别参数配置
domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等
language: zh_cn # 语言,zh_cn:中文,en_us:英文
accent: mandarin # 方言,mandarin:普通话
dwa: wpgs # 动态修正,wpgs:实时返回中间结果
# 调整音频处理参数以提高长语音识别质量
output_dir: tmp/
VAD: VAD:
SileroVAD: SileroVAD:
@@ -585,6 +610,16 @@ VLLM:
url: https://dashscope.aliyuncs.com/compatible-mode/v1 url: https://dashscope.aliyuncs.com/compatible-mode/v1
# 可在这里找到你的api key https://bailian.console.aliyun.com/?apiKey=1#/api-key # 可在这里找到你的api key https://bailian.console.aliyun.com/?apiKey=1#/api-key
api_key: 你的api_key api_key: 你的api_key
XunfeiSparkLLM:
# 定义LLM API类型
type: openai
# 先新建应用,在下面的地址
# 开通应用地址:https://console.xfyun.cn/app/myapp
# 有免费额度,但也要开通服务,才能获取api_key
# 每一个模型都需要单独开通,每一个模型的api_password都不同,例如Lite模型在https://console.xfyun.cn/services/cbm 开通
base_url: https://ark.cn-beijing.volces.com/api/v3
model_name: lite
api_key: 你的api_password
TTS: TTS:
# 当前支持的type为edge、doubao,可自行适配 # 当前支持的type为edge、doubao,可自行适配
EdgeTTS: EdgeTTS:
@@ -936,4 +971,26 @@ TTS:
# sample_rate: 24000 # 采样率:16000, 24000, 48000 # sample_rate: 24000 # 采样率:16000, 24000, 48000
# volume: 50 # 音量:0-100 # volume: 50 # 音量:0-100
# rate: 1 # 语速:0.5~2 # rate: 1 # 语速:0.5~2
# pitch: 1 # 语调:0.5~2 # pitch: 1 # 语调:0.5~2
XunFeiTTS:
# 讯飞TTS服务 官方网站:https://www.xfyun.cn/
# 登录讯飞语音技术平台 https://console.xfyun.cn/app/myapp 创建相关应用
# 选择需要的服务获取api相关配置 https://console.xfyun.cn/services/uts
# 为需要使用的应用(APPID)购买相关服务 例如:超拟人合成 https://console.xfyun.cn/services/uts
type: xunfei_stream
api_url: wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6
app_id: 你的app_id
api_secret: 你的api_secret
api_key: 你的api_key
voice: x5_lingxiaoxuan_flow
output_dir: tmp/
# 以下可不用设置,使用默认设置,注意V5音色不支持口语化配置
# oral_level: mid # 口语化等级:high, mid, low
# spark_assist: 1 # 是否通过大模型进行口语化 开启:1, 关闭:0
# stop_split: 0 # 关闭服务端拆句 不关闭:0,关闭:1
# remain: 0 # 是否保留原书面语的样子 保留:1, 不保留:0
# format: raw # 音频格式:raw(PCM), lame(MP3), speex, opus, opus-wb, opus-swb, speex-wb
# sample_rate: 24000 # 采样率:16000, 8000, 24000
# volume: 50 # 音量:0-100
# speed: 50 # 语速:0-100
# pitch: 50 # 语调:0-100
+1 -1
View File
@@ -5,7 +5,7 @@ from config.config_loader import load_config
from config.settings import check_config_file from config.settings import check_config_file
from datetime import datetime from datetime import datetime
SERVER_VERSION = "0.8.1" SERVER_VERSION = "0.8.3"
_logger_initialized = False _logger_initialized = False
+81 -4
View File
@@ -1,5 +1,8 @@
import json import json
import time import time
import base64
import hashlib
import hmac
from aiohttp import web from aiohttp import web
from core.utils.util import get_local_ip from core.utils.util import get_local_ip
from core.api.base_handler import BaseHandler from core.api.base_handler import BaseHandler
@@ -10,6 +13,24 @@ TAG = __name__
class OTAHandler(BaseHandler): class OTAHandler(BaseHandler):
def __init__(self, config: dict): def __init__(self, config: dict):
super().__init__(config) super().__init__(config)
def generate_password_signature(self, content: str, secret_key: str) -> str:
"""生成MQTT密码签名
Args:
content: 签名内容 (clientId + '|' + username)
secret_key: 密钥
Returns:
str: Base64编码的HMAC-SHA256签名
"""
try:
hmac_obj = hmac.new(secret_key.encode('utf-8'), content.encode('utf-8'), hashlib.sha256)
signature = hmac_obj.digest()
return base64.b64encode(signature).decode('utf-8')
except Exception as e:
self.logger.bind(tag=TAG).error(f"生成MQTT密码签名失败: {e}")
return ""
def _get_websocket_url(self, local_ip: str, port: int) -> str: def _get_websocket_url(self, local_ip: str, port: int) -> str:
"""获取websocket地址 """获取websocket地址
@@ -58,10 +79,66 @@ class OTAHandler(BaseHandler):
"version": data_json["application"].get("version", "1.0.0"), "version": data_json["application"].get("version", "1.0.0"),
"url": "", "url": "",
}, },
"websocket": {
"url": self._get_websocket_url(local_ip, port),
},
} }
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
if mqtt_gateway_endpoint: # 如果配置了非空字符串
# 尝试从请求数据中获取设备型号
device_model = "default"
try:
if "device" in data_json and isinstance(data_json["device"], dict):
device_model = data_json["device"].get("model", "default")
elif "model" in data_json:
device_model = data_json["model"]
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
except Exception as e:
self.logger.bind(tag=TAG).error(f"获取设备型号失败: {e}")
group_id = "GID_default"
mac_address_safe = device_id.replace(":", "_")
mqtt_client_id = f"{group_id}@@@{mac_address_safe}@@@{mac_address_safe}"
# 构建用户数据
user_data = {
"ip": "unknown"
}
try:
user_data_json = json.dumps(user_data)
username = base64.b64encode(user_data_json.encode('utf-8')).decode('utf-8')
except Exception as e:
self.logger.bind(tag=TAG).error(f"生成用户名失败: {e}")
username = ""
# 生成密码
password = ""
signature_key = server_config.get("mqtt_signature_key", "")
if signature_key:
password = self.generate_password_signature(mqtt_client_id + "|" + username, signature_key)
if not password:
password = "" # 签名失败则留空,由设备决定是否允许无密码
else:
self.logger.bind(tag=TAG).warning("缺少MQTT签名密钥,密码留空")
# 构建MQTT配置(直接使用 mqtt_gateway 字符串)
return_json["mqtt_gateway"] = {
"endpoint": mqtt_gateway_endpoint,
"client_id": mqtt_client_id,
"username": username,
"password": password,
"publish_topic": "device-server",
"subscribe_topic": f"devices/p2p/{mac_address_safe}"
}
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发MQTT网关配置")
else: # 未配置 mqtt_gateway,下发 WebSocket
return_json["websocket"] = {
"url": self._get_websocket_url(local_ip, port),
}
self.logger.bind(tag=TAG).info(f"未配置MQTT网关,为设备 {device_id} 下发WebSocket配置")
response = web.Response( response = web.Response(
text=json.dumps(return_json, separators=(",", ":")), text=json.dumps(return_json, separators=(",", ":")),
content_type="application/json", content_type="application/json",
@@ -90,4 +167,4 @@ class OTAHandler(BaseHandler):
response = web.Response(text="OTA接口异常", content_type="text/plain") response = web.Response(text="OTA接口异常", content_type="text/plain")
finally: finally:
self._add_cors_headers(response) self._add_cors_headers(response)
return response return response
@@ -0,0 +1,442 @@
import json
import hmac
import base64
import hashlib
import asyncio
import websockets
import opuslib_next
from time import mktime
from datetime import datetime
from urllib.parse import urlencode
from typing import List
from config.logger import setup_logging
from wsgiref.handlers import format_date_time
from core.providers.asr.base import ASRProviderBase
from core.providers.asr.dto.dto import InterfaceType
TAG = __name__
logger = setup_logging()
# 帧状态常量
STATUS_FIRST_FRAME = 0 # 第一帧的标识
STATUS_CONTINUE_FRAME = 1 # 中间帧标识
STATUS_LAST_FRAME = 2 # 最后一帧的标识
class ASRProvider(ASRProviderBase):
def __init__(self, config, delete_audio_file):
super().__init__()
self.interface_type = InterfaceType.STREAM
self.config = config
self.text = ""
self.decoder = opuslib_next.Decoder(16000, 1)
self.asr_ws = None
self.forward_task = None
self.is_processing = False
self.server_ready = False
self.last_frame_sent = False # 标记是否已发送最终帧
self.best_text = "" # 保存最佳识别结果
# 讯飞配置
self.app_id = config.get("app_id")
self.api_key = config.get("api_key")
self.api_secret = config.get("api_secret")
if not all([self.app_id, self.api_key, self.api_secret]):
raise ValueError("必须提供app_id、api_key和api_secret")
# 识别参数
self.iat_params = {
"domain": config.get("domain", "slm"),
"language": config.get("language", "zh_cn"),
"accent": config.get("accent", "mandarin"),
"dwa": config.get("dwa", "wpgs"),
"result": {
"encoding": "utf8",
"compress": "raw",
"format": "plain"
}
}
self.output_dir = config.get("output_dir", "tmp/")
self.delete_audio_file = delete_audio_file
def create_url(self) -> str:
"""生成认证URL"""
url = 'ws://iat.cn-huabei-1.xf-yun.com/v1'
# 生成RFC1123格式的时间戳
now = datetime.now()
date = format_date_time(mktime(now.timetuple()))
# 拼接字符串
signature_origin = "host: " + "iat.cn-huabei-1.xf-yun.com" + "\n"
signature_origin += "date: " + date + "\n"
signature_origin += "GET " + "/v1 " + "HTTP/1.1"
# 进行hmac-sha256进行加密
signature_sha = hmac.new(self.api_secret.encode('utf-8'), signature_origin.encode('utf-8'),
digestmod=hashlib.sha256).digest()
signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8')
authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % (
self.api_key, "hmac-sha256", "host date request-line", signature_sha)
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
# 将请求的鉴权参数组合为字典
v = {
"authorization": authorization,
"date": date,
"host": "iat.cn-huabei-1.xf-yun.com"
}
# 拼接鉴权参数,生成url
url = url + '?' + urlencode(v)
return url
async def open_audio_channels(self, conn):
await super().open_audio_channels(conn)
async def receive_audio(self, conn, audio, audio_have_voice):
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
# 存储音频数据用于声纹识别
if not hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
conn.asr_audio_for_voiceprint.append(audio)
# 如果本次有声音,且之前没有建立连接
if audio_have_voice and self.asr_ws is None and not self.is_processing:
try:
await self._start_recognition(conn)
except Exception as e:
logger.bind(tag=TAG).error(f"建立ASR连接失败: {str(e)}")
await self._cleanup(conn)
return
# 发送当前音频数据
if self.asr_ws and self.is_processing and self.server_ready:
try:
pcm_frame = self.decoder.decode(audio, 960)
await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME)
except Exception as e:
logger.bind(tag=TAG).warning(f"发送音频数据时发生错误: {e}")
await self._cleanup(conn)
async def _start_recognition(self, conn):
"""开始识别会话"""
try:
self.is_processing = True
# 建立WebSocket连接
ws_url = self.create_url()
logger.bind(tag=TAG).info(f"正在连接ASR服务: {ws_url[:50]}...")
self.asr_ws = await websockets.connect(
ws_url,
max_size=1000000000,
ping_interval=None,
ping_timeout=None,
close_timeout=10,
)
logger.bind(tag=TAG).info("ASR WebSocket连接已建立")
self.server_ready = False
self.last_frame_sent = False
self.best_text = ""
self.forward_task = asyncio.create_task(self._forward_results(conn))
# 发送首帧音频
if conn.asr_audio and len(conn.asr_audio) > 0:
first_audio = conn.asr_audio[-1] if conn.asr_audio else b''
pcm_frame = self.decoder.decode(first_audio, 960) if first_audio else b''
await self._send_audio_frame(pcm_frame, STATUS_FIRST_FRAME)
self.server_ready = True
logger.bind(tag=TAG).info("已发送首帧,开始识别")
# 发送缓存的音频数据
for cached_audio in conn.asr_audio[-10:]:
try:
pcm_frame = self.decoder.decode(cached_audio, 960)
await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME)
except Exception as e:
logger.bind(tag=TAG).info(f"发送缓存音频数据时发生错误: {e}")
break
except Exception as e:
logger.bind(tag=TAG).error(f"建立ASR连接失败: {str(e)}")
if hasattr(e, "__cause__") and e.__cause__:
logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}")
if self.asr_ws:
await self.asr_ws.close()
self.asr_ws = None
self.is_processing = False
raise
async def _send_audio_frame(self, audio_data: bytes, status: int):
"""发送音频帧"""
if not self.asr_ws:
return
audio_b64 = base64.b64encode(audio_data).decode('utf-8')
frame_data = {
"header": {
"status": status,
"app_id": self.app_id
},
"parameter": {
"iat": self.iat_params
},
"payload": {
"audio": {
"audio": audio_b64,
"sample_rate": 16000,
"encoding": "raw"
}
}
}
await self.asr_ws.send(json.dumps(frame_data, ensure_ascii=False))
# 标记是否发送了最终帧
if status == STATUS_LAST_FRAME:
self.last_frame_sent = True
logger.bind(tag=TAG).info("标记最终帧已发送")
async def _forward_results(self, conn):
"""转发识别结果"""
try:
while self.asr_ws and not conn.stop_event.is_set():
# 获取当前连接的音频数据
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
try:
# 如果已发送最终帧,增加超时时间等待完整结果
timeout = 3.0 if self.last_frame_sent else 30.0
response = await asyncio.wait_for(self.asr_ws.recv(), timeout=timeout)
result = json.loads(response)
logger.bind(tag=TAG).debug(f"收到ASR结果: {result}")
header = result.get("header", {})
payload = result.get("payload", {})
code = header.get("code", 0)
status = header.get("status", 0)
if code != 0:
logger.bind(tag=TAG).error(f"识别错误,错误码: {code}, 消息: {header.get('message', '')}")
if code in [10114, 10160]: # 连接问题
break
continue
# 处理识别结果
if payload and "result" in payload:
text_data = payload["result"]["text"]
if text_data:
# 解码base64文本
decoded_text = base64.b64decode(text_data).decode('utf-8')
text_json = json.loads(decoded_text)
# 提取文本内容
text_ws = text_json.get('ws', [])
result_text = ''
for i in text_ws:
for j in i.get("cw", []):
w = j.get("w", "")
result_text += w
# 更新识别文本 - 实时更新策略
if result_text and result_text.strip() not in ['', '', '.', ',', '']:
# 实时更新:正常情况下都更新,提高响应速度
should_update = True
# 保存最佳文本(最长的有意义文本)
if (len(result_text) > len(self.best_text) and
result_text.strip() not in ['', '?', '', '.']):
self.best_text = result_text
logger.bind(tag=TAG).debug(f"保存最佳文本: {self.best_text}")
# 如果已发送最终帧,只过滤明显的无效结果
if self.last_frame_sent:
# 最终帧后拒绝问号等明显错误的结果
if result_text.strip() in ['', '?', '', '.']:
should_update = False
logger.bind(tag=TAG).warning(f"最终帧后拒绝无效文本: {result_text}")
if should_update:
self.text = result_text
logger.bind(tag=TAG).info(f"实时更新识别文本: {self.text} (最终帧已发送: {self.last_frame_sent})")
# 识别完成,但如果还没发送最终帧,继续等待
if status == 2:
logger.bind(tag=TAG).info(f"识别完成状态已到达,当前识别文本: {self.text}")
# 如果还没发送最终帧,继续等待
if not self.last_frame_sent:
logger.bind(tag=TAG).info("识别完成但最终帧未发送,继续等待...")
continue
# 已发送最终帧且收到完成状态,使用最佳文本作为最终结果
if self.best_text and len(self.best_text) > len(self.text):
logger.bind(tag=TAG).info(f"使用最佳文本作为最终结果: {self.text} -> {self.best_text}")
self.text = self.best_text
logger.bind(tag=TAG).info(f"获取到最终完整文本: {self.text}")
conn.reset_vad_states()
if len(audio_data) > 15: # 确保有足够音频数据
# 准备处理结果
pass
break
except asyncio.TimeoutError:
if self.last_frame_sent:
# 超时时也使用最佳文本
if self.best_text and len(self.best_text) > len(self.text):
logger.bind(tag=TAG).info(f"超时,使用最佳文本: {self.text} -> {self.best_text}")
self.text = self.best_text
logger.bind(tag=TAG).info(f"最终帧后超时,使用结果: {self.text}")
break
# 如果还没发送最终帧,继续等待
continue
except websockets.ConnectionClosed:
logger.bind(tag=TAG).info("ASR服务连接已关闭")
self.is_processing = False
break
except Exception as e:
logger.bind(tag=TAG).error(f"处理ASR结果时发生错误: {str(e)}")
if hasattr(e, "__cause__") and e.__cause__:
logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}")
self.is_processing = False
break
except Exception as e:
logger.bind(tag=TAG).error(f"ASR结果转发任务发生错误: {str(e)}")
if hasattr(e, "__cause__") and e.__cause__:
logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}")
finally:
if self.asr_ws:
await self.asr_ws.close()
self.asr_ws = None
self.is_processing = False
if conn:
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
if hasattr(conn, 'has_valid_voice'):
conn.has_valid_voice = False
async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]):
"""处理语音停止,发送最后一帧并处理识别结果"""
try:
# 先发送最后一帧表示音频结束
if self.asr_ws and self.is_processing:
try:
# 取最后一个有效的音频帧作为最后一帧数据
last_frame = b''
if asr_audio_task:
last_audio = asr_audio_task[-1]
last_frame = self.decoder.decode(last_audio, 960)
await self._send_audio_frame(last_frame, STATUS_LAST_FRAME)
logger.bind(tag=TAG).info("已发送最后一帧")
# 发送最终帧后,给_forward_results适当时间处理最终结果
# 减少等待时间,提高响应速度
await asyncio.sleep(0.1) # 从3秒减少到1秒
logger.bind(tag=TAG).info(f"准备处理最终识别结果: {self.text}")
except Exception as e:
logger.bind(tag=TAG).error(f"发送最后一帧失败: {e}")
# 调用父类的handle_voice_stop方法处理识别结果
await super().handle_voice_stop(conn, asr_audio_task)
except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
import traceback
logger.bind(tag=TAG).debug(f"异常详情: {traceback.format_exc()}")
def stop_ws_connection(self):
if self.asr_ws:
asyncio.create_task(self.asr_ws.close())
self.asr_ws = None
self.is_processing = False
async def _cleanup(self, conn):
"""清理资源"""
logger.bind(tag=TAG).info(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}")
# 发送最后一帧
if self.asr_ws and self.is_processing:
try:
await self._send_audio_frame(b'', STATUS_LAST_FRAME)
await asyncio.sleep(0.1)
logger.bind(tag=TAG).info("已发送最后一帧")
except Exception as e:
logger.bind(tag=TAG).error(f"发送最后一帧失败: {e}")
# 状态重置
self.is_processing = False
self.server_ready = False
self.last_frame_sent = False
self.best_text = ""
logger.bind(tag=TAG).info("ASR状态已重置")
# 清理任务
if self.forward_task and not self.forward_task.done():
self.forward_task.cancel()
try:
await asyncio.wait_for(self.forward_task, timeout=1.0)
except asyncio.CancelledError:
pass
except Exception as e:
logger.bind(tag=TAG).debug(f"forward_task取消异常: {e}")
finally:
self.forward_task = None
# 关闭连接
if self.asr_ws:
try:
logger.bind(tag=TAG).debug("正在关闭WebSocket连接")
await asyncio.wait_for(self.asr_ws.close(), timeout=2.0)
logger.bind(tag=TAG).debug("WebSocket连接已关闭")
except Exception as e:
logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}")
finally:
self.asr_ws = None
# 清理连接的音频缓存
if conn:
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
if hasattr(conn, 'has_valid_voice'):
conn.has_valid_voice = False
logger.bind(tag=TAG).info("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format):
"""获取识别结果"""
result = self.text
self.text = ""
return result, None
async def close(self):
"""资源清理方法"""
if self.asr_ws:
await self.asr_ws.close()
self.asr_ws = None
if self.forward_task:
self.forward_task.cancel()
try:
await self.forward_task
except asyncio.CancelledError:
pass
self.forward_task = None
self.is_processing = False
# 清理所有连接的音频缓冲区
if hasattr(self, '_connections'):
for conn in self._connections.values():
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
if hasattr(conn, 'has_valid_voice'):
conn.has_valid_voice = False
@@ -0,0 +1,527 @@
import os
import time
import uuid
import json
import hmac
import queue
import base64
import hashlib
import asyncio
import traceback
import websockets
from asyncio import Task
from config.logger import setup_logging
from core.utils import opus_encoder_utils
from core.utils.tts import MarkdownCleaner
from urllib.parse import urlencode, urlparse
from core.providers.tts.base import TTSProviderBase
from core.providers.tts.dto.dto import SentenceType, ContentType, InterfaceType
TAG = __name__
logger = setup_logging()
class XunfeiWSAuth:
@staticmethod
def create_auth_url(api_key, api_secret, api_url):
"""生成讯飞WebSocket认证URL"""
parsed_url = urlparse(api_url)
host = parsed_url.netloc
path = parsed_url.path
# 获取UTC时间,讯飞要求使用RFC1123格式
now = time.gmtime()
date = time.strftime('%a, %d %b %Y %H:%M:%S GMT', now)
# 构造签名字符串
signature_origin = f"host: {host}\ndate: {date}\nGET {path} HTTP/1.1"
# 计算签名
signature_sha = hmac.new(
api_secret.encode('utf-8'),
signature_origin.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
signature_sha_base64 = base64.b64encode(signature_sha).decode(encoding='utf-8')
# 构造authorization
authorization_origin = f'api_key="{api_key}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha_base64}"'
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
# 构造最终的WebSocket URL
v = {
"authorization": authorization,
"date": date,
"host": host
}
url = api_url + '?' + urlencode(v)
return url
class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file)
# 设置为流式接口类型
self.interface_type = InterfaceType.DUAL_STREAM
# 基础配置
self.app_id = config.get("app_id")
self.api_key = config.get("api_key")
self.api_secret = config.get("api_secret")
# 接口地址
self.api_url = config.get("api_url", "wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6")
# 音色配置
self.voice = config.get("voice", "x5_lingxiaoxuan_flow")
if config.get("private_voice"):
self.voice = config.get("private_voice")
# 音频参数配置
speed = config.get("speed", "50")
self.speed = int(speed) if speed else 50
volume = config.get("volume", "50")
self.volume = int(volume) if volume else 50
pitch = config.get("pitch", "50")
self.pitch = int(pitch) if pitch else 50
# 音频编码配置
self.format = config.get("format", "raw")
sample_rate = config.get("sample_rate", "24000")
self.sample_rate = int(sample_rate) if sample_rate else 24000
# 口语化配置
self.oral_level = config.get("oral_level", "mid")
spark_assist = config.get("spark_assist", "1")
self.spark_assist = int(spark_assist) if spark_assist else 1
stop_split = config.get("stop_split", "0")
self.stop_split = int(stop_split) if stop_split else 0
remain = config.get("remain", "0")
self.remain = int(remain) if remain else 0
# WebSocket配置
self.ws = None
self._monitor_task = None
# 序列号管理
self.text_seq = 0
# 创建Opus编码器
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=self.sample_rate, channels=1, frame_size_ms=60
)
# 验证必需参数
if not all([self.app_id, self.api_key, self.api_secret]):
raise ValueError("讯飞TTS需要配置app_id、api_key和api_secret")
async def _ensure_connection(self):
"""确保WebSocket连接可用"""
try:
logger.bind(tag=TAG).info("开始建立新连接...")
# 生成认证URL
auth_url = XunfeiWSAuth.create_auth_url(
self.api_key, self.api_secret, self.api_url
)
self.ws = await websockets.connect(
auth_url,
ping_interval=30,
ping_timeout=10,
close_timeout=10,
)
logger.bind(tag=TAG).info("WebSocket连接建立成功")
return self.ws
except Exception as e:
logger.bind(tag=TAG).error(f"建立连接失败: {str(e)}")
self.ws = None
raise
def tts_text_priority_thread(self):
"""流式文本处理线程"""
while not self.conn.stop_event.is_set():
try:
message = self.tts_text_queue.get(timeout=1)
logger.bind(tag=TAG).debug(
f"收到TTS任务|{message.sentence_type.name} {message.content_type.name} | 会话ID: {self.conn.sentence_id}"
)
if message.sentence_type == SentenceType.FIRST:
# 重置序列号
self.text_seq = 0
self.conn.client_abort = False
# 增加序列号
self.text_seq += 1
if self.conn.client_abort:
logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程")
continue
if message.sentence_type == SentenceType.FIRST:
# 初始化参数
try:
if not getattr(self.conn, "sentence_id", None):
self.conn.sentence_id = uuid.uuid4().hex
logger.bind(tag=TAG).info(f"自动生成新的 会话ID: {self.conn.sentence_id}")
logger.bind(tag=TAG).info("开始启动TTS会话...")
future = asyncio.run_coroutine_threadsafe(
self.start_session(self.conn.sentence_id),
loop=self.conn.loop,
)
future.result()
self.before_stop_play_files.clear()
logger.bind(tag=TAG).info("TTS会话启动成功")
except Exception as e:
logger.bind(tag=TAG).error(f"启动TTS会话失败: {str(e)}")
continue
# 处理文本内容
if ContentType.TEXT == message.content_type:
if message.content_detail:
try:
logger.bind(tag=TAG).debug(
f"开始发送TTS文本: {message.content_detail}"
)
future = asyncio.run_coroutine_threadsafe(
self.text_to_speak(message.content_detail, None),
loop=self.conn.loop,
)
future.result()
logger.bind(tag=TAG).debug("TTS文本发送成功")
except Exception as e:
logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}")
# 不使用continue,确保后续处理不被中断
# 处理文件内容
if ContentType.FILE == message.content_type:
logger.bind(tag=TAG).info(
f"添加音频文件到待播放列表: {message.content_file}"
)
if message.content_file and os.path.exists(message.content_file):
# 先处理文件音频数据
self._process_audio_file_stream(message.content_file, callback=lambda audio_data: self.handle_audio_file(audio_data, message.content_detail))
# 处理会话结束
if message.sentence_type == SentenceType.LAST:
try:
logger.bind(tag=TAG).info("开始结束TTS会话...")
asyncio.run_coroutine_threadsafe(
self.finish_session(self.conn.sentence_id),
loop=self.conn.loop,
)
except Exception as e:
logger.bind(tag=TAG).error(f"结束TTS会话失败: {str(e)}")
continue
except queue.Empty:
continue
except Exception as e:
logger.bind(tag=TAG).error(
f"处理TTS文本失败: {str(e)}, 类型: {type(e).__name__}, 堆栈: {traceback.format_exc()}"
)
async def text_to_speak(self, text, _):
"""发送文本到TTS服务进行合成"""
try:
if self.ws is None:
logger.bind(tag=TAG).warning(f"WebSocket连接不存在,终止发送文本")
return
filtered_text = MarkdownCleaner.clean_markdown(text)
# 发送文本合成请求
run_request = self._build_base_request(status=1,text=filtered_text)
await self.ws.send(json.dumps(run_request))
return
except Exception as e:
logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}")
if self.ws:
try:
await self.ws.close()
except:
pass
self.ws = None
raise
async def start_session(self, session_id):
logger.bind(tag=TAG).info(f"开始会话~~{session_id}")
try:
# 会话开始时检测上个会话的监听状态
if (
self._monitor_task is not None
and isinstance(self._monitor_task, Task)
and not self._monitor_task.done()
):
logger.bind(tag=TAG).info(
"检测到未完成的上个会话,关闭监听任务和连接..."
)
await self.close()
# 建立新连接
await self._ensure_connection()
# 启动监听任务
self._monitor_task = asyncio.create_task(self._start_monitor_tts_response())
# 发送会话启动请求
start_request = self._build_base_request(status=0)
await self.ws.send(json.dumps(start_request))
logger.bind(tag=TAG).info("会话启动请求已发送")
except Exception as e:
logger.bind(tag=TAG).error(f"启动会话失败: {str(e)}")
# 确保清理资源
await self.close()
raise
async def finish_session(self, session_id):
logger.bind(tag=TAG).info(f"关闭会话~~{session_id}")
try:
if self.ws:
# 发送会话结束请求
stop_request = self._build_base_request(status=2)
await self.ws.send(json.dumps(stop_request))
logger.bind(tag=TAG).info("会话结束请求已发送")
if self._monitor_task:
try:
await self._monitor_task
except Exception as e:
logger.bind(tag=TAG).error(f"等待监听任务完成时发生错误: {str(e)}")
finally:
self._monitor_task = None
except Exception as e:
logger.bind(tag=TAG).error(f"关闭会话失败: {str(e)}")
await self.close()
raise
async def close(self):
"""资源清理"""
if self._monitor_task:
try:
self._monitor_task.cancel()
await self._monitor_task
except asyncio.CancelledError:
pass
except Exception as e:
logger.bind(tag=TAG).warning(f"关闭时取消监听任务错误: {e}")
self._monitor_task = None
if self.ws:
try:
await self.ws.close()
except:
pass
self.ws = None
async def _start_monitor_tts_response(self):
"""监听TTS响应"""
try:
while not self.conn.stop_event.is_set():
try:
msg = await self.ws.recv()
# 检查客户端是否中止
if self.conn.client_abort:
logger.bind(tag=TAG).info("收到打断信息,终止监听TTS响应")
break
try:
data = json.loads(msg)
header = data.get("header", {})
code = header.get("code")
if code == 0:
payload = data.get("payload", {})
audio_payload = payload.get("audio", {})
if audio_payload:
status = audio_payload.get("status", 0)
audio_data = audio_payload.get("audio", "")
if status == 0:
logger.bind(tag=TAG).debug("TTS合成已启动")
self.tts_audio_queue.put(
(SentenceType.FIRST, [], None)
)
elif status == 2:
logger.bind(tag=TAG).debug("收到结束状态的音频数据,TTS合成完成")
self._process_before_stop_play_files()
break
else:
if self.conn.tts_MessageText:
logger.bind(tag=TAG).info(
f"句子语音生成成功: {self.conn.tts_MessageText}"
)
self.tts_audio_queue.put(
(SentenceType.FIRST, [], self.conn.tts_MessageText)
)
self.conn.tts_MessageText = None
try:
audio_bytes = base64.b64decode(audio_data)
self.opus_encoder.encode_pcm_to_opus_stream(
audio_bytes, False, self.handle_opus
)
except Exception as e:
logger.bind(tag=TAG).error(f"处理音频数据失败: {e}")
else:
message = header.get("message", "未知错误")
logger.bind(tag=TAG).error(f"TTS合成错误: {code} - {message}")
break
except json.JSONDecodeError:
logger.bind(tag=TAG).warning("收到无效的JSON消息")
except websockets.ConnectionClosed:
logger.bind(tag=TAG).warning("WebSocket连接已关闭")
break
except Exception as e:
logger.bind(tag=TAG).error(
f"处理TTS响应时出错: {e}\n{traceback.format_exc()}"
)
break
# 链接不可复用
if self.ws:
try:
await self.ws.close()
except:
pass
self.ws = None
# 监听任务退出时清理引用
finally:
self._monitor_task = None
def to_tts(self, text: str) -> list:
"""非流式TTS处理,用于测试及保存音频文件的场景"""
try:
# 创建新的事件循环
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# 存储音频数据
audio_data = []
async def _generate_audio():
# 生成认证URL
auth_url = XunfeiWSAuth.create_auth_url(
self.api_key, self.api_secret, self.api_url
)
# 建立WebSocket连接
ws = await websockets.connect(
auth_url,
ping_interval=30,
ping_timeout=10,
close_timeout=10,
)
try:
filtered_text = MarkdownCleaner.clean_markdown(text)
text_request = self._build_base_request(status=2,text=filtered_text)
await ws.send(json.dumps(text_request))
task_finished = False
while not task_finished:
msg = await ws.recv()
data = json.loads(msg)
header = data.get("header", {})
code = header.get("code")
if code == 0:
payload = data.get("payload", {})
audio_payload = payload.get("audio", {})
if audio_payload:
status = audio_payload.get("status", 0)
audio_base64 = audio_payload.get("audio", "")
if status == 1:
try:
audio_bytes = base64.b64decode(audio_base64)
self.opus_encoder.encode_pcm_to_opus_stream(
audio_bytes,
end_of_stream=False,
callback=lambda opus: audio_data.append(opus)
)
except Exception as e:
logger.bind(tag=TAG).error(f"处理音频数据失败: {e}")
elif status == 2:
task_finished = True
logger.bind(tag=TAG).debug("TTS任务完成")
else:
message = header.get("message", "未知错误")
raise Exception(f"合成失败: {code} - {message}")
finally:
# 清理资源
try:
await ws.close()
except:
pass
loop.run_until_complete(_generate_audio())
loop.close()
return audio_data
except Exception as e:
logger.bind(tag=TAG).error(f"生成音频数据失败: {str(e)}")
return []
def _build_base_request(self, status,text=" "):
"""构建基础请求结构"""
return {
"header": {
"app_id": self.app_id,
"status": status,
},
"parameter": {
"oral": {
"oral_level": self.oral_level,
"spark_assist": self.spark_assist,
"stop_split": self.stop_split,
"remain": self.remain
},
"tts": {
"vcn": self.voice,
"speed": self.speed,
"volume": self.volume,
"pitch": self.pitch,
"bgs": 0,
"reg": 0,
"rdn": 0,
"rhy": 0,
"audio": {
"encoding": self.format,
"sample_rate": self.sample_rate,
"channels": 1,
"bit_depth": 16,
"frame_size": 0
}
}
},
"payload": {
"text": {
"encoding": "utf8",
"compress": "raw",
"format": "plain",
"status": status,
"seq": self.text_seq,
"text": base64.b64encode(text.encode('utf-8')).decode('utf-8')
}
}
}
@@ -184,7 +184,7 @@ class PromptManager:
self.logger.bind(tag=TAG).error(f"更新上下文信息失败: {e}") self.logger.bind(tag=TAG).error(f"更新上下文信息失败: {e}")
def build_enhanced_prompt( def build_enhanced_prompt(
self, user_prompt: str, device_id: str, client_ip: str = None self, user_prompt: str, device_id: str, client_ip: str = None, *args, **kwargs
) -> str: ) -> str:
"""构建增强的系统提示词""" """构建增强的系统提示词"""
if not self.base_prompt_template: if not self.base_prompt_template:
@@ -225,6 +225,7 @@ class PromptManager:
weather_info=weather_info, weather_info=weather_info,
emojiList=EMOJI_List, emojiList=EMOJI_List,
device_id=device_id, device_id=device_id,
*args, **kwargs
) )
device_cache_key = f"device_prompt:{device_id}" device_cache_key = f"device_prompt:{device_id}"
self.cache_manager.set( self.cache_manager.set(
+26
View File
@@ -6,6 +6,27 @@ import importlib
logger = setup_logging() logger = setup_logging()
punctuation_set = {
"",
",", # 中文逗号 + 英文逗号
"",
".", # 中文句号 + 英文句号
"",
"!", # 中文感叹号 + 英文感叹号
"",
"",
'"', # 中文双引号 + 英文引号
"",
":", # 中文冒号 + 英文冒号
"-",
"", # 英文连字符 + 中文全角横线
"", # 中文顿号
"[",
"]", # 方括号
"",
"", # 中文方括号
"~", # 波浪号
}
def create_instance(class_name, *args, **kwargs): def create_instance(class_name, *args, **kwargs):
# 创建TTS实例 # 创建TTS实例
@@ -107,6 +128,11 @@ class MarkdownCleaner:
""" """
主入口方法依序执行所有正则移除或替换 Markdown 元素 主入口方法依序执行所有正则移除或替换 Markdown 元素
""" """
# 检查文本是否全为英文和基本标点符号
if text and all((c.isascii() or c.isspace() or c in punctuation_set) for c in text):
# 保留原始空格,直接返回
return text
for regex, replacement in MarkdownCleaner.REGEXES: for regex, replacement in MarkdownCleaner.REGEXES:
text = regex.sub(replacement, text) text = regex.sub(replacement, text)
return text.strip() return text.strip()
@@ -11,6 +11,12 @@ from tabulate import tabulate
from config.settings import load_config from config.settings import load_config
import tempfile import tempfile
import wave import wave
import hmac
import base64
import hashlib
from datetime import datetime
from wsgiref.handlers import format_date_time
from time import mktime
description = "流式ASR首词延迟测试" description = "流式ASR首词延迟测试"
try: try:
import dashscope import dashscope
@@ -259,6 +265,137 @@ class QwenASRFlashTester(BaseASRTester):
return self._calculate_result("通义千问ASR", latencies, test_count) return self._calculate_result("通义千问ASR", latencies, test_count)
class XunfeiStreamASRTester(BaseASRTester):
def __init__(self):
super().__init__("XunfeiStreamASR")
def _create_url(self):
"""生成讯飞ASR认证URL"""
url = 'ws://iat.cn-huabei-1.xf-yun.com/v1'
# 生成RFC1123格式的时间戳
now = datetime.now()
date = format_date_time(mktime(now.timetuple()))
# 拼接字符串
signature_origin = "host: " + "iat.cn-huabei-1.xf-yun.com" + "\n"
signature_origin += "date: " + date + "\n"
signature_origin += "GET " + "/v1 " + "HTTP/1.1"
# 进行hmac-sha256进行加密
signature_sha = hmac.new(self.asr_config["api_secret"].encode('utf-8'), signature_origin.encode('utf-8'),
digestmod=hashlib.sha256).digest()
signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8')
authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % (
self.asr_config["api_key"], "hmac-sha256", "host date request-line", signature_sha)
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
# 将请求的鉴权参数组合为字典
v = {
"authorization": authorization,
"date": date,
"host": "iat.cn-huabei-1.xf-yun.com"
}
# 拼接鉴权参数,生成url
url = url + '?' + parse.urlencode(v)
return url
async def test(self, test_count=5):
if not self.test_audio_files:
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未找到测试音频"}
if not self.asr_config:
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未配置"}
# 检查必要的配置参数
required_keys = ["app_id", "api_key", "api_secret"]
for key in required_keys:
if key not in self.asr_config:
return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置项 {key}"}
latencies = []
STATUS_FIRST_FRAME = 0
for i in range(test_count):
try:
# 生成认证URL
ws_url = self._create_url()
# 获取音频数据
audio_data = self.test_audio_files[0]['data']
if audio_data.startswith(b'RIFF'):
audio_data = audio_data[44:] # 跳过WAV文件头
# 识别参数
iat_params = {
"domain": self.asr_config.get("domain", "slm"),
"language": self.asr_config.get("language", "zh_cn"),
"accent": self.asr_config.get("accent", "mandarin"),
"dwa": self.asr_config.get("dwa", "wpgs"),
"result": {
"encoding": "utf8",
"compress": "raw",
"format": "plain"
}
}
# 准备首帧数据
first_frame_data = {
"header": {
"status": STATUS_FIRST_FRAME,
"app_id": self.asr_config["app_id"]
},
"parameter": {
"iat": iat_params
},
"payload": {
"audio": {
"audio": base64.b64encode(audio_data[:960]).decode('utf-8'),
"sample_rate": 16000,
"encoding": "raw"
}
}
}
# 启动连接并测量时间
start_time = time.time()
async with websockets.connect(
ws_url,
max_size=1000000000,
ping_interval=None,
ping_timeout=None,
close_timeout=30,
) as ws:
# 发送首帧数据
await ws.send(json.dumps(first_frame_data, ensure_ascii=False))
print(f"[讯飞ASR] 第{i+1}次测试:已发送首帧,等待响应...")
# 直接等待第一个响应并计算延迟
# 参考豆包和通义千问的实现方式,简化逻辑
response_received = False
while not response_received:
try:
# 设置较大的超时时间
response = await asyncio.wait_for(ws.recv(), timeout=30.0)
# 收到响应立即计算延迟,不管内容是什么
# 这样可以准确测量首包到达时间
latency = time.time() - start_time
latencies.append(latency)
response_received = True
print(f"[讯飞ASR] 第{i+1}次测试:收到首包响应,延迟: {latency:.3f}s")
break
except asyncio.TimeoutError:
print(f"[讯飞ASR] 第{i+1}次测试:响应超时")
raise Exception("获取响应超时")
except Exception as e:
print(f"[讯飞ASR] 第{i+1}次测试失败: {str(e)}")
latencies.append(0)
return self._calculate_result("讯飞流式ASR", latencies, test_count)
class ASRPerformanceSuite: class ASRPerformanceSuite:
def __init__(self): def __init__(self):
self.testers = [] self.testers = []
@@ -272,7 +409,8 @@ class ASRPerformanceSuite:
except Exception as e: except Exception as e:
name_map = { name_map = {
"DoubaoStreamASRTester": "豆包流式ASR", "DoubaoStreamASRTester": "豆包流式ASR",
"QwenASRFlashTester": "通义千问ASR" "QwenASRFlashTester": "通义千问ASR",
"XunfeiStreamASRTester": "讯飞流式ASR"
} }
name = name_map.get(tester_class.__name__, tester_class.__name__) name = name_map.get(tester_class.__name__, tester_class.__name__)
print(f"跳过 {name}: {str(e)}") print(f"跳过 {name}: {str(e)}")
@@ -326,6 +464,7 @@ async def main():
suite = ASRPerformanceSuite() suite = ASRPerformanceSuite()
suite.register_tester(DoubaoStreamASRTester) suite.register_tester(DoubaoStreamASRTester)
suite.register_tester(QwenASRFlashTester) suite.register_tester(QwenASRFlashTester)
suite.register_tester(XunfeiStreamASRTester)
await suite.run(args.count) await suite.run(args.count)
@@ -4,6 +4,11 @@ import json
import uuid import uuid
import aiohttp import aiohttp
import websockets import websockets
import hmac
import base64
import hashlib
import asyncio
from urllib.parse import urlparse, urlencode
from tabulate import tabulate from tabulate import tabulate
from config.settings import load_config from config.settings import load_config
@@ -285,6 +290,144 @@ class StreamTTSPerformanceTester:
latencies.append(0) latencies.append(0)
return self._calculate_result("LinkeraiTTS", latencies, test_count) return self._calculate_result("LinkeraiTTS", latencies, test_count)
async def test_xunfei_tts(self, text=None, test_count=5):
"""测试讯飞流式TTS首词延迟(测试多次取平均)"""
text = text or self.test_texts[0]
latencies = []
for i in range(test_count):
try:
# 修正配置节点名称,与配置文件中的XunFeiTTS匹配
tts_config = self.config["TTS"]["XunFeiTTS"]
app_id = tts_config["app_id"]
api_key = tts_config["api_key"]
api_secret = tts_config["api_secret"]
api_url = tts_config.get("api_url", "wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6")
voice = tts_config.get("voice", "x5_lingxiaoxuan_flow")
# 生成认证URL
auth_url = self._create_xunfei_auth_url(api_key, api_secret, api_url)
async with websockets.connect(
auth_url,
ping_interval=30,
ping_timeout=10,
close_timeout=10,
max_size=1000000000
) as ws:
# 构造请求
request = self._build_xunfei_request(app_id, text, voice)
# 发送请求后立即计时,确保准确测量从发送文本到接收首块的时间
await ws.send(json.dumps(request))
start_time = time.time()
# 等待第一个音频数据块
first_audio_received = False
while not first_audio_received:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
header = data.get("header", {})
code = header.get("code")
if code != 0:
message = header.get("message", "未知错误")
raise Exception(f"合成失败: {code} - {message}")
payload = data.get("payload", {})
audio_payload = payload.get("audio", {})
if audio_payload:
status = audio_payload.get("status", 0)
audio_data = audio_payload.get("audio", "")
if status == 1 and audio_data:
# 收到第一个音频数据块
latency = time.time() - start_time
latencies.append(latency)
first_audio_received = True
break
except Exception as e:
latencies.append(0)
return self._calculate_result("讯飞TTS", latencies, test_count)
def _create_xunfei_auth_url(self, api_key, api_secret, api_url):
"""生成讯飞WebSocket认证URL"""
parsed_url = urlparse(api_url)
host = parsed_url.netloc
path = parsed_url.path
# 获取UTC时间,讯飞要求使用RFC1123格式
now = time.gmtime()
date = time.strftime('%a, %d %b %Y %H:%M:%S GMT', now)
# 构造签名字符串
signature_origin = f"host: {host}\ndate: {date}\nGET {path} HTTP/1.1"
# 计算签名
signature_sha = hmac.new(
api_secret.encode('utf-8'),
signature_origin.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
signature_sha_base64 = base64.b64encode(signature_sha).decode(encoding='utf-8')
# 构造authorization
authorization_origin = f'api_key="{api_key}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha_base64}"'
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
# 构造最终的WebSocket URL
v = {
"authorization": authorization,
"date": date,
"host": host
}
url = api_url + '?' + urlencode(v)
return url
def _build_xunfei_request(self, app_id, text, voice):
"""构建讯飞TTS请求结构"""
return {
"header": {
"app_id": app_id,
"status": 2,
},
"parameter": {
"oral": {
"oral_level": "mid",
"spark_assist": 1,
"stop_split": 0,
"remain": 0
},
"tts": {
"vcn": voice,
"speed": 50,
"volume": 50,
"pitch": 50,
"bgs": 0,
"reg": 0,
"rdn": 0,
"rhy": 0,
"audio": {
"encoding": "raw",
"sample_rate": 24000,
"channels": 1,
"bit_depth": 16,
"frame_size": 0
}
}
},
"payload": {
"text": {
"encoding": "utf8",
"compress": "raw",
"format": "plain",
"status": 2,
"seq": 1,
"text": base64.b64encode(text.encode('utf-8')).decode('utf-8')
}
}
}
def _calculate_result(self, service_name, latencies, test_count): def _calculate_result(self, service_name, latencies, test_count):
@@ -368,6 +511,11 @@ class StreamTTSPerformanceTester:
result = await self.test_indexstream_tts(test_text, test_count) result = await self.test_indexstream_tts(test_text, test_count)
self.results.append(result) self.results.append(result)
# 测试讯飞TTS
if self.config.get("TTS", {}).get("XunFeiTTS"):
result = await self.test_xunfei_tts(test_text, test_count)
self.results.append(result)
# 打印结果 # 打印结果
self._print_results(test_text, test_count) self._print_results(test_text, test_count)