Compare commits

...
119 Commits
Author SHA1 Message Date
欣南科技andGitHub d934e0a69d Merge pull request #1348 from xinnan-tech/custom_tts_api
Custom tts api
2025-05-22 11:26:26 +08:00
hrz 2ecce6485c update:增加自定义的TTS接口服务的使用说明 2025-05-22 11:26:00 +08:00
hrz 1ad8aeeaa7 add:CustomTTS提供一个示例 2025-05-22 11:22:16 +08:00
hrz 46a15082d6 update:优化按钮 2025-05-22 10:41:56 +08:00
hrzandGitHub 153ecf0b41 Merge pull request #1253 from hsoftxl/custom_tts_api
自定义tts接口增加POST请求支持
2025-05-22 09:58:47 +08:00
hrzandGitHub f0f7304ae2 Merge branch 'custom_tts_api' into custom_tts_api 2025-05-22 09:58:38 +08:00
欣南科技andGitHub 997e171118 Merge pull request #1327 from xinnan-tech/web-pages-modifiy
增加fastgpt字段处理,搜索图标下移
2025-05-22 09:44:11 +08:00
欣南科技andGitHub 04a8fe8af2 Merge pull request #1342 from xinnan-tech/fix-global-iot
fix:修复全局变量all_function_registry导致iot描述无法更新bug
2025-05-22 09:43:13 +08:00
hrzandGitHub a0822a356c Merge pull request #1345 from CaixyPromise/chore/server-manager
chore: 服务端管理接口代码健壮性调整,补充注释与删除署名注释。
2025-05-22 09:01:28 +08:00
caixypromise b78d337fd0 chore: 服务端管理接口代码健壮性优化,补充注释与删除署名配置。 2025-05-22 00:49:56 +08:00
hrzandGitHub 13c5cb3f1d Merge pull request #1340 from xinnan-tech/py_symbol_fix
解决符号误识别问题,添加与退出相关的提示词
2025-05-21 22:12:52 +08:00
hrzandGitHub 197c0532fa Merge pull request #1341 from xinnan-tech/py_Recognizing-Intent_fix
update:修复intent_llm识别意图不精准问题,handle_device改名
2025-05-21 22:09:56 +08:00
hrz 76342b09ff fix:修复全局变量all_function_registry导致iot描述无法更新bug 2025-05-21 18:35:34 +08:00
CGD 241d96f6ac update:修复intent_llm识别意图不精准问题,handle_device改名 2025-05-21 18:17:08 +08:00
欣南科技andGitHub 0dc267c109 Merge pull request #1337 from xinnan-tech/connect-server-and-api
智控台控制xiaozhi-server重启和更新配置
2025-05-21 11:01:43 +08:00
hrz bc6c85c6c6 update:增加ws连接时长,避免ws时间过短到只conn对象销毁丢失无法使用 2025-05-21 10:43:48 +08:00
Sakura-RanChen 915f4cac5e 对通用dict类型的字段进行处理 2025-05-21 09:55:15 +08:00
hrz b8da0714c2 update:优化无用组件 2025-05-20 22:58:42 +08:00
hrzandGitHub f1105e6822 Merge pull request #1331 from CaixyPromise/feature/connect-server-and-api
feat: 新增智控台管理websocket服务器端【配置拉取】和【重启】
2025-05-20 22:09:29 +08:00
hrzandGitHub 20296148eb Merge pull request #1332 from CaixyPromise/fix/dockerfile-arm
fix: 修复docker镜像不兼容arm64架构。
2025-05-20 22:01:52 +08:00
caixypromise 2138c0fb4f fix: 修复docker镜像不兼容arm开发板架构。
更新第三阶段运行状态的运行jdk镜像,切换包管理器为apk。
2025-05-20 20:13:41 +08:00
caixypromise 34d9857089 feat: 新增智控台管理websocket服务器端【配置拉取】和【重启】 2025-05-20 20:02:16 +08:00
欣南科技andGitHub 888b71a196 Merge pull request #1330 from xinnan-tech/hot-fix
update:优化ai_model_provider的fields字段
2025-05-20 18:05:10 +08:00
hrz 5f16c871dc update:优化ai_model_provider的fields字段 2025-05-20 18:04:33 +08:00
Sakura-RanChen 595a338de7 增加fastgpt字段处理,搜索图标下移 2025-05-20 16:57:48 +08:00
欣南科技andGitHub fe47fd1910 Merge pull request #1323 from xinnan-tech/hot-fix
fix:参数名称变更
2025-05-20 11:31:28 +08:00
hrz ac9abf8ede fix:参数名称变更 2025-05-20 11:30:57 +08:00
欣南科技andGitHub 282706dbc8 Merge pull request #1320 from xinnan-tech/manager-icp-num
可自定义智控台底部的系统名称、ICP备案号、公安备案号
2025-05-20 11:15:38 +08:00
hrz 5360cbf19c update:优化asr测速源:从已有的几个文件中测速度 2025-05-20 11:14:20 +08:00
hrz 84ac1b6686 update:可自定义智控台底部系统名称 2025-05-20 10:59:22 +08:00
CGD ebc16207f4 update:intent_llm添加与退出相关的提示词 2025-05-20 10:54:48 +08:00
欣南科技andGitHub 7611e98156 Merge pull request #1312 from xinnan-tech/manager-api-aly-message
修复短信并发漏洞
2025-05-19 21:23:49 +08:00
hrz 848f73715f update:完成密码找回功能 2025-05-19 21:19:24 +08:00
hrzandGitHub 6f30dfb963 Merge pull request #1315 from qjh9527/add-asr-performance
fix: stt中 test_count 计算逻辑错误
2025-05-19 20:32:03 +08:00
QJH c8aa903e3e fix: stt中 test_count 计算逻辑错误 2025-05-19 18:43:34 +08:00
剑雨 f0e55037de 剔除已经删除的类的引用和使用 2025-05-19 18:33:02 +08:00
hrzandGitHub 1335b368d1 Merge pull request #1314 from xinnan-tech/web-forget-password
增加找回密码的功能
2025-05-19 17:57:55 +08:00
CGD a647f3c105 fix:解决符号误识别问题 2025-05-19 15:18:01 +08:00
剑雨 c936372105 优化发送短信失败的情况,回退发送次数
--ALiYunSmsService.java 使用redis加1原子操作
--CaptchaServiceImpl.java 使用redis减1原子操作
2025-05-19 14:41:53 +08:00
Sakura-RanChen f242d43f73 增加找回密码的功能 2025-05-19 14:15:28 +08:00
hrzandGitHub c216012304 Merge pull request #1296 from qjh9527/add-asr-performance
Add ASR performance
2025-05-19 14:04:45 +08:00
hrzandGitHub 87c2f0e38e Merge pull request #1304 from 2331892928/main
ICP备案号添加
2025-05-19 13:51:15 +08:00
欣南科技andGitHub d9cc75edc0 Merge pull request #1307 from xinnan-tech/ali-msm-doc
增加阿里云短信集成使用说明
2025-05-19 11:44:27 +08:00
hrz 1f91ec32da update:优化说明 2025-05-19 11:43:28 +08:00
hrz 92b60b7f25 update:优化阿里云短信管理文档 2025-05-19 11:36:59 +08:00
hrz 8104d02a3a add:阿里云短信集群指南 2025-05-19 11:31:53 +08:00
剑雨 83c4add375 优化清空redis的key方法。添加加1,减1方法
--RedisUtils.java 新的方法
--emptyAll.lua 清空redis的key的lua脚本
2025-05-19 11:20:57 +08:00
剑雨 c19084b7b4 修复并发漏洞,获取和设置短信发送间隔时间,不是原子性的,并发请求过来会导致验证有漏洞被通过
--CaptchaServiceImpl.java 设置获取和设置发现间隔的方法为原子性
2025-05-19 10:51:39 +08:00
剑雨 2761e97c6a 新增获取在redis指定key的值,如果值为空,着设置key的默认值的方法
--RedisUtils.java 添加新方法
--getKeyOrCreate.lua 此方法的lua脚本
2025-05-19 10:46:59 +08:00
剑雨 0cdfd1d114 新建资源读取工具类
--ResourcesUtils.java 读取资源获取到字符串的方法
2025-05-19 10:41:02 +08:00
AMEN 8566e048b6 Merge remote-tracking branch 'origin/main' 2025-05-18 23:34:53 +08:00
AMEN 31fc731af6 前端
修改参数查询的input提示
增加公安备案号和网站备案号显示
2025-05-18 23:33:28 +08:00
AMEN ff95764824 后端
参数管理新增参数:
server.beian_icp_num
server.beian_ga_num
修改:
参数查询支持备注查询
2025-05-18 23:00:01 +08:00
欣南科技andGitHub 737c4a6b5d Merge pull request #1303 from xinnan-tech/hot-fix
修复:测试用例参数错误
2025-05-18 22:25:46 +08:00
hrz db68871646 修复:测试用例参数错误 2025-05-18 22:25:09 +08:00
剑雨 748f17b777 开启手机注册,验证相关参数不可以为空
--SysParamsServiceImpl.java 开启手机注册参数添加验证
2025-05-18 18:44:11 +08:00
欣南科技andGitHub bbba24c7bc Merge pull request #1301 from xinnan-tech/hot-fix
fix:登录失败错误信息显示
2025-05-18 17:59:37 +08:00
hrz 5bba864faa fix:登录失败错误信息显示 2025-05-18 17:59:08 +08:00
欣南科技andGitHub 33b7fe6072 Merge pull request #1300 from xinnan-tech/hot-fix
update:补充缺失参数
2025-05-18 17:55:53 +08:00
hrz 4d0bef62ed update:补充缺失参数 2025-05-18 17:55:18 +08:00
欣南科技andGitHub e09ba81cfa Merge pull request #1290 from xinnan-tech/manager-api-aly-message
阿里云短信发送功能,手机注册和找回密码接口
2025-05-18 17:42:30 +08:00
hrzandGitHub baf15450a3 Merge branch 'main' into manager-api-aly-message 2025-05-18 17:42:09 +08:00
hrz d24419cfb7 update:发送手机验证码 2025-05-18 17:39:12 +08:00
hrz fe88db2094 add:登录页面和注册页面增加手机号码选项 2025-05-18 17:38:20 +08:00
hrz 91d2ab7d1d update:优化 2025-05-18 11:09:58 +08:00
QJH 9a405a756f fix: "ASR", "LLM", "TTS"为空时的情况 2025-05-18 05:56:16 +08:00
QJH 86f5be23fe add: ASR测试任务 2025-05-18 05:48:31 +08:00
欣南科技andGitHub 9f4ca1aa6b Merge pull request #1294 from xinnan-tech/model-provider-service
Model provider service
2025-05-17 23:56:47 +08:00
hrz 624b1cce74 update:优化已存在的Group注解 2025-05-17 23:54:25 +08:00
hrz 29216768e1 update:优化模型供应器查询 2025-05-17 23:18:19 +08:00
hrz 59e18dc1c1 Merge branch 'main' into model-provider-service 2025-05-17 22:41:25 +08:00
欣南科技andGitHub 858a85f952 Merge pull request #1293 from xinnan-tech/hit-fix
update:修复fishspeech seed参数类型转换
2025-05-17 17:08:01 +08:00
hrz 1cbd68f1e3 update:修复fishspeech seed参数类型转换 2025-05-17 17:07:19 +08:00
欣南科技andGitHub 324bf62bb8 Merge pull request #1291 from xinnan-tech/hot-fix
update:恢复旧文件,解决已部署用户sql出错问题
2025-05-17 16:00:35 +08:00
hrz dd1d7d5dc1 update:回复旧文件,解决已部署用户sql出错问题 2025-05-17 15:58:53 +08:00
hrzandGitHub e6ad9e23cc Merge pull request #1288 from ljwwd2/main
供应器管理
2025-05-17 15:47:42 +08:00
hrzandGitHub a557322dc6 Merge pull request #1277 from 2331892928/main
用户管理页面的搜索功能增加模糊搜索
2025-05-17 15:36:20 +08:00
剑雨 7d7324ddbc 测试短信发送
--loginControllerTest.java 短信测试的测试类
2025-05-17 11:00:02 +08:00
剑雨 680ed4596f 测试,修复业务流程bug,添加新的错误代码和说明
--ErrorCode.java 新的错误代码
--LoginController.java 修复业务流程bug
--messages.properties 添加新的业务说明
2025-05-17 10:59:00 +08:00
剑雨 e66ab647e1 优化短信发送的异常处理
--ALiYunSmsService.java 优化异常处理
2025-05-17 10:57:02 +08:00
ljwwd2andGitHub 253f4c59a3 Merge branch 'xinnan-tech:main' into main 2025-05-17 01:19:12 +08:00
ljwwd2 38a97d6b5a 供应器管理-前端页面 2025-05-17 01:18:19 +08:00
ljwwd2 ae8f11d5a8 供应器管理-增删改查api 2025-05-17 01:18:03 +08:00
CGDandGitHub dcfd82f84e Merge pull request #1283 from xinnan-tech/py_text_audio
样式修改
2025-05-16 17:48:00 +08:00
TOM88812andGitHub 25a531341a Merge pull request #1281 from jwhna1/main
fix:修复智控台fish-speech无法按声音播放bug
2025-05-16 17:23:22 +08:00
曾能混andGitHub f421f6dee8 Merge branch 'xinnan-tech:main' into main 2025-05-16 17:16:44 +08:00
曾能混andGitHub a5332ccc7d Update 202504112044.sql
fix:修复智控台fish-speech无法按声音播放bug
2025-05-16 17:10:55 +08:00
Sakura-RanChen 76c3f401ea 样式修改 2025-05-16 14:55:53 +08:00
Sakura-RanChenandGitHub 5173dded2a Merge pull request #1280 from xinnan-tech/py_test_fix
update:在识别到continue_chat意图时,清理对话历史中的工具调用相关消息
2025-05-16 14:47:04 +08:00
CGD bdd8597f69 update:在识别到continue_chat意图时,清理对话历史中的工具调用相关消息 2025-05-16 14:33:37 +08:00
lvjinfa 46bba64a94 用户管理页面的搜索功能增加模糊搜索 2025-05-16 12:28:32 +08:00
XL d0694a0e70 兼容yaml配置和管理端配置 2025-05-16 11:56:54 +08:00
hsoftxlandGitHub bd12b0e81f Merge branch 'xinnan-tech:main' into custom_tts_api 2025-05-16 10:51:49 +08:00
TOM88812andGitHub 2f4fb0fc52 Merge pull request #1250 from jwhna1/main
Update ollama.py 为本地ollama部署qwen3大语言模型添加适配!
2025-05-16 10:51:17 +08:00
ljwwd2 cbabf01902 供应器管理-增删改查api 2025-05-16 03:04:52 +08:00
ljwwd2 c51c8b4af0 供应器管理-增删改查api 2025-05-16 01:42:19 +08:00
剑雨 2741e11078 新增发送短信验证码接口,找回密码接口,修改注册接口,如果开启手机注册模式,开启短信验证模式
--LoginController.java 添加和修改方法
--RetrievePasswordDTO.java 添加找回密码dto
2025-05-16 00:16:20 +08:00
剑雨 aa251aa011 添加校验单参数是否是正确的手机号的方法
--ValidatorUtils.java 添加新方法
2025-05-15 22:47:57 +08:00
剑雨 0509ddb129 验证模块添加发送短信验证码和验证短信验证码
--CaptchaService.java 新增发送短信验证码和验证短信验证码方法定义
--CaptchaServiceImpl.java 实现新定义定义的方法
--RedisKeys.java 添加短信验证码key
2025-05-15 21:45:33 +08:00
欣南科技andGitHub 5a180836b7 Merge pull request #1273 from xinnan-tech/fix-firmware-default-url
修复:固件要求firmware的url不能为空
2025-05-15 21:42:23 +08:00
hrz 9c7c6b9ea7 修复:固件要求firmware的url不能为空 2025-05-15 21:41:40 +08:00
欣南科技andGitHub d64eb0b087 Merge pull request #1268 from xinnan-tech/openrz-patch-2
docker-compose设置redis开机自启动
2025-05-15 17:41:54 +08:00
hrzandGitHub 28e4318f8a Update docker-compose_all.yml 2025-05-15 17:41:16 +08:00
欣南科技andGitHub 36f0821ea0 Merge pull request #1267 from xinnan-tech/openrz-patch-1
Update requirements.txt
2025-05-15 17:22:11 +08:00
hrzandGitHub 293a690ccd Update requirements.txt 2025-05-15 17:21:46 +08:00
欣南科技andGitHub 1c96ff7d3b Merge pull request #1266 from xinnan-tech/web-headerbar-modify
update:优化文档
2025-05-15 17:07:17 +08:00
hrz db8a2bb872 update:优化文档 2025-05-15 17:06:06 +08:00
hrz dfbe4fc7ee update:增加mcp-proxy依赖 2025-05-15 16:30:57 +08:00
欣南科技andGitHub 3cccc4ec64 Merge pull request #1265 from xinnan-tech/web-headerbar-modify
update:优化文档
2025-05-15 16:23:26 +08:00
hrz e29684e672 update:优化文档 2025-05-15 16:22:41 +08:00
XL eadb69bdc0 修改使用说明 2025-05-15 15:46:41 +08:00
XL e73223ca8a Merge remote-tracking branch 'origin/custom_tts_api' into custom_tts_api
# Conflicts:
#	main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml
2025-05-15 15:39:47 +08:00
XL 6dd66dbae9 自定义tts接口参数转dict
支持post请求
2025-05-15 15:39:06 +08:00
XL 073b69f94a 自定义tts接口参数转dict 2025-05-15 15:38:34 +08:00
XL 1a0a0777a3 自定义tts接口参数转dict
支持post请求
2025-05-15 15:35:45 +08:00
XL a971ae2168 自定义tts接口参数转dict 2025-05-15 10:23:40 +08:00
曾能混andGitHub 6b8d2502b7 Update ollama.py
为本地ollama部署qwen3大语言模型添加适配!在识别到启用为qwen3大模型的时候自动添加no_think停用推理模式,并过滤<think></think>标签转入给TTS(因为qwen3虽然用no_think停用推理后还是会输出<think></think>转入到TTS)
2025-05-15 06:32:47 +08:00
剑雨 e4194cdf4d 添加使用阿里云平台发送验证码短信的方法
--pom.xml 添加短信发送sdk
--Constant.java 添加发送短信的参数枚举
--ALiYunSmsService.java 阿里云实现短信方法
--SmsService.java 短信方法定义
2025-05-14 14:18:01 +08:00
剑雨 c8392c9b47 添加发送短信参数,添加到数据库里
--202505141129.sql
--db.changelog-master.yaml
2025-05-14 12:22:13 +08:00
105 changed files with 3966 additions and 712 deletions
+10 -7
View File
@@ -7,20 +7,23 @@ COPY main/manager-web .
RUN npm run build RUN npm run build
# 第二阶段:构建Java后端 # 第二阶段:构建Java后端
FROM maven:3-eclipse-temurin-21-alpine as api-builder FROM maven:3.9.4-eclipse-temurin-21 as api-builder
WORKDIR /app WORKDIR /app
COPY main/manager-api/pom.xml . COPY main/manager-api/pom.xml .
COPY main/manager-api/src ./src COPY main/manager-api/src ./src
RUN mvn clean package -Dmaven.test.skip=true RUN mvn clean package -Dmaven.test.skip=true
# 第三阶段:构建最终镜像 # 第三阶段:构建最终镜像
FROM eclipse-temurin:21-jdk-jammy FROM bellsoft/liberica-runtime-container:jre-21-glibc
# 安装Nginx并清理缓存 # 安装Nginx和字体库
RUN apt-get update && \ RUN apk update && \
apt-get install -y nginx && \ apk add --no-cache nginx bash && \
apt-get clean && \ apk add --no-cache fontconfig ttf-dejavu msttcorefonts-installer && \
rm -rf /var/lib/apt/lists/* rm -rf /var/cache/apk/*
# 更新字体缓存
RUN printf 'YES\n' | update-ms-fonts && fc-cache -f -v
# 配置Nginx # 配置Nginx
COPY docs/docker/nginx.conf /etc/nginx/nginx.conf COPY docs/docker/nginx.conf /etc/nginx/nginx.conf
+5 -1
View File
@@ -104,7 +104,11 @@ VAD:
参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md) 参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md)
### 8、更多问题,可联系我们反馈 💬 ### 8、如何开启手机注册智控台 📱
参考教程[阿里云短信集成指南](./ali-sms-integration.md)
### 9、更多问题,可联系我们反馈 💬
可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。 可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。
+44
View File
@@ -0,0 +1,44 @@
# 阿里云短信集成指南
登录阿里云控制台,进入“短信服务”页面:https://dysms.console.aliyun.com/overview
## 第一步 添加签名
![步骤](images/alisms/sms-01.png)
![步骤](images/alisms/sms-02.png)
以上步骤,会得到签名,请把它写入到智控台参数,`aliyun.sms.sign_name`
## 第二步 添加模版
![步骤](images/alisms/sms-11.png)
以上步骤,会得到模版code,请把它写入到智控台参数,`aliyun.sms.sms_code_template_code`
注意,签名要等7个工作日,等运营商报备成功后才能发送成功。
注意,签名要等7个工作日,等运营商报备成功后才能发送成功。
注意,签名要等7个工作日,等运营商报备成功后才能发送成功。
可以等报备成功后,再继续往下操作。
## 第三步 创建短信账户和开通权限
登录阿里云控制台,进入“访问控制”页面:https://ram.console.aliyun.com/overview?activeTab=overview
![步骤](images/alisms/sms-21.png)
![步骤](images/alisms/sms-22.png)
![步骤](images/alisms/sms-23.png)
![步骤](images/alisms/sms-24.png)
![步骤](images/alisms/sms-25.png)
以上步骤,会得到access_key_id和access_key_secret,请把它写入到智控台参数,`aliyun.sms.access_key_id``aliyun.sms.access_key_secret`
## 第四步 启动手机注册功能
1、正常来说,以上信息都填完后,会有这个效果,如果没有,可能缺少了某个步骤
![步骤](images/alisms/sms-31.png)
2、开启允许非管理员用户可注册,将参数`server.allow_user_register`设置成`true`
3、开启手机注册功能,将参数`server.enable_mobile_register`设置成`true`
![步骤](images/alisms/sms-32.png)
+2 -1
View File
@@ -1,7 +1,7 @@
# 基于虾哥编译好的固件配置自定义服务器 # 基于虾哥编译好的固件配置自定义服务器
## 第1步 确认版本 ## 第1步 确认版本
烧录虾哥已经编译好的1.6.1版本固件 烧录虾哥已经编译好的[1.6.1版本以上固件](https://github.com/78/xiaozhi-esp32/releases)
## 第2步 准备你的ota地址 ## 第2步 准备你的ota地址
如果你按照教程使用的是全模块部署,就应该会有ota地址。 如果你按照教程使用的是全模块部署,就应该会有ota地址。
@@ -29,6 +29,7 @@ wss://2662r3426b.vicp.fun/xiaozhi/v1/
## 第3步 进入配网模式 ## 第3步 进入配网模式
进入机器的配网模式,在页面顶部,点击“高级选项”,在里面输入你服务器的`ota`地址,点击保存。重启设备 进入机器的配网模式,在页面顶部,点击“高级选项”,在里面输入你服务器的`ota`地址,点击保存。重启设备
![请参考-OTA地址设置](../docs/images/firmware-setting-ota.png)
## 第4步 唤醒小智,查看日志输出 ## 第4步 唤醒小智,查看日志输出
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

+15
View File
@@ -28,6 +28,8 @@
<captcha.version>1.6.2</captcha.version> <captcha.version>1.6.2</captcha.version>
<guava.version>33.0.0-jre</guava.version> <guava.version>33.0.0-jre</guava.version>
<liquibase-core.version>4.20.0</liquibase-core.version> <liquibase-core.version>4.20.0</liquibase-core.version>
<aliyun-sms-version>4.1.0</aliyun-sms-version>
<okio-version>3.4.0</okio-version>
</properties> </properties>
<dependencies> <dependencies>
@@ -37,6 +39,7 @@
<classifier>jakarta</classifier> <classifier>jakarta</classifier>
<version>${shiro.version}</version> <version>${shiro.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.shiro</groupId> <groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId> <artifactId>shiro-spring</artifactId>
@@ -201,6 +204,18 @@
<groupId>com.fasterxml.jackson.datatype</groupId> <groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId> <artifactId>jackson-datatype-jsr310</artifactId>
</dependency> </dependency>
<!-- 阿里云短信sdk -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>${aliyun-sms-version}</version>
</dependency>
<!-- 阿里云短信sdk使用的okio:1.15.0版本有安全漏洞:GHSA-w33c-445m-f8w7 现在升级成3.4.0安全版本-->
<dependency>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
<version>${okio-version}</version>
</dependency>
</dependencies> </dependencies>
<!-- 阿里云maven仓库 --> <!-- 阿里云maven仓库 -->
@@ -118,29 +118,17 @@ public interface Constant {
enum SysBaseParam { enum SysBaseParam {
/** /**
* 系统全称 * ICP备案号
*/ */
SYS_NAME("SYS_NAME"), BEIAN_ICP_NUM("server.beian_icp_num"),
/** /**
* 系统简称 * GA备案号
*/ */
SYS_SHORT_NAME("SYS_SHORT_NAME"), BEIAN_GA_NUM("server.beian_ga_num"),
/** /**
* 系统描述 * 系统名称
*/ */
SYS_DES("SYS_DES"), SERVER_NAME("server.name");
/**
* 登录失败几次锁定
*/
LOGIN_LOCK_COUNT("LOGIN_LOCK_COUNT"),
/**
* 账号失败锁定分钟数
*/
LOGIN_LOCK_TIME("LOGIN_LOCK_TIME"),
/**
* TOKEN强验证
*/
SYS_TOKEN_SECURITY("SYS_TOKEN_SECURITY");
private String value; private String value;
@@ -153,6 +141,46 @@ public interface Constant {
} }
} }
/**
* 系统短信
*/
enum SysMSMParam {
/**
* 阿里云授权keyID
*/
ALIYUN_SMS_ACCESS_KEY_ID("aliyun.sms.access_key_id"),
/**
* 阿里云授权密钥
*/
ALIYUN_SMS_ACCESS_KEY_SECRET("aliyun.sms.access_key_secret"),
/**
* 阿里云短信签名
*/
ALIYUN_SMS_SIGN_NAME("aliyun.sms.sign_name"),
/**
* 阿里云短信模板
*/
ALIYUN_SMS_SMS_CODE_TEMPLATE_CODE("aliyun.sms.sms_code_template_code"),
/**
* 单号码最大短信发送条数
*/
SERVER_SMS_MAX_SEND_COUNT("server.sms_max_send_count"),
/**
* 是否开启手机注册
*/
SERVER_ENABLE_MOBILE_REGISTER("server.enable_mobile_register");
private String value;
SysMSMParam(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
/** /**
* 数据状态 * 数据状态
*/ */
@@ -199,5 +227,30 @@ public interface Constant {
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.4.3"; public static final String VERSION = "0.4.4";
/**
* 无效固件URL
*/
String INVALID_FIRMWARE_URL = "http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL";
/**
* 字典类型
*/
enum DictType {
/**
* 手机区号
*/
MOBILE_AREA("MOBILE_AREA");
private String value;
DictType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
} }
@@ -20,7 +20,7 @@ public interface ErrorCode {
int ACCOUNT_DISABLE = 10005; int ACCOUNT_DISABLE = 10005;
int IDENTIFIER_NOT_NULL = 10006; int IDENTIFIER_NOT_NULL = 10006;
int CAPTCHA_ERROR = 10007; int CAPTCHA_ERROR = 10007;
int SUB_MENU_EXIST = 10008; int PHONE_NOT_NULL = 10008;
int PASSWORD_ERROR = 10009; int PASSWORD_ERROR = 10009;
int SUPERIOR_DEPT_ERROR = 10011; int SUPERIOR_DEPT_ERROR = 10011;
@@ -1,7 +1,13 @@
package xiaozhi.common.exception; package xiaozhi.common.exception;
import java.util.List;
import java.util.Objects;
import org.apache.shiro.authz.UnauthorizedException; import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
@@ -60,4 +66,20 @@ public class RenExceptionHandler {
return new Result<Void>().error(404, "资源不存在"); return new Result<Void>().error(404, "资源不存在");
} }
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
List<ObjectError> allErrors = ex.getBindingResult().getAllErrors();
String errorMsg = allErrors.stream()
.filter(Objects::nonNull)
.map(err -> {
String msg = err.getDefaultMessage();
return (msg != null && !msg.trim().isEmpty()) ? msg : null;
})
.filter(Objects::nonNull)
.findFirst()
.orElse("请求参数错误!");
return new Result<Void>().error(ErrorCode.PARAM_VALUE_NULL, errorMsg);
}
} }
@@ -117,4 +117,26 @@ public class RedisKeys {
public static String getAgentAudioIdKey(String uuid) { public static String getAgentAudioIdKey(String uuid) {
return "agent:audio:id:" + uuid; return "agent:audio:id:" + uuid;
} }
/**
* 获取短信验证码的缓存key
*/
public static String getSMSValidateCodeKey(String phone) {
return "sms:Validate:Code:" + phone;
}
/**
* 获取短信验证码最后发送时间的缓存key
*/
public static String getSMSLastSendTimeKey(String phone) {
return "sms:Validate:Code:" + phone + ":last_send_time";
}
/**
* 获取短信验证码今日发送次数的缓存key
*/
public static String getSMSTodayCountKey(String phone) {
return "sms:Validate:Code:" + phone + ":today_count";
}
} }
@@ -6,12 +6,14 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import xiaozhi.common.utils.ResourcesUtils;
/** /**
* Redis工具类 * Redis工具类
@@ -23,6 +25,9 @@ public class RedisUtils {
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ResourcesUtils resourceUtils;
/** /**
* 默认过期时长为24小时,单位:秒 * 默认过期时长为24小时,单位:秒
*/ */
@@ -40,6 +45,24 @@ public class RedisUtils {
*/ */
public final static long NOT_EXPIRE = -1L; public final static long NOT_EXPIRE = -1L;
public Long increment(String key, long expire) {
Long increment = redisTemplate.opsForValue().increment(key, 1L);
if (expire != NOT_EXPIRE) {
expire(key, expire);
}
return increment;
}
public Long increment(String key) {
return redisTemplate.opsForValue().increment(key, 1L);
}
public Long decrement(String key) {
return redisTemplate.opsForValue().decrement(key, 1L);
}
public void set(String key, Object value, long expire) { public void set(String key, Object value, long expire) {
redisTemplate.opsForValue().set(key, value); redisTemplate.opsForValue().set(key, value);
if (expire != NOT_EXPIRE) { if (expire != NOT_EXPIRE) {
@@ -134,7 +157,7 @@ public class RedisUtils {
*/ */
public void emptyAll() { public void emptyAll() {
// Lua 脚本 FLUSHALL是redis清空所有库的命令 // Lua 脚本 FLUSHALL是redis清空所有库的命令
String luaScript ="redis.call('FLUSHALL')"; String luaScript =resourceUtils.loadString("lua/emptyAll.lua");
// 创建 DefaultRedisScript 对象 // 创建 DefaultRedisScript 对象
DefaultRedisScript<Void> redisScript = new DefaultRedisScript<>(); DefaultRedisScript<Void> redisScript = new DefaultRedisScript<>();
@@ -147,4 +170,26 @@ public class RedisUtils {
} }
/**
* 获取在redis指定key的值,如果值为空,着设置key的默认值
* @param key redis的key
* @param defaultValue 默认值
* @param expiresInSecond 过期时间
* @return 返回key的值
*/
public String getKeyOrCreate(String key, String defaultValue,Long expiresInSecond) {
// Lua 脚本
String luaScript = resourceUtils.loadString("lua/getKeyOrCreate.lua");
DefaultRedisScript<String> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript);
redisScript.setResultType(String.class);
// 执行 Lua 脚本
List<String> keys = Collections.singletonList(key);
return redisTemplate.execute(redisScript, keys, defaultValue,expiresInSecond);
}
} }
@@ -1,6 +1,7 @@
package xiaozhi.common.service.impl; package xiaozhi.common.service.impl;
import java.io.Serializable; import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -18,6 +19,7 @@ import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
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.core.metadata.OrderItem;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.Constants; import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.baomidou.mybatisplus.core.toolkit.ReflectionKit; import com.baomidou.mybatisplus.core.toolkit.ReflectionKit;
import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.baomidou.mybatisplus.core.toolkit.StringUtils;
@@ -45,6 +47,12 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
* @param params 分页查询参数 * @param params 分页查询参数
* @param defaultOrderField 默认排序字段 * @param defaultOrderField 默认排序字段
* @param isAsc 排序方式 * @param isAsc 排序方式
* @see xiaozhi.common.constant.Constant
* params.put(Constant.PAGE, "1");
* params.put(Constant.LIMIT, "10");
* params.put(Constant.ORDER_FIELD, "field"); // 单个字段
* params.put(Constant.ORDER_FIELD, List.of("field1", "field2")); // 多个字段
* params.put(Constant.ORDER, "asc");
*/ */
protected IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) { protected IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) {
// 分页参数 // 分页参数
@@ -65,28 +73,34 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
params.put(Constant.PAGE, page); params.put(Constant.PAGE, page);
// 排序字段 // 排序字段
String orderField = (String) params.get(Constant.ORDER_FIELD); Object orderField = params.get(Constant.ORDER_FIELD);
String order = (String) params.get(Constant.ORDER); String order = (String) params.get(Constant.ORDER);
// 前端字段排序 List<String> orderFields = new ArrayList<>();
if (StringUtils.isNotBlank(orderField) && StringUtils.isNotBlank(order)) {
if (Constant.ASC.equalsIgnoreCase(order)) { // 处理排序字段
return page.addOrder(OrderItem.asc(orderField)); if (orderField instanceof String) {
orderFields.add((String) orderField);
} else if (orderField instanceof List) {
orderFields.addAll((List<String>) orderField);
}
// 有排序字段则排序
if (CollectionUtils.isNotEmpty(orderFields)) {
if (StringUtils.isNotBlank(order) && Constant.ASC.equalsIgnoreCase(order)) {
return page.addOrder(OrderItem.ascs(orderFields.toArray(new String[0])));
} else { } else {
return page.addOrder(OrderItem.desc(orderField)); return page.addOrder(OrderItem.descs(orderFields.toArray(new String[0])));
} }
} }
// 没有排序字段,则不排序 // 没有排序字段,使用默认排序
if (StringUtils.isBlank(defaultOrderField)) { if (StringUtils.isNotBlank(defaultOrderField)) {
return page; if (isAsc) {
} page.addOrder(OrderItem.asc(defaultOrderField));
} else {
// 默认排序 page.addOrder(OrderItem.desc(defaultOrderField));
if (isAsc) { }
page.addOrder(OrderItem.asc(defaultOrderField));
} else {
page.addOrder(OrderItem.desc(defaultOrderField));
} }
return page; return page;
@@ -21,6 +21,8 @@ public class DateUtils {
* 时间格式(yyyy-MM-dd HH:mm:ss) * 时间格式(yyyy-MM-dd HH:mm:ss)
*/ */
public final static String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; public final static String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
public final static String DATE_TIME_MILLIS_PATTERN = "yyyy-MM-dd HH:mm:ss.SSS";
/** /**
* 日期格式化 日期格式为:yyyy-MM-dd * 日期格式化 日期格式为:yyyy-MM-dd
@@ -63,6 +65,19 @@ public class DateUtils {
return null; return null;
} }
public static String getDateTimeNow() {
return getDateTimeNow(DATE_TIME_PATTERN);
}
public static String getDateTimeNow(String pattern) {
return format(new Date(), pattern);
}
public static String millsToSecond(long mills) {
return String.format("%.3f", mills / 1000.0);
}
/** /**
* 获取简短的时间字符串:10秒前返回刚刚,多少秒前,几小时前,超过一周返回年月日时分秒 * 获取简短的时间字符串:10秒前返回刚刚,多少秒前,几小时前,超过一周返回年月日时分秒
* @param date * @param date
@@ -0,0 +1,44 @@
package xiaozhi.common.utils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import xiaozhi.common.exception.RenException;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
/**
* 资源处理工具
*/
@AllArgsConstructor
@Slf4j
@Component
public class ResourcesUtils {
private ResourceLoader resourceLoader;
/**
* 读取资源,返回字符串
* @param fileName 资源路径:resources下开始
* @return 字符串
*/
public String loadString(String fileName) {
Resource resource = resourceLoader.getResource("classpath:" + fileName);
StringBuilder luaScriptBuilder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
luaScriptBuilder.append(line).append("\n");
}
} catch (IOException e){
log.error("方法:loadString()读取资源失败--{}",e.getMessage());
throw new RenException("读取资源失败");
}
return luaScriptBuilder.toString();
}
}
@@ -2,6 +2,7 @@ package xiaozhi.common.validator;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern;
import org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator; import org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator;
import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.context.i18n.LocaleContextHolder;
@@ -45,4 +46,32 @@ public class ValidatorUtils {
throw new RenException(constraint.getMessage()); throw new RenException(constraint.getMessage());
} }
} }
/**
* 国际手机号正则表达式
* 要求必须带国际区号,格式:+[国家代码][手机号]
* 例如:
* - +8613800138000
* - +12345678900
* - +447123456789
*/
private static final String INTERNATIONAL_PHONE_REGEX = "^\\+[1-9]\\d{0,3}[1-9]\\d{4,14}$";
/**
* 校验手机号是否有效
* 要求必须带国际区号,格式:+[国家代码][手机号]
* 例如:+8613800138000
*
* @param phone 手机号
* @return boolean
*/
public static boolean isValidPhone(String phone) {
if (phone == null || phone.isEmpty()) {
return false;
}
// 验证必须带国际区号的手机号格式
Pattern pattern = Pattern.compile(INTERNATIONAL_PHONE_REGEX);
return pattern.matcher(phone).matches();
}
} }
@@ -142,7 +142,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
if (deviceById == null) { if (deviceById == null) {
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware(); DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
firmware.setVersion(deviceReport.getApplication().getVersion()); firmware.setVersion(deviceReport.getApplication().getVersion());
firmware.setUrl("http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL"); firmware.setUrl(Constant.INVALID_FIRMWARE_URL);
response.setFirmware(firmware); response.setFirmware(firmware);
} else { } else {
// 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息 // 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
@@ -378,7 +378,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
} }
firmware.setVersion(ota == null ? currentVersion : ota.getVersion()); firmware.setVersion(ota == null ? currentVersion : ota.getVersion());
firmware.setUrl(downloadUrl == null ? "" : downloadUrl); firmware.setUrl(downloadUrl == null ? Constant.INVALID_FIRMWARE_URL : downloadUrl);
return firmware; return firmware;
} }
@@ -0,0 +1,68 @@
package xiaozhi.modules.model.controller;
import java.util.List;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.group.UpdateGroup;
import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.service.ModelProviderService;
@AllArgsConstructor
@RestController
@RequestMapping("/models/provider")
@Tag(name = "模型供应器")
public class ModelProviderController {
private final ModelProviderService modelProviderService;
@GetMapping
@Operation(summary = "获取模型供应器列表")
@RequiresPermissions("sys:role:superAdmin")
public Result<PageData<ModelProviderDTO>> getListPage(ModelProviderDTO modelProviderDTO,
@RequestParam(required = true, defaultValue = "0") String page,
@RequestParam(required = true, defaultValue = "10") String limit) {
return new Result<PageData<ModelProviderDTO>>()
.ok(modelProviderService.getListPage(modelProviderDTO, page, limit));
}
@PostMapping
@Operation(summary = "新增模型供应器")
@RequiresPermissions("sys:role:superAdmin")
public Result<ModelProviderDTO> add(@RequestBody @Validated ModelProviderDTO modelProviderDTO) {
ModelProviderDTO resp = modelProviderService.add(modelProviderDTO);
return new Result<ModelProviderDTO>().ok(resp);
}
@PutMapping
@Operation(summary = "修改模型供应器")
@RequiresPermissions("sys:role:superAdmin")
public Result<ModelProviderDTO> edit(@RequestBody @Validated(UpdateGroup.class) ModelProviderDTO modelProviderDTO) {
ModelProviderDTO resp = modelProviderService.edit(modelProviderDTO);
return new Result<ModelProviderDTO>().ok(resp);
}
@PostMapping("/delete")
@Operation(summary = "删除模型供应器")
@RequiresPermissions("sys:role:superAdmin")
@Parameter(name = "ids", description = "ID数组", required = true)
public Result<Void> delete(@RequestBody List<String> ids) {
modelProviderService.delete(ids);
return new Result<>();
}
}
@@ -8,29 +8,37 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import xiaozhi.common.validator.group.UpdateGroup;
@Data @Data
@Schema(description = "模型供应器/商") @Schema(description = "模型供应器/商")
public class ModelProviderDTO implements Serializable { public class ModelProviderDTO implements Serializable {
// @Schema(description = "主键")
// @Schema(description = "主键") @NotBlank(message = "id不能为空", groups = UpdateGroup.class)
// private Long id; private String id;
@Schema(description = "模型类型(Memory/ASR/VAD/LLM/TTS)") @Schema(description = "模型类型(Memory/ASR/VAD/LLM/TTS)")
@NotBlank(message = "modelType不能为空")
private String modelType; private String modelType;
@Schema(description = "供应器类型") @Schema(description = "供应器类型")
@NotBlank(message = "providerCode不能为空")
private String providerCode; private String providerCode;
@Schema(description = "供应器名称") @Schema(description = "供应器名称")
@NotBlank(message = "name不能为空")
private String name; private String name;
@Schema(description = "供应器字段列表(JSON格式)") @Schema(description = "供应器字段列表(JSON格式)")
@TableField(typeHandler = JacksonTypeHandler.class) @TableField(typeHandler = JacksonTypeHandler.class)
@NotBlank(message = "fields(JSON格式)不能为空")
private String fields; private String fields;
@Schema(description = "排序") @Schema(description = "排序")
@NotNull(message = "sort不能为空")
private Integer sort; private Integer sort;
@Schema(description = "更新者") @Schema(description = "更新者")
@@ -3,10 +3,8 @@ package xiaozhi.modules.model.entity;
import java.util.Date; import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -30,7 +28,6 @@ public class ModelProviderEntity {
private String name; private String name;
@Schema(description = "供应器字段列表(JSON格式)") @Schema(description = "供应器字段列表(JSON格式)")
@TableField(typeHandler = JacksonTypeHandler.class)
private String fields; private String fields;
@Schema(description = "排序") @Schema(description = "排序")
@@ -2,8 +2,8 @@ package xiaozhi.modules.model.service;
import java.util.List; import java.util.List;
import xiaozhi.common.page.PageData;
import xiaozhi.modules.model.dto.ModelProviderDTO; import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.entity.ModelProviderEntity;
public interface ModelProviderService { public interface ModelProviderService {
@@ -11,11 +11,15 @@ public interface ModelProviderService {
List<ModelProviderDTO> getListByModelType(String modelType); List<ModelProviderDTO> getListByModelType(String modelType);
ModelProviderDTO add(ModelProviderEntity modelProviderEntity); ModelProviderDTO add(ModelProviderDTO modelProviderDTO);
ModelProviderDTO edit(ModelProviderEntity modelProviderEntity); ModelProviderDTO edit(ModelProviderDTO modelProviderDTO);
void delete(); void delete(String id);
void delete(List<String> id);
PageData<ModelProviderDTO> getListPage(ModelProviderDTO modelProviderDTO, String page, String limit);
List<ModelProviderDTO> getList(String modelType, String provideCode); List<ModelProviderDTO> getList(String modelType, String provideCode);
} }
@@ -1,19 +1,29 @@
package xiaozhi.modules.model.service.impl; package xiaozhi.modules.model.service.impl;
import java.util.Date;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.json.JSONArray;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.model.dao.ModelProviderDao; import xiaozhi.modules.model.dao.ModelProviderDao;
import xiaozhi.modules.model.dto.ModelProviderDTO; import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.entity.ModelProviderEntity; import xiaozhi.modules.model.entity.ModelProviderEntity;
import xiaozhi.modules.model.service.ModelProviderService; import xiaozhi.modules.model.service.ModelProviderService;
import xiaozhi.modules.security.user.SecurityUser;
@Service @Service
@AllArgsConstructor @AllArgsConstructor
@@ -32,18 +42,78 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
} }
@Override @Override
public ModelProviderDTO add(ModelProviderEntity modelProviderEntity) { public PageData<ModelProviderDTO> getListPage(ModelProviderDTO modelProviderDTO, String page, String limit) {
return null;
Map<String, Object> params = new HashMap<String, Object>();
params.put(Constant.PAGE, page);
params.put(Constant.LIMIT, limit);
params.put(Constant.ORDER_FIELD, List.of("model_type", "sort"));
params.put(Constant.ORDER, "asc");
IPage<ModelProviderEntity> pageParam = getPage(params, null, true);
QueryWrapper<ModelProviderEntity> wrapper = new QueryWrapper<ModelProviderEntity>();
if (StringUtils.isNotBlank(modelProviderDTO.getModelType())) {
wrapper.eq("model_type", modelProviderDTO.getModelType());
}
if (StringUtils.isNotBlank(modelProviderDTO.getName())) {
wrapper.and(w -> w.like("name", modelProviderDTO.getName())
.or()
.like("provider_code", modelProviderDTO.getName()));
}
return getPageData(modelProviderDao.selectPage(pageParam, wrapper), ModelProviderDTO.class);
}
public static void main(String[] args) {
String jsonString = "\"[]\"";
JSONArray jsonArray = new JSONArray(jsonString);
System.out.println("字符串转 JSONArray: " + jsonArray.toString());
} }
@Override @Override
public ModelProviderDTO edit(ModelProviderEntity modelProviderEntity) { public ModelProviderDTO add(ModelProviderDTO modelProviderDTO) {
return null; UserDetail user = SecurityUser.getUser();
modelProviderDTO.setCreator(user.getId());
modelProviderDTO.setUpdater(user.getId());
modelProviderDTO.setCreateDate(new Date());
modelProviderDTO.setUpdateDate(new Date());
// 去除Fields左右的双引号
modelProviderDTO.setFields(modelProviderDTO.getFields());
ModelProviderEntity entity = ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderEntity.class);
if (modelProviderDao.insert(entity) == 0) {
throw new RenException("新增数据失败");
}
return ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderDTO.class);
} }
@Override @Override
public void delete() { public ModelProviderDTO edit(ModelProviderDTO modelProviderDTO) {
UserDetail user = SecurityUser.getUser();
modelProviderDTO.setUpdater(user.getId());
modelProviderDTO.setUpdateDate(new Date());
if (modelProviderDao
.updateById(ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderEntity.class)) == 0) {
throw new RenException("修改数据失败");
}
return ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderDTO.class);
}
@Override
public void delete(String id) {
if (modelProviderDao.deleteById(id) == 0) {
throw new RenException("删除数据失败");
}
}
@Override
public void delete(List<String> ids) {
if (modelProviderDao.deleteBatchIds(ids) == 0) {
throw new RenException("删除数据失败");
}
} }
@Override @Override
@@ -80,9 +80,11 @@ public class ShiroConfig {
filterMap.put("/doc.html", "anon"); filterMap.put("/doc.html", "anon");
filterMap.put("/favicon.ico", "anon"); filterMap.put("/favicon.ico", "anon");
filterMap.put("/user/captcha", "anon"); filterMap.put("/user/captcha", "anon");
filterMap.put("/user/smsVerification", "anon");
filterMap.put("/user/login", "anon"); filterMap.put("/user/login", "anon");
filterMap.put("/user/pub-config", "anon"); filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon"); filterMap.put("/user/register", "anon");
filterMap.put("/user/retrieve-password", "anon");
// 将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");
@@ -1,7 +1,9 @@
package xiaozhi.modules.security.controller; package xiaozhi.modules.security.controller;
import java.io.IOException; import java.io.IOException;
import java.util.Calendar;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -24,13 +26,18 @@ import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.AssertUtils; import xiaozhi.common.validator.AssertUtils;
import xiaozhi.common.validator.ValidatorUtils; import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.security.dto.LoginDTO; import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.dto.SmsVerificationDTO;
import xiaozhi.modules.security.password.PasswordUtils; 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.modules.sys.dto.PasswordDTO; import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysDictDataService;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserService; import xiaozhi.modules.sys.service.SysUserService;
import xiaozhi.modules.sys.vo.SysDictDataItem;
/** /**
* 登录控制层 * 登录控制层
@@ -43,24 +50,43 @@ public class LoginController {
private final SysUserService sysUserService; private final SysUserService sysUserService;
private final SysUserTokenService sysUserTokenService; private final SysUserTokenService sysUserTokenService;
private final CaptchaService captchaService; private final CaptchaService captchaService;
private final SysParamsService sysParamsService;
private final SysDictDataService sysDictDataService;
@GetMapping("/captcha") @GetMapping("/captcha")
@Operation(summary = "验证码") @Operation(summary = "验证码")
public void captcha(HttpServletResponse response, String uuid) throws IOException { public void captcha(HttpServletResponse response, String uuid) throws IOException {
// uuid不能为空 // uuid不能为空
AssertUtils.isBlank(uuid, ErrorCode.IDENTIFIER_NOT_NULL); AssertUtils.isBlank(uuid, ErrorCode.IDENTIFIER_NOT_NULL);
// 生成验证码 // 生成验证码
captchaService.create(response, uuid); captchaService.create(response, uuid);
} }
@PostMapping("/smsVerification")
@Operation(summary = "短信验证码")
public Result<Void> smsVerification(@RequestBody SmsVerificationDTO dto) {
// 验证图形验证码
boolean validate = captchaService.validate(dto.getCaptchaId(), dto.getCaptcha(), true);
if (!validate) {
throw new RenException("图形验证码错误");
}
Boolean isMobileRegister = sysParamsService
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
if (!isMobileRegister) {
throw new RenException("没有开启手机注册,没法使用短信验证码功能");
}
// 发送短信验证码
captchaService.sendSMSValidateCode(dto.getPhone());
return new Result<>();
}
@PostMapping("/login") @PostMapping("/login")
@Operation(summary = "登录") @Operation(summary = "登录")
public Result<TokenDTO> login(@RequestBody LoginDTO login) { public Result<TokenDTO> login(@RequestBody LoginDTO login) {
// 验证是否正确输入验证码 // 验证是否正确输入验证码
boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha()); boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true);
if (!validate) { if (!validate) {
throw new RenException("验证码错误,请重新获取"); throw new RenException("图形验证码错误,请重新获取");
} }
// 按照用户名获取用户 // 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
@@ -81,11 +107,29 @@ public class LoginController {
if (!sysUserService.getAllowUserRegister()) { if (!sysUserService.getAllowUserRegister()) {
throw new RenException("当前不允许普通用户注册"); throw new RenException("当前不允许普通用户注册");
} }
// 验证是否正确输入验证码 // 是否开启手机注册
boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha()); Boolean isMobileRegister = sysParamsService
if (!validate) { .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
throw new RenException("验证码错误,请重新获取"); boolean validate;
if (isMobileRegister) {
// 验证用户是否是手机号码
boolean validPhone = ValidatorUtils.isValidPhone(login.getUsername());
if (!validPhone) {
throw new RenException("用户名不是手机号码,请重新输入");
}
// 验证短信验证码是否正常
validate = captchaService.validateSMSValidateCode(login.getUsername(), login.getMobileCaptcha(), false);
if (!validate) {
throw new RenException("手机验证码错误,请重新获取");
}
} else {
// 验证是否正确输入验证码
validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true);
if (!validate) {
throw new RenException("图形验证码错误,请重新获取");
}
} }
// 按照用户名获取用户 // 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
if (userDTO != null) { if (userDTO != null) {
@@ -96,7 +140,6 @@ public class LoginController {
userDTO.setPassword(login.getPassword()); userDTO.setPassword(login.getPassword());
sysUserService.save(userDTO); sysUserService.save(userDTO);
return new Result<>(); return new Result<>();
} }
@GetMapping("/info") @GetMapping("/info")
@@ -118,12 +161,54 @@ public class LoginController {
return new Result<>(); return new Result<>();
} }
@PutMapping("/retrieve-password")
@Operation(summary = "找回密码")
public Result<?> retrievePassword(@RequestBody RetrievePasswordDTO dto) {
// 是否开启手机注册
Boolean isMobileRegister = sysParamsService
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
if (!isMobileRegister) {
throw new RenException("没有开启手机注册,没法使用找回密码功能");
}
// 判断非空
ValidatorUtils.validateEntity(dto);
// 验证用户是否是手机号码
boolean validPhone = ValidatorUtils.isValidPhone(dto.getPhone());
if (!validPhone) {
throw new RenException("输入的手机号码格式不正确");
}
// 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(dto.getPhone());
if (userDTO == null) {
throw new RenException("输入的手机号码未注册");
}
// 验证短信验证码是否正常
boolean validate = captchaService.validateSMSValidateCode(dto.getPhone(), dto.getCode(), false);
// 判断是否通过验证
if (!validate) {
throw new RenException("输入的手机验证码错误");
}
sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword());
return new Result<>();
}
@GetMapping("/pub-config") @GetMapping("/pub-config")
@Operation(summary = "公共配置") @Operation(summary = "公共配置")
public Result<Map<String, Object>> pubConfig() { public Result<Map<String, Object>> pubConfig() {
Map<String, Object> config = new HashMap<>(); Map<String, Object> config = new HashMap<>();
config.put("enableMobileRegister", sysParamsService
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class));
config.put("version", Constant.VERSION); config.put("version", Constant.VERSION);
config.put("year", "©" + Calendar.getInstance().get(Calendar.YEAR));
config.put("allowUserRegister", sysUserService.getAllowUserRegister()); config.put("allowUserRegister", sysUserService.getAllowUserRegister());
List<SysDictDataItem> list = sysDictDataService.getDictDataByType(Constant.DictType.MOBILE_AREA.getValue());
config.put("mobileAreaList", list);
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("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true));
return new Result<Map<String, Object>>().ok(config); return new Result<Map<String, Object>>().ok(config);
} }
} }
@@ -25,6 +25,9 @@ public class LoginDTO implements Serializable {
@NotBlank(message = "{sysuser.captcha.require}") @NotBlank(message = "{sysuser.captcha.require}")
private String captcha; private String captcha;
@Schema(description = "手机验证码")
private String mobileCaptcha;
@Schema(description = "唯一标识") @Schema(description = "唯一标识")
@NotBlank(message = "{sysuser.uuid.require}") @NotBlank(message = "{sysuser.uuid.require}")
private String captchaId; private String captchaId;
@@ -0,0 +1,28 @@
package xiaozhi.modules.security.dto;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 短信验证码请求DTO
*/
@Data
@Schema(description = "短信验证码请求")
public class SmsVerificationDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "手机号码")
@NotBlank(message = "{sysuser.username.require}")
private String phone;
@Schema(description = "验证码")
@NotBlank(message = "{sysuser.captcha.require}")
private String captcha;
@Schema(description = "唯一标识")
@NotBlank(message = "{sysuser.uuid.require}")
private String captchaId;
}
@@ -18,10 +18,28 @@ public interface CaptchaService {
/** /**
* 验证码效验 * 验证码效验
* *
* @param uuid uuid * @param uuid uuid
* @param code 验证码 * @param code 验证码
* @param delete 是否删除验证码
* @return true:成功 false:失败 * @return true:成功 false:失败
*/ */
boolean validate(String uuid, String code); boolean validate(String uuid, String code, Boolean delete);
/**
* 发送短信验证码
*
* @param phone 手机
*/
void sendSMSValidateCode(String phone);
/**
* 验证短信验证码
*
* @param phone 手机
* @param code 验证码
* @param delete 是否删除验证码
* @return true:成功 false:失败
*/
boolean validateSMSValidateCode(String phone, String code, Boolean delete);
} }
@@ -1,6 +1,7 @@
package xiaozhi.modules.security.service.impl; package xiaozhi.modules.security.service.impl;
import java.io.IOException; import java.io.IOException;
import java.util.Random;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -14,9 +15,13 @@ import com.wf.captcha.base.Captcha;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.security.service.CaptchaService; import xiaozhi.modules.security.service.CaptchaService;
import xiaozhi.modules.sms.service.SmsService;
import xiaozhi.modules.sys.service.SysParamsService;
/** /**
* 验证码 * 验证码
@@ -25,6 +30,10 @@ import xiaozhi.modules.security.service.CaptchaService;
public class CaptchaServiceImpl implements CaptchaService { public class CaptchaServiceImpl implements CaptchaService {
@Resource @Resource
private RedisUtils redisUtils; private RedisUtils redisUtils;
@Resource
private SmsService smsService;
@Resource
private SysParamsService sysParamsService;
@Value("${renren.redis.open}") @Value("${renren.redis.open}")
private boolean open; private boolean open;
/** /**
@@ -51,12 +60,12 @@ public class CaptchaServiceImpl implements CaptchaService {
} }
@Override @Override
public boolean validate(String uuid, String code) { public boolean validate(String uuid, String code, Boolean delete) {
if (StringUtils.isBlank(code)) { if (StringUtils.isBlank(code)) {
return false; return false;
} }
// 获取验证码 // 获取验证码
String captcha = getCache(uuid); String captcha = getCache(uuid, delete);
// 效验成功 // 效验成功
if (code.equalsIgnoreCase(captcha)) { if (code.equalsIgnoreCase(captcha)) {
@@ -66,21 +75,97 @@ public class CaptchaServiceImpl implements CaptchaService {
return false; return false;
} }
@Override
public void sendSMSValidateCode(String phone) {
// 检查发送间隔
String lastSendTimeKey = RedisKeys.getSMSLastSendTimeKey(phone);
// 获取是否发送过,如果没有设置最后发送时间(60秒)
String lastSendTime = redisUtils
.getKeyOrCreate(lastSendTimeKey,
String.valueOf(System.currentTimeMillis()), 60L);
if (lastSendTime != null) {
long lastSendTimeLong = Long.parseLong(lastSendTime);
long currentTime = System.currentTimeMillis();
long timeDiff = currentTime - lastSendTimeLong;
if (timeDiff < 60000) {
throw new RenException("发送太频繁,请" + (60000 - timeDiff) / 1000 + "秒后再试");
}
}
// 检查今日发送次数
String todayCountKey = RedisKeys.getSMSTodayCountKey(phone);
Integer todayCount = (Integer) redisUtils.get(todayCountKey);
if (todayCount == null) {
todayCount = 0;
}
// 获取最大发送次数限制
Integer maxSendCount = sysParamsService.getValueObject(
Constant.SysMSMParam.SERVER_SMS_MAX_SEND_COUNT.getValue(),
Integer.class);
if (maxSendCount == null) {
maxSendCount = 5; // 默认值
}
if (todayCount >= maxSendCount) {
throw new RenException("今日发送次数已达上限");
}
String key = RedisKeys.getSMSValidateCodeKey(phone);
String validateCodes = generateValidateCode(6);
// 设置验证码
setCache(key, validateCodes);
// 更新今日发送次数
if (todayCount == 0) {
redisUtils.increment(todayCountKey, RedisUtils.DEFAULT_EXPIRE);
} else {
redisUtils.increment(todayCountKey);
}
// 发送验证码短信
smsService.sendVerificationCodeSms(phone, validateCodes);
}
@Override
public boolean validateSMSValidateCode(String phone, String code, Boolean delete) {
String key = RedisKeys.getSMSValidateCodeKey(phone);
return validate(key, code, delete);
}
/**
* 生成指定数量的随机数验证码
*
* @param length 数量
* @return 随机码
*/
private String generateValidateCode(Integer length) {
String chars = "0123456789"; // 字符范围可以自定义:数字
Random random = new Random();
StringBuilder code = new StringBuilder();
for (int i = 0; i < length; i++) {
code.append(chars.charAt(random.nextInt(chars.length())));
}
return code.toString();
}
private void setCache(String key, String value) { private void setCache(String key, String value) {
if (open) { if (open) {
key = RedisKeys.getCaptchaKey(key); key = RedisKeys.getCaptchaKey(key);
// 设置5分钟过期
redisUtils.set(key, value, 300); redisUtils.set(key, value, 300);
} else { } else {
localCache.put(key, value); localCache.put(key, value);
} }
} }
private String getCache(String key) { private String getCache(String key, Boolean delete) {
if (open) { if (open) {
key = RedisKeys.getCaptchaKey(key); key = RedisKeys.getCaptchaKey(key);
String captcha = (String) redisUtils.get(key); String captcha = (String) redisUtils.get(key);
// 删除验证码 // 删除验证码
if (captcha != null) { if (captcha != null && delete) {
redisUtils.delete(key); redisUtils.delete(key);
} }
@@ -0,0 +1,17 @@
package xiaozhi.modules.sms.service;
/**
* 短信服务的方法定义接口
*
* @author zjy
* @since 2025-05-12
*/
public interface SmsService {
/**
* 发送验证码短信
* @param phone 手机号码
* @param VerificationCode 验证码
*/
void sendVerificationCodeSms(String phone, String VerificationCode) ;
}
@@ -0,0 +1,76 @@
package xiaozhi.modules.sms.service.imp;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sms.service.SmsService;
import xiaozhi.modules.sys.service.SysParamsService;
@Service
@AllArgsConstructor
@Slf4j
public class ALiYunSmsService implements SmsService {
private final SysParamsService sysParamsService;
private final RedisUtils redisUtils;
@Override
public void sendVerificationCodeSms(String phone, String VerificationCode) {
Client client = createClient();
String SignName = sysParamsService.getValue(Constant.SysMSMParam
.ALIYUN_SMS_SIGN_NAME.getValue(),true);
String TemplateCode = sysParamsService.getValue(Constant.SysMSMParam
.ALIYUN_SMS_SMS_CODE_TEMPLATE_CODE.getValue(),true);
try {
SendSmsRequest sendSmsRequest = new SendSmsRequest()
.setSignName(SignName)
.setTemplateCode(TemplateCode)
.setPhoneNumbers(phone)
.setTemplateParam(String.format("{\"code\":\"%s\"}", VerificationCode));
RuntimeOptions runtime = new RuntimeOptions();
// 复制代码运行请自行打印 API 的返回值
SendSmsResponse sendSmsResponse = client.sendSmsWithOptions(sendSmsRequest, runtime);
log.info("发送短信响应的requestID: {}", sendSmsResponse.getBody().getRequestId());
} catch (Exception e) {
// 如果发送失败了退还这次发送数
String todayCountKey = RedisKeys.getSMSTodayCountKey(phone);
redisUtils.delete(todayCountKey);
// 错误 message
log.error(e.getMessage());
throw new RenException("短信发送失败");
}
}
/**
* 创建阿里云连接
* @return 返回连接对象
*/
private Client createClient(){
String ACCESS_KEY_ID = sysParamsService.getValue(Constant.SysMSMParam
.ALIYUN_SMS_ACCESS_KEY_ID.getValue(),true);
String ACCESS_KEY_SECRET = sysParamsService.getValue(Constant.SysMSMParam
.ALIYUN_SMS_ACCESS_KEY_SECRET.getValue(),true);
try {
Config config = new Config()
.setAccessKeyId(ACCESS_KEY_ID)
.setAccessKeySecret(ACCESS_KEY_SECRET);
// 配置 Endpoint。中国站请使用dysmsapi.aliyuncs.com
config.endpoint = "dysmsapi.aliyuncs.com";
return new Client(config);
}catch (Exception e){
// 错误 message
log.error(e.getMessage());
throw new RenException("短信连接建立失败");
}
}
}
@@ -0,0 +1,121 @@
package xiaozhi.modules.sys.controller;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.socket.WebSocketHttpHeaders;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.annotation.LogOperation;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.sys.dto.EmitSeverActionDTO;
import xiaozhi.modules.sys.dto.ServerActionPayloadDTO;
import xiaozhi.modules.sys.dto.ServerActionResponseDTO;
import xiaozhi.modules.sys.enums.ServerActionEnum;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.utils.WebSocketClientManager;
/**
* 服务端管理控制器
*/
@RestController
@RequestMapping("/admin/server")
@Tag(name = "服务端管理")
@AllArgsConstructor
public class ServerSideManageController {
private final SysParamsService sysParamsService;
private static final ObjectMapper objectMapper;
static {
objectMapper = new ObjectMapper();
// 忽略json字符串中存在,但pojo中不存在对应字段的情况
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
@Operation(summary = "获取Ws服务端列表")
@GetMapping("/server-list")
@RequiresPermissions("sys:role:superAdmin")
public Result<List<String>> getWsServerList() {
String wsText = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsText)) {
return new Result<List<String>>().ok(Collections.emptyList());
}
return new Result<List<String>>().ok(Arrays.asList(wsText.split(";")));
}
@Operation(summary = "通知python服务端更新配置")
@PostMapping("/emit-action")
@LogOperation("通知python服务端更新配置")
@RequiresPermissions("sys:role:superAdmin")
public Result<Boolean> emitServerAction(@RequestBody @Valid EmitSeverActionDTO emitSeverActionDTO) {
if (emitSeverActionDTO.getAction() == null) {
throw new RenException("无效服务端操作");
}
String wsText = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsText)) {
throw new RenException("未配置服务端WebSocket地址");
}
String targetWs = emitSeverActionDTO.getTargetWs();
String[] wsList = wsText.split(";");
// 找到需要发起的
if (StringUtils.isBlank(targetWs) || !Arrays.asList(wsList).contains(targetWs)) {
throw new RenException("目标WebSocket地址不存在");
}
return new Result<Boolean>().ok(emitServerActionByWs(targetWs, emitSeverActionDTO.getAction()));
}
private Boolean emitServerActionByWs(String targetWsUri, ServerActionEnum actionEnum) {
if (StringUtils.isBlank(targetWsUri) || actionEnum == null) {
return false;
}
String serverSK = sysParamsService.getValue(Constant.SERVER_SECRET, true);
WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
headers.add("device-id", UUID.randomUUID().toString());
headers.add("client-id", UUID.randomUUID().toString());
try (WebSocketClientManager client = new WebSocketClientManager.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.maxSessionDuration(120, TimeUnit.SECONDS)
.uri(targetWsUri)
.headers(headers)
.build()) {
// 如果连接成功则发送一个json数据包并等待服务端响应
client.sendJson(
ServerActionPayloadDTO.build(
actionEnum,
Map.of("secret", serverSK)));
// 等待服务端响应并持续监听信息
client.listener((jsonText) -> {
if (StringUtils.isBlank(jsonText)) {
return false;
}
try {
ServerActionResponseDTO response = objectMapper.readValue(jsonText, ServerActionResponseDTO.class);
Boolean isSuccess = ServerActionResponseDTO.isSuccess(response);
return isSuccess;
} catch (JsonProcessingException e) {
return false;
}
});
} catch (Exception e) {
// 捕获全部错误,由全局异常处理器返回
throw new RenException("WebSocket连接失败或连接超时");
}
return true;
}
}
@@ -59,7 +59,7 @@ public class SysParamsController {
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", in = ParameterIn.QUERY, required = true, ref = "int"), @Parameter(name = Constant.LIMIT, description = "每页显示记录数", in = ParameterIn.QUERY, required = true, ref = "int"),
@Parameter(name = Constant.ORDER_FIELD, description = "排序字段", in = ParameterIn.QUERY, ref = "String"), @Parameter(name = Constant.ORDER_FIELD, description = "排序字段", in = ParameterIn.QUERY, ref = "String"),
@Parameter(name = Constant.ORDER, description = "排序方式,可选值(asc、desc)", in = ParameterIn.QUERY, ref = "String"), @Parameter(name = Constant.ORDER, description = "排序方式,可选值(asc、desc)", in = ParameterIn.QUERY, ref = "String"),
@Parameter(name = "paramCode", description = "参数编码", in = ParameterIn.QUERY, ref = "String") @Parameter(name = "paramCode", description = "参数编码或参数备注", in = ParameterIn.QUERY, ref = "String")
}) })
@RequiresPermissions("sys:role:superAdmin") @RequiresPermissions("sys:role:superAdmin")
public Result<PageData<SysParamsDTO>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) { public Result<PageData<SysParamsDTO>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
@@ -111,7 +111,7 @@ public class SysParamsController {
/** /**
* 验证WebSocket地址列表 * 验证WebSocket地址列表
* *
* @param urls WebSocket地址列表,以分号分隔 * @param urls WebSocket地址列表,以分号分隔
* @return 验证结果 * @return 验证结果
*/ */
@@ -143,6 +143,19 @@ public class SysParamsController {
} }
} }
@PostMapping("/delete")
@Operation(summary = "删除")
@LogOperation("删除")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> delete(@RequestBody String[] ids) {
// 效验数据
AssertUtils.isArrayEmpty(ids, "id");
sysParamsService.delete(ids);
configService.getConfig(false);
return new Result<Void>();
}
/** /**
* 验证OTA地址 * 验证OTA地址
*/ */
@@ -182,17 +195,4 @@ public class SysParamsController {
throw new RenException("OTA接口验证失败:" + e.getMessage()); throw new RenException("OTA接口验证失败:" + e.getMessage());
} }
} }
@PostMapping("/delete")
@Operation(summary = "删除")
@LogOperation("删除")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> delete(@RequestBody String[] ids) {
// 效验数据
AssertUtils.isArrayEmpty(ids, "id");
sysParamsService.delete(ids);
configService.getConfig(false);
return new Result<Void>();
}
} }
@@ -0,0 +1,26 @@
package xiaozhi.modules.sys.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import xiaozhi.modules.sys.enums.ServerActionEnum;
/**
* 发送python服务端操作DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmitSeverActionDTO
{
@Schema(description = "目标ws地址")
@NotEmpty(message = "目标ws地址不能为空")
private String targetWs;
@Schema(description = "指定操作")
@NotNull(message = "操作不能为空")
private ServerActionEnum action;
}
@@ -0,0 +1,30 @@
package xiaozhi.modules.sys.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serializable;
/**
* 找回密码DTO
*/
@Data
@Schema(description = "找回密码")
public class RetrievePasswordDTO implements Serializable {
@Schema(description = "手机号码")
@NotBlank(message = "{sysuser.password.require}")
private String phone;
@Schema(description = "验证码")
@NotBlank(message = "{sysuser.password.require}")
private String code;
@Schema(description = "新密码")
@NotBlank(message = "{sysuser.password.require}")
private String password;
}
@@ -0,0 +1,36 @@
package xiaozhi.modules.sys.dto;
import lombok.Data;
import xiaozhi.modules.sys.enums.ServerActionEnum;
import java.util.Map;
/**
* 服务端动作DTO
*/
@Data
public class ServerActionPayloadDTO
{
/**
* 类型(智控台发往服务端的都是server)
*/
private String type;
/**
* 动作
*/
private ServerActionEnum action;
/**
* 内容
*/
private Map<String, Object> content;
public static ServerActionPayloadDTO build(ServerActionEnum action, Map<String, Object> content) {
ServerActionPayloadDTO serverActionPayloadDTO = new ServerActionPayloadDTO();
serverActionPayloadDTO.setAction(action);
serverActionPayloadDTO.setContent(content);
serverActionPayloadDTO.setType("server");
return serverActionPayloadDTO;
}
// 私有化
private ServerActionPayloadDTO() {}
}
@@ -0,0 +1,34 @@
package xiaozhi.modules.sys.dto;
import lombok.Data;
import xiaozhi.modules.sys.enums.ServerActionResponseEnum;
import java.util.Map;
/**
* 服务端动作响应体
*/
@Data
public class ServerActionResponseDTO
{
private ServerActionResponseEnum status;
private String message;
private String type;
private Map<String, Object> content; // 后续这个字段可以移除,并把这个类作为基类,针对业务写自己的content类型
public static final String DEFAULT_TYPE_FORM_SERVER = "server";
public static Boolean isSuccess(ServerActionResponseDTO actionResponseDTO) {
System.out.println(actionResponseDTO);
if (actionResponseDTO == null) {
return false;
}
if (actionResponseDTO.getStatus() == null || !actionResponseDTO.getStatus().equals(ServerActionResponseEnum.SUCCESS)) {
return false;
}
Object actionType = actionResponseDTO.getContent().get("action");
if (actionType == null) {
return false;
}
return actionResponseDTO.getType() != null && actionResponseDTO.getType().equals(DEFAULT_TYPE_FORM_SERVER);
}
}
@@ -0,0 +1,40 @@
package xiaozhi.modules.sys.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import xiaozhi.common.exception.RenException;
/**
* 服务端动作枚举
*/
public enum ServerActionEnum
{
RESTART("restart"),
UPDATE_CONFIG("update_config");
private final String value;
ServerActionEnum(String value)
{
this.value = value;
}
@JsonValue
public String getValue()
{
return value;
}
@JsonCreator
public static ServerActionEnum fromValue(String value)
{
for (ServerActionEnum action : ServerActionEnum.values())
{
if (action.value.equalsIgnoreCase(value))
{
return action;
}
}
return null;
}
}
@@ -0,0 +1,46 @@
package xiaozhi.modules.sys.enums;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
/**
* 服务端调用响应枚举
*/
public enum ServerActionResponseEnum
{
SUCCESS("success"), FAIL("fail");
private final String value;
ServerActionResponseEnum(String value) {
this.value = value;
}
@JsonValue
public String getValue()
{
return value;
}
@JsonCreator
public static ServerActionResponseEnum fromValue(String value) {
ServerActionResponseEnum byValue = getByValue(value);
if (byValue == null) {
throw new IllegalArgumentException("Unknown enum value: " + value);
}
return byValue;
}
public static ServerActionResponseEnum getByValue(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
for (ServerActionResponseEnum action : ServerActionResponseEnum.values()) {
if (action.value.equals(value)) {
return action;
}
}
return null;
}
}
@@ -1,5 +1,6 @@
package xiaozhi.modules.sys.service.impl; package xiaozhi.modules.sys.service.impl;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -55,7 +56,9 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
QueryWrapper<SysParamsEntity> wrapper = new QueryWrapper<>(); QueryWrapper<SysParamsEntity> wrapper = new QueryWrapper<>();
wrapper.eq("param_type", 1); wrapper.eq("param_type", 1);
wrapper.like(StringUtils.isNotBlank(paramCode), "param_code", paramCode); wrapper.nested(StringUtils.isNotBlank(paramCode), i -> i.like("param_code", paramCode)
.or()
.like("remark", paramCode));
return wrapper; return wrapper;
} }
@@ -82,7 +85,7 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void update(SysParamsDTO dto) { public void update(SysParamsDTO dto) {
validateParamValue(dto); validateParamValue(dto);
detectingSMSParameters(dto.getParamCode(), dto.getParamValue());
SysParamsEntity entity = ConvertUtils.sourceToTarget(dto, SysParamsEntity.class); SysParamsEntity entity = ConvertUtils.sourceToTarget(dto, SysParamsEntity.class);
updateById(entity); updateById(entity);
@@ -204,4 +207,41 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
updateValueByCode(Constant.SERVER_SECRET, newSecret); updateValueByCode(Constant.SERVER_SECRET, newSecret);
} }
} }
/**
* 检测短信参数是否符合要求
*
* @param paramCode 参数编码
* @param paramValue 参数值
* @return 是否通过
*/
private boolean detectingSMSParameters(String paramCode, String paramValue) {
// 判断是否是开启手机注册的参数编码,如果不是参数编码,着不需要检测其他短信参数,直接返回true
if (!Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue().equals(paramCode)) {
return true;
}
// 判断是否为关闭,如果是关闭短信注册,着不需要检测其他短信参数,直接返回true
if ("false".equalsIgnoreCase(paramValue)) {
return true;
}
// 检测短信关联参数是否为空
ArrayList<String> list = new ArrayList<String>();
list.add(Constant.SysMSMParam.SERVER_SMS_MAX_SEND_COUNT.getValue());
list.add(Constant.SysMSMParam.ALIYUN_SMS_ACCESS_KEY_ID.getValue());
list.add(Constant.SysMSMParam.ALIYUN_SMS_ACCESS_KEY_SECRET.getValue());
list.add(Constant.SysMSMParam.ALIYUN_SMS_SIGN_NAME.getValue());
list.add(Constant.SysMSMParam.ALIYUN_SMS_SMS_CODE_TEMPLATE_CODE.getValue());
StringBuilder str = new StringBuilder();
list.forEach(item -> {
if (!StringUtils.isNoneBlank(item)) {
str.append(",").append(item);
}
});
if (!str.isEmpty()) {
String promptStr = "%s这些参数不可以为空";
String substring = str.substring(1, str.length());
throw new RenException(promptStr.formatted(substring));
}
return true;
}
} }
@@ -133,6 +133,10 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void changePasswordDirectly(Long userId, String password) { public void changePasswordDirectly(Long userId, String password) {
// 新密码强度
if (!isStrongPassword(password)) {
throw new RenException(ErrorCode.PASSWORD_WEAK_ERROR);
}
SysUserEntity sysUserEntity = new SysUserEntity(); SysUserEntity sysUserEntity = new SysUserEntity();
sysUserEntity.setId(userId); sysUserEntity.setId(userId);
sysUserEntity.setPassword(PasswordUtils.encode(password)); sysUserEntity.setPassword(PasswordUtils.encode(password));
@@ -159,7 +163,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
params.put(Constant.LIMIT, dto.getLimit()); params.put(Constant.LIMIT, dto.getLimit());
IPage<SysUserEntity> page = baseDao.selectPage( IPage<SysUserEntity> page = baseDao.selectPage(
getPage(params, "id", true), getPage(params, "id", true),
new QueryWrapper<SysUserEntity>().eq(StringUtils.isNotBlank(dto.getMobile()), "username", new QueryWrapper<SysUserEntity>().like(StringUtils.isNotBlank(dto.getMobile()), "username",
dto.getMobile())); dto.getMobile()));
// 循环处理page获取回来的数据,返回需要的字段 // 循环处理page获取回来的数据,返回需要的字段
List<AdminPageUserVO> list = page.getRecords().stream().map(user -> { List<AdminPageUserVO> list = page.getRecords().stream().map(user -> {
@@ -0,0 +1,315 @@
package xiaozhi.modules.sys.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StopWatch;
import org.springframework.web.socket.*;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.AbstractWebSocketHandler;
import xiaozhi.common.utils.DateUtils;
import java.io.Closeable;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Predicate;
/**
* WebSocketClientResource:支持 try-with-resources 模式
*/
@Slf4j
public class WebSocketClientManager implements Closeable
{
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// 全局回调线程池
private static final ExecutorService CALLBACK_EXECUTOR = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), new ThreadFactory() {
private final AtomicInteger cnt = new AtomicInteger();
public Thread newThread(Runnable r)
{
Thread t = new Thread(r, "ws-callback-" + cnt.getAndIncrement());
t.setDaemon(true);
return t;
}
});
private volatile WebSocketSession session;
private final BlockingQueue<String> textMessageQueue;
private final BlockingQueue<byte[]> binaryMessageQueue;
private final CompletableFuture<Void> errorFuture;
private final long maxSessionDuration;
private final TimeUnit maxSessionDurationUnit;
private volatile Consumer<String> onText;
private volatile Consumer<byte[]> onBinary;
private volatile Consumer<Throwable> onError;
private final String uri;
private final WebSocketHttpHeaders headers;
private final long connectTimeout;
private final TimeUnit connectUnit;
private final int queueCapacity;
// 私有构造,仅由 Builder 调用
private WebSocketClientManager(Builder b) {
this.uri = b.uri;
this.headers = b.headers != null ? b.headers : new WebSocketHttpHeaders();
this.connectTimeout = b.connectTimeout;
this.connectUnit = b.connectUnit;
this.maxSessionDuration = b.maxSessionDuration;
this.maxSessionDurationUnit = b.maxSessionDurationUnit;
this.queueCapacity = b.queueCapacity;
this.textMessageQueue = new LinkedBlockingQueue<>(queueCapacity);
this.binaryMessageQueue = new LinkedBlockingQueue<>(queueCapacity);
this.errorFuture = new CompletableFuture<>();
}
public static WebSocketClientManager build(Builder b) throws InterruptedException, ExecutionException, TimeoutException, IOException {
WebSocketClientManager ws = new WebSocketClientManager(b);
StandardWebSocketClient client = new StandardWebSocketClient();
CompletableFuture<WebSocketSession> future = client.execute(ws.new InternalHandler(b.uri), b.headers, URI.create(b.uri));
WebSocketSession sess = future.get(b.connectTimeout, b.connectUnit);
if (sess == null || !sess.isOpen())
{
throw new IOException("握手失败或会话未打开");
}
ws.session = sess;
return ws;
}
/**
* 发送 Text
*/
public void sendText(String text) throws IOException {
session.sendMessage(new TextMessage(text));
}
public void sendBinary(byte[] data) throws IOException {
session.sendMessage(new BinaryMessage(data));
}
public void sendJson(Object payload) throws IOException {
String json = OBJECT_MAPPER.writeValueAsString(payload);
session.sendMessage(new TextMessage(json));
}
private <T> List<T> listenerCustom(
BlockingQueue<T> queue,
Predicate<T> predicate)
throws InterruptedException, TimeoutException, ExecutionException
{
List<T> collected = new ArrayList<>();
long deadline = System.currentTimeMillis() + maxSessionDurationUnit.toMillis(maxSessionDuration);
while (true) {
if (errorFuture.isDone()) {
errorFuture.get();
}
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
throw new TimeoutException("等待批量消息超时");
}
T msg = queue.poll(remaining, TimeUnit.MILLISECONDS);
if (msg == null) {
throw new TimeoutException("等待批量消息超时");
}
collected.add(msg);
if (predicate.test(msg)) {
break;
}
}
close();
return collected;
}
/**
* 同步接收多条消息,直到 predicate 为 true 或超时抛异常;
* @return 返回监听期间的所有消息列表
*/
public List<String> listener(Predicate<String> predicate)
throws InterruptedException, TimeoutException, ExecutionException
{
return listenerCustom(textMessageQueue, predicate);
}
public List<byte[]> listenerBinary(Predicate<byte[]> predicate)
throws InterruptedException, TimeoutException, ExecutionException
{
return listenerCustom(binaryMessageQueue, predicate);
}
/**
* 注册文本回调
*/
public WebSocketClientManager onText(Consumer<String> c) {
this.onText = c;
return this;
}
/**
* 注册二进制回调
*/
public WebSocketClientManager onBinary(Consumer<byte[]> c) {
this.onBinary = c;
return this;
}
/**
* 注册错误回调
*/
public WebSocketClientManager onError(Consumer<Throwable> c) {
this.onError = c;
return this;
}
/**
* 关闭会话,try-with-resources / finally 自动调用
*/
@Override
public void close() {
try {
if (session != null && session.isOpen()) {
session.close(CloseStatus.NORMAL);
}
}
catch (IOException ignored) {}
textMessageQueue.clear();
binaryMessageQueue.clear();
errorFuture.completeExceptionally(new IOException("WebSocket 已关闭"));
}
private class InternalHandler extends AbstractWebSocketHandler {
private final String targetUri;
private final StopWatch stopWatch;
InternalHandler(String targetUri) {
this.targetUri = targetUri;
this.stopWatch = new StopWatch();
}
/**
* 连接建立时回调
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) {
// 保存会话
WebSocketClientManager.this.session = session;
this.stopWatch.start();
log.info("ws连接成功, 目标URI: {}, 连接时间: {}", targetUri, DateUtils.getDateTimeNow(DateUtils.DATE_TIME_MILLIS_PATTERN));
}
/**
* 处理文本消息
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
// 入队
textMessageQueue.offer(payload);
// 回调用户注册的 onText
if (onText != null) {
CALLBACK_EXECUTOR.submit(() -> onText.accept(payload));
}
}
/**
* 处理二进制消息
*/
@Override
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
ByteBuffer buf = message.getPayload();
byte[] data = new byte[buf.remaining()];
buf.get(data);
// 入队
binaryMessageQueue.offer(data);
// 回调用户注册的 onBinary
if (onBinary != null) {
CALLBACK_EXECUTOR.submit(() -> onBinary.accept(data));
}
}
/**
* 传输错误时回调
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
super.handleTransportError(session, exception);
// 保持原有逻辑:完成 errorFuture、回调 onError、关闭会话、异步通知连接失败
errorFuture.completeExceptionally(exception);
if (onError != null) {
CALLBACK_EXECUTOR.submit(() -> onError.accept(exception));
}
session.close(CloseStatus.SERVER_ERROR);
}
/**
* 连接关闭时回调
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
super.afterConnectionClosed(session, status);
if (stopWatch.isRunning()) {
stopWatch.stop();
}
log.info("ws连接关闭, 目标URI: {}, 关闭时间: {}, 连接总时长: {}s",
targetUri, DateUtils.getDateTimeNow(DateUtils.DATE_TIME_MILLIS_PATTERN), DateUtils.millsToSecond(stopWatch.getTotalTimeMillis()));
}
}
public static class Builder {
private String uri; // 目标 WS URI
private long connectTimeout = 3; // 请求连接等待时间
private TimeUnit connectUnit = TimeUnit.SECONDS; // 请求连接等待时间单位
private long maxSessionDuration = 5; // 最大连线时间,默认5秒
private TimeUnit maxSessionDurationUnit = TimeUnit.SECONDS; // 最大连线时间单位
private int queueCapacity = 100; // 消息队列容量
private WebSocketHttpHeaders headers; // 请求头
/**
* 目标 WS URI
*/
public Builder uri(String uri) {
this.uri = Objects.requireNonNull(uri);
return this;
}
public Builder headers(WebSocketHttpHeaders h) {
this.headers = h;
return this;
}
public Builder connectTimeout(long t, TimeUnit u) {
this.connectTimeout = t;
this.connectUnit = u;
return this;
}
public Builder maxSessionDuration(long t, TimeUnit u) {
this.maxSessionDuration = t;
this.maxSessionDurationUnit = u;
return this;
}
public Builder queueCapacity(int c) {
this.queueCapacity = c;
return this;
}
public WebSocketClientManager build() throws InterruptedException, ExecutionException, TimeoutException, IOException {
return WebSocketClientManager.build(this);
}
}
}
@@ -0,0 +1,18 @@
-- 修改自定义TTS接口请求定义
update `ai_model_provider` set `fields` =
'[{"key":"url","label":"服务地址","type":"string"},{"key":"method","label":"请求方式","type":"string"},{"key":"params","label":"请求参数","type":"dict","dict_name":"params"},{"key":"headers","label":"请求头","type":"dict","dict_name":"headers"},{"key":"format","label":"音频格式","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]'
where `id` = 'SYSTEM_TTS_custom';
-- 修改自定义TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '自定义TTS配置说明:
1. 自定义的TTS接口服务,请求参数可自定义,可接入众多TTS服务
2. 以本地部署的KokoroTTS为例
3. 如果只有cpu运行:docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
4. 如果只有gpu运行:docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest
配置说明:
1. 在params中配置请求参数,使用JSON格式
例如KokoroTTS{ "input": "{prompt_text}", "speed": 1, "voice": "zm_yunxi", "stream": true, "download_format": "mp3", "response_format": "mp3", "return_download_link": true }
2. 在headers中配置请求头
3. 设置返回音频格式' WHERE `id` = 'TTS_CustomTTS';
@@ -0,0 +1,65 @@
-- 添加手机短信注册功能的需要的参数
delete from sys_params where id in (108, 109, 110, 111, 112, 113, 114, 115);
delete from sys_params where id in (610, 611, 612, 613);
INSERT INTO sys_params
(id, param_code, param_value, value_type, param_type, remark, creator, create_date, updater, update_date)
VALUES
(108, 'server.name', 'xiaozhi-esp32-server', 'string', 1, '系统名称', NULL, NULL, NULL, NULL),
(109, 'server.beian_icp_num', 'null', 'string', 1, 'icp备案号,填写null则不设置', NULL, NULL, NULL, NULL),
(110, 'server.beian_ga_num', 'null', 'string', 1, '公安备案号,填写null则不设置', NULL, NULL, NULL, NULL),
(111, 'server.enable_mobile_register', 'false', 'boolean', 1, '是否开启手机注册', NULL, NULL, NULL, NULL),
(112, 'server.sms_max_send_count', '10', 'number', 1, '单号码单日最大短信发送条数', NULL, NULL, NULL, NULL),
(610, 'aliyun.sms.access_key_id', '', 'string', 1, '阿里云平台access_key', NULL, NULL, NULL, NULL),
(611, 'aliyun.sms.access_key_secret', '', 'string', 1, '阿里云平台access_key_secret', NULL, NULL, NULL, NULL),
(612, 'aliyun.sms.sign_name', '', 'string', 1, '阿里云短信签名', NULL, NULL, NULL, NULL),
(613, 'aliyun.sms.sms_code_template_code', '', 'string', 1, '阿里云短信模板', NULL, NULL, NULL, NULL);
update sys_params set remark = '是否允许管理员以外的人注册' where param_code = 'server.allow_user_register';
-- 增加手机区域字典
-- 插入固件类型字典类型
delete from `sys_dict_type` where `id` = 102;
INSERT INTO `sys_dict_type` (`id`, `dict_type`, `dict_name`, `remark`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
(102, 'MOBILE_AREA', '手机区域', '手机区域字典', 0, 1, NOW(), 1, NOW());
-- 插入固件类型字典数据
delete from `sys_dict_data` where `dict_type_id` = 102;
INSERT INTO `sys_dict_data` (`id`, `dict_type_id`, `dict_label`, `dict_value`, `remark`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
(102001, 102, '中国大陆', '+86', '中国大陆', 1, 1, NOW(), 1, NOW()),
(102002, 102, '中国香港', '+852', '中国香港', 2, 1, NOW(), 1, NOW()),
(102003, 102, '中国澳门', '+853', '中国澳门', 3, 1, NOW(), 1, NOW()),
(102004, 102, '中国台湾', '+886', '中国台湾', 4, 1, NOW(), 1, NOW()),
(102005, 102, '美国/加拿大', '+1', '美国/加拿大', 5, 1, NOW(), 1, NOW()),
(102006, 102, '英国', '+44', '英国', 6, 1, NOW(), 1, NOW()),
(102007, 102, '法国', '+33', '法国', 7, 1, NOW(), 1, NOW()),
(102008, 102, '意大利', '+39', '意大利', 8, 1, NOW(), 1, NOW()),
(102009, 102, '德国', '+49', '德国', 9, 1, NOW(), 1, NOW()),
(102010, 102, '波兰', '+48', '波兰', 10, 1, NOW(), 1, NOW()),
(102011, 102, '瑞士', '+41', '瑞士', 11, 1, NOW(), 1, NOW()),
(102012, 102, '西班牙', '+34', '西班牙', 12, 1, NOW(), 1, NOW()),
(102013, 102, '丹麦', '+45', '丹麦', 13, 1, NOW(), 1, NOW()),
(102014, 102, '马来西亚', '+60', '马来西亚', 14, 1, NOW(), 1, NOW()),
(102015, 102, '澳大利亚', '+61', '澳大利亚', 15, 1, NOW(), 1, NOW()),
(102016, 102, '印度尼西亚', '+62', '印度尼西亚', 16, 1, NOW(), 1, NOW()),
(102017, 102, '菲律宾', '+63', '菲律宾', 17, 1, NOW(), 1, NOW()),
(102018, 102, '新西兰', '+64', '新西兰', 18, 1, NOW(), 1, NOW()),
(102019, 102, '新加坡', '+65', '新加坡', 19, 1, NOW(), 1, NOW()),
(102020, 102, '泰国', '+66', '泰国', 20, 1, NOW(), 1, NOW()),
(102021, 102, '日本', '+81', '日本', 21, 1, NOW(), 1, NOW()),
(102022, 102, '韩国', '+82', '韩国', 22, 1, NOW(), 1, NOW()),
(102023, 102, '越南', '+84', '越南', 23, 1, NOW(), 1, NOW()),
(102024, 102, '印度', '+91', '印度', 24, 1, NOW(), 1, NOW()),
(102025, 102, '巴基斯坦', '+92', '巴基斯坦', 25, 1, NOW(), 1, NOW()),
(102026, 102, '尼日利亚', '+234', '尼日利亚', 26, 1, NOW(), 1, NOW()),
(102027, 102, '孟加拉国', '+880', '孟加拉国', 27, 1, NOW(), 1, NOW()),
(102028, 102, '沙特阿拉伯', '+966', '沙特阿拉伯', 28, 1, NOW(), 1, NOW()),
(102029, 102, '阿联酋', '+971', '阿联酋', 29, 1, NOW(), 1, NOW()),
(102030, 102, '巴西', '+55', '巴西', 30, 1, NOW(), 1, NOW()),
(102031, 102, '墨西哥', '+52', '墨西哥', 31, 1, NOW(), 1, NOW()),
(102032, 102, '智利', '+56', '智利', 32, 1, NOW(), 1, NOW()),
(102033, 102, '阿根廷', '+54', '阿根廷', 33, 1, NOW(), 1, NOW()),
(102034, 102, '埃及', '+20', '埃及', 34, 1, NOW(), 1, NOW()),
(102035, 102, '南非', '+27', '南非', 35, 1, NOW(), 1, NOW()),
(102036, 102, '肯尼亚', '+254', '肯尼亚', 36, 1, NOW(), 1, NOW()),
(102037, 102, '坦桑尼亚', '+255', '坦桑尼亚', 37, 1, NOW(), 1, NOW()),
(102038, 102, '哈萨克斯坦', '+7', '哈萨克斯坦', 38, 1, NOW(), 1, NOW());
@@ -0,0 +1,3 @@
-- 更新ai_model_provider的fields字段,将type为dict的改为string
update ai_model_provider set fields = replace(fields, '"type": "dict"', '"type": "string"') where id not in ('SYSTEM_LLM_fastgpt', 'SYSTEM_TTS_custom');
update ai_model_provider set fields = replace(fields, '"type":"dict"', '"type": "string"') where id not in ('SYSTEM_LLM_fastgpt', 'SYSTEM_TTS_custom');
@@ -142,3 +142,24 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202505142037.sql path: classpath:db/changelog/202505142037.sql
- changeSet:
id: 202505182234
author: amen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505182234.sql
- changeSet:
id: 202505201744
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505201744.sql
- changeSet:
id: 202505151451
author: hsoftxl
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505151451.sql
@@ -9,6 +9,7 @@
10005=\u8D26\u53F7\u5DF2\u88AB\u505C\u7528 10005=\u8D26\u53F7\u5DF2\u88AB\u505C\u7528
10006=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A 10006=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
10007=\u9A8C\u8BC1\u7801\u4E0D\u6B63\u786E 10007=\u9A8C\u8BC1\u7801\u4E0D\u6B63\u786E
10008=\u624B\u673A\u53F7\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
10009=\u539F\u5BC6\u7801\u4E0D\u6B63\u786E 10009=\u539F\u5BC6\u7801\u4E0D\u6B63\u786E
10010=\u8D26\u53F7\u6216\u5BC6\u7801\u4E0D\u6B63\u786E,\u60A8\u8FD8\u6709\u53EF\u4EE5\u5C1D\u8BD5{0}\u6B21 10010=\u8D26\u53F7\u6216\u5BC6\u7801\u4E0D\u6B63\u786E,\u60A8\u8FD8\u6709\u53EF\u4EE5\u5C1D\u8BD5{0}\u6B21
10011=\u4E0A\u7EA7\u90E8\u95E8\u9009\u62E9\u9519\u8BEF 10011=\u4E0A\u7EA7\u90E8\u95E8\u9009\u62E9\u9519\u8BEF
@@ -0,0 +1 @@
redis.call('FLUSHALL')
@@ -0,0 +1,11 @@
local value = redis.call('GET', KEYS[1])
-- value 如果为空着设置值
if not value then
local result = redis.call('SET', KEYS[1], ARGV[1])
-- 检查 ARGV[2] 是否存在且大于 0
local expireTime = tonumber(ARGV[2])
if expireTime and expireTime > 0 then
redis.call('EXPIRE', KEYS[1], expireTime)
end
end
return value
@@ -0,0 +1,58 @@
package xiaozhi.modules.sys;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.modules.security.controller.LoginController;
import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.dto.SmsVerificationDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
@Slf4j
@SpringBootTest
@ActiveProfiles("dev")
class loginControllerTest {
@Autowired
LoginController loginController;
@Test
public void testRegister() {
LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("手机号码");
loginDTO.setPassword("密码");
loginDTO.setCaptcha("123456");
loginController.register(loginDTO);
}
@Test
public void testSmsVerification() {
try {
SmsVerificationDTO smsVerificationDTO = new SmsVerificationDTO();
smsVerificationDTO.setPhone("手机号码");
smsVerificationDTO.setCaptchaId("123456");
smsVerificationDTO.setCaptcha("123456");
loginController.smsVerification(smsVerificationDTO);
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
@Test
public void testRetrievePassword() {
try {
RetrievePasswordDTO retrievePasswordDTO = new RetrievePasswordDTO();
retrievePasswordDTO.setCode("123456");
retrievePasswordDTO.setPhone("手机号码");
retrievePasswordDTO.setPassword("密码");
loginController.retrievePassword(retrievePasswordDTO);
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
+17 -8
View File
@@ -20,6 +20,7 @@ function sendRequest() {
return { return {
_sucCallback: null, _sucCallback: null,
_failCallback: null, _failCallback: null,
_networkFailCallback: null,
_method: 'GET', _method: 'GET',
_data: {}, _data: {},
_header: { 'content-type': 'application/json; charset=utf-8' }, _header: { 'content-type': 'application/json; charset=utf-8' },
@@ -36,7 +37,7 @@ function sendRequest() {
headers: this._header, headers: this._header,
responseType: this._responseType responseType: this._responseType
}).then((res) => { }).then((res) => {
const error = httpHandlerError(res, this._failCallback); const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
if (error) { if (error) {
return return
} }
@@ -47,7 +48,7 @@ function sendRequest() {
}).catch((res) => { }).catch((res) => {
// 打印失败响应 // 打印失败响应
console.log('catch', res) console.log('catch', res)
httpHandlerError(res, this._failCallback) httpHandlerError(res, this._failCallback, this._networkFailCallback)
}) })
return this return this
}, },
@@ -59,6 +60,10 @@ function sendRequest() {
this._failCallback = callback this._failCallback = callback
return this return this
}, },
'networkFail'(callback) {
this._networkFailCallback = callback
return this
},
'url'(url) { 'url'(url) {
if (url) { if (url) {
url = url.replaceAll('$', '/') url = url.replaceAll('$', '/')
@@ -95,11 +100,11 @@ function sendRequest() {
/** /**
* Info 请求完成后返回信息 * Info 请求完成后返回信息
* callBack 回调函数 * failCallback 回调函数
* errTip 自定义错误信息 * networkFailCallback 回调函数
*/ */
// 在错误处理函数中添加日志 // 在错误处理函数中添加日志
function httpHandlerError(info, callBack) { function httpHandlerError(info, failCallback, networkFailCallback) {
/** 请求成功,退出该函数 可以根据项目需求来判断是否请求成功。这里判断的是status为200的时候是成功 */ /** 请求成功,退出该函数 可以根据项目需求来判断是否请求成功。这里判断的是status为200的时候是成功 */
let networkError = false let networkError = false
@@ -111,12 +116,16 @@ function httpHandlerError(info, callBack) {
goToPage(Constant.PAGE.LOGIN, true); goToPage(Constant.PAGE.LOGIN, true);
return true return true
} else { } else {
showDanger(info.data.msg) if (failCallback) {
failCallback(info)
} else {
showDanger(info.data.msg)
}
return true return true
} }
} }
if (callBack) { if (networkFailCallback) {
callBack(info) networkFailCallback(info)
} else { } else {
showDanger(`网络请求出现了错误【${info.status}`) showDanger(`网络请求出现了错误【${info.status}`)
} }
+39 -7
View File
@@ -18,7 +18,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('请求失败:', err) console.error('请求失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getUserList(callback) this.getUserList(callback)
@@ -34,7 +34,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('删除失败:', err) console.error('删除失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.deleteUser(id, callback) this.deleteUser(id, callback)
@@ -50,7 +50,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('重置密码失败:', err) console.error('重置密码失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.resetUserPassword(id, callback) this.resetUserPassword(id, callback)
@@ -72,7 +72,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取参数列表失败:', err) console.error('获取参数列表失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getParamsList(params, callback) this.getParamsList(params, callback)
@@ -89,7 +89,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('添加参数失败:', err) console.error('添加参数失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.addParam(data, callback) this.addParam(data, callback)
@@ -106,7 +106,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('更新参数失败:', err) console.error('更新参数失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.updateParam(data, callback) this.updateParam(data, callback)
@@ -123,12 +123,44 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('删除参数失败:', err) console.error('删除参数失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.deleteParam(ids, callback) this.deleteParam(ids, callback)
}) })
}).send() }).send()
}, },
// 获取ws服务端列表
getWsServerList(params, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/server/server-list`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.networkFail((err) => {
console.error('获取ws服务端列表失败:', err)
RequestService.reAjaxFun(() => {
this.getWsServerList(params, callback)
})
}).send();
},
// 发送ws服务器动作指令
sendWsServerAction(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/server/emit-action`)
.method('POST')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.networkFail((err) => {
RequestService.reAjaxFun(() => {
this.sendWsServerAction(data, callback)
})
}).send();
}
} }
+9 -9
View File
@@ -12,7 +12,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getAgentList(callback); this.getAgentList(callback);
}); });
@@ -28,7 +28,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.addAgent(agentName, callback); this.addAgent(agentName, callback);
}); });
@@ -43,7 +43,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.deleteAgent(agentId, callback); this.deleteAgent(agentId, callback);
}); });
@@ -58,7 +58,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取配置失败:', err); console.error('获取配置失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getDeviceConfig(agentId, callback); this.getDeviceConfig(agentId, callback);
@@ -75,7 +75,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.updateAgentConfig(agentId, configData, callback); this.updateAgentConfig(agentId, configData, callback);
}); });
@@ -90,7 +90,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取模板失败:', err); console.error('获取模板失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getAgentTemplate(callback); this.getAgentTemplate(callback);
@@ -107,7 +107,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getAgentSessions(agentId, params, callback); this.getAgentSessions(agentId, params, callback);
}); });
@@ -122,7 +122,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getAgentChatHistory(agentId, sessionId, callback); this.getAgentChatHistory(agentId, sessionId, callback);
}); });
@@ -137,7 +137,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getAudioId(audioId, callback); this.getAudioId(audioId, callback);
}); });
+4 -4
View File
@@ -11,7 +11,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取设备列表失败:', err); console.error('获取设备列表失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getAgentBindDevices(agentId, callback); this.getAgentBindDevices(agentId, callback);
@@ -28,7 +28,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('解绑设备失败:', err); console.error('解绑设备失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.unbindDevice(device_id, callback); this.unbindDevice(device_id, callback);
@@ -44,7 +44,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('绑定设备失败:', err); console.error('绑定设备失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.bindDevice(agentId, deviceCode, callback); this.bindDevice(agentId, deviceCode, callback);
@@ -59,7 +59,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('更新OTA状态失败:', err) console.error('更新OTA状态失败:', err)
this.$message.error(err.msg || '更新OTA状态失败') this.$message.error(err.msg || '更新OTA状态失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
+11 -11
View File
@@ -18,7 +18,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取字典类型列表失败:', err) console.error('获取字典类型列表失败:', err)
this.$message.error(err.msg || '获取字典类型列表失败') this.$message.error(err.msg || '获取字典类型列表失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -36,7 +36,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取字典类型详情失败:', err) console.error('获取字典类型详情失败:', err)
this.$message.error(err.msg || '获取字典类型详情失败') this.$message.error(err.msg || '获取字典类型详情失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -55,7 +55,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('新增字典类型失败:', err) console.error('新增字典类型失败:', err)
this.$message.error(err.msg || '新增字典类型失败') this.$message.error(err.msg || '新增字典类型失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -74,7 +74,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('更新字典类型失败:', err) console.error('更新字典类型失败:', err)
this.$message.error(err.msg || '更新字典类型失败') this.$message.error(err.msg || '更新字典类型失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -93,7 +93,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('删除字典类型失败:', err) console.error('删除字典类型失败:', err)
this.$message.error(err.msg || '删除字典类型失败') this.$message.error(err.msg || '删除字典类型失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -119,7 +119,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取字典数据列表失败:', err) console.error('获取字典数据列表失败:', err)
this.$message.error(err.msg || '获取字典数据列表失败') this.$message.error(err.msg || '获取字典数据列表失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -137,7 +137,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取字典数据详情失败:', err) console.error('获取字典数据详情失败:', err)
this.$message.error(err.msg || '获取字典数据详情失败') this.$message.error(err.msg || '获取字典数据详情失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -156,7 +156,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('新增字典数据失败:', err) console.error('新增字典数据失败:', err)
this.$message.error(err.msg || '新增字典数据失败') this.$message.error(err.msg || '新增字典数据失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -175,7 +175,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('更新字典数据失败:', err) console.error('更新字典数据失败:', err)
this.$message.error(err.msg || '更新字典数据失败') this.$message.error(err.msg || '更新字典数据失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -194,7 +194,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('删除字典数据失败:', err) console.error('删除字典数据失败:', err)
this.$message.error(err.msg || '删除字典数据失败') this.$message.error(err.msg || '删除字典数据失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -217,7 +217,7 @@ export default {
reject(new Error(res.data?.msg || '获取字典数据列表失败')) reject(new Error(res.data?.msg || '获取字典数据列表失败'))
} }
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取字典数据列表失败:', err) console.error('获取字典数据列表失败:', err)
reject(err) reject(err)
}).send() }).send()
+131 -24
View File
@@ -18,7 +18,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取模型列表失败:', err) console.error('获取模型列表失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getModelList(params, callback) this.getModelList(params, callback)
@@ -34,7 +34,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res.data?.data || []) callback(res.data?.data || [])
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取供应器列表失败:', err) console.error('获取供应器列表失败:', err)
this.$message.error('获取供应器列表失败') this.$message.error('获取供应器列表失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -65,7 +65,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('新增模型失败:', err) console.error('新增模型失败:', err)
this.$message.error(err.msg || '新增模型失败') this.$message.error(err.msg || '新增模型失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -82,7 +82,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('删除模型失败:', err) console.error('删除模型失败:', err)
this.$message.error(err.msg || '删除模型失败') this.$message.error(err.msg || '删除模型失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -100,7 +100,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getModelNames(modelType, modelName, callback); this.getModelNames(modelType, modelName, callback);
}); });
@@ -118,7 +118,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail(() => { .networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getModelVoices(modelId, voiceName, callback); this.getModelVoices(modelId, voiceName, callback);
}); });
@@ -133,7 +133,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取模型配置失败:', err) console.error('获取模型配置失败:', err)
this.$message.error(err.msg || '获取模型配置失败') this.$message.error(err.msg || '获取模型配置失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -150,7 +150,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('更新模型状态失败:', err) console.error('更新模型状态失败:', err)
this.$message.error(err.msg || '更新模型状态失败') this.$message.error(err.msg || '更新模型状态失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
@@ -166,20 +166,20 @@ export default {
configJson: formData.configJson configJson: formData.configJson
}; };
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/models/${modelType}/${provideCode}/${id}`) .url(`${getServiceUrl()}/models/${modelType}/${provideCode}/${id}`)
.method('PUT') .method('PUT')
.data(payload) .data(payload)
.success((res) => { .success((res) => {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('更新模型失败:', err); console.error('更新模型失败:', err);
this.$message.error(err.msg || '更新模型失败'); this.$message.error(err.msg || '更新模型失败');
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.updateModel(params, callback); this.updateModel(params, callback);
}); });
}).send(); }).send();
}, },
// 设置默认模型 // 设置默认模型
setDefaultModel(id, callback) { setDefaultModel(id, callback) {
@@ -190,12 +190,119 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('设置默认模型失败:', err) console.error('设置默认模型失败:', err)
this.$message.error(err.msg || '设置默认模型失败') this.$message.error(err.msg || '设置默认模型失败')
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.setDefaultModel(id, callback) this.setDefaultModel(id, callback)
}) })
}).send() }).send()
} },
/**
* 获取模型配置列表(支持查询参数)
* @param {Object} params - 查询参数对象,例如 { name: 'test', modelType: 1 }
* @param {Function} callback - 回调函数
*/
getModelProvidersPage(params, callback) {
// 构建查询参数
const queryParams = new URLSearchParams();
if (params.name) queryParams.append('name', params.name);
if (params.modelType !== undefined) queryParams.append('modelType', params.modelType);
if (params.page !== undefined) queryParams.append('page', params.page);
if (params.limit !== undefined) queryParams.append('limit', params.limit);
RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider?${queryParams.toString()}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
this.$message.error(err.msg || '获取供应器列表失败');
RequestService.reAjaxFun(() => {
this.getModelProviders(params, callback);
});
}).send();
},
/**
* 新增模型供应器配置
* @param {Object} params - 请求参数对象,例如 { modelType: '1', providerCode: '1', name: '1', fields: '1', sort: 1 }
* @param {Function} callback - 成功回调函数
*/
addModelProvider(params, callback) {
const postData = {
modelType: params.modelType || '',
providerCode: params.providerCode || '',
name: params.name || '',
fields: JSON.stringify(params.fields || []),
sort: params.sort || 0
};
RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider`)
.method('POST')
.data(postData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
console.error('新增模型供应器失败:', err)
this.$message.error(err.msg || '新增模型供应器失败')
RequestService.reAjaxFun(() => {
this.addModelProvider(params, callback);
});
}).send();
},
/**
* 更新模型供应器配置
* @param {Object} params - 请求参数对象,例如 { id: '111', modelType: '1', providerCode: '1', name: '1', fields: '1', sort: 1 }
* @param {Function} callback - 成功回调函数
*/
updateModelProvider(params, callback) {
const putData = {
id: params.id || '',
modelType: params.modelType || '',
providerCode: params.providerCode || '',
name: params.name || '',
fields: JSON.stringify(params.fields || []),
sort: params.sort || 0
};
RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider`)
.method('PUT')
.data(putData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
this.$message.error(err.msg || '更新模型供应器失败')
RequestService.reAjaxFun(() => {
this.updateModelProvider(params, callback);
});
}).send();
},
// 删除
deleteModelProviderByIds(ids, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider/delete`)
.method('POST')
.data(ids)
.success((res) => {
RequestService.clearRequestTime()
callback(res);
})
.networkFail((err) => {
this.$message.error(err.msg || '删除模型供应器失败')
RequestService.reAjaxFun(() => {
this.deleteModelProviderByIds(ids, callback)
})
}).send()
},
} }
+7 -7
View File
@@ -12,7 +12,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取OTA固件列表失败:', err); console.error('获取OTA固件列表失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getOtaList(params, callback); this.getOtaList(params, callback);
@@ -28,7 +28,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取OTA固件信息失败:', err); console.error('获取OTA固件信息失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getOtaInfo(id, callback); this.getOtaInfo(id, callback);
@@ -45,7 +45,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('保存OTA固件信息失败:', err); console.error('保存OTA固件信息失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.saveOta(entity, callback); this.saveOta(entity, callback);
@@ -62,7 +62,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('更新OTA固件信息失败:', err); console.error('更新OTA固件信息失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.updateOta(id, entity, callback); this.updateOta(id, entity, callback);
@@ -78,7 +78,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('删除OTA固件失败:', err); console.error('删除OTA固件失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.deleteOta(id, callback); this.deleteOta(id, callback);
@@ -97,7 +97,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('上传固件文件失败:', err); console.error('上传固件文件失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.uploadFirmware(file, callback); this.uploadFirmware(file, callback);
@@ -113,7 +113,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取下载链接失败:', err); console.error('获取下载链接失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getDownloadUrl(id, callback); this.getDownloadUrl(id, callback);
+5 -5
View File
@@ -1,4 +1,4 @@
import {getServiceUrl} from '../api'; import { getServiceUrl } from '../api';
import RequestService from '../httpRequest'; import RequestService from '../httpRequest';
export default { export default {
@@ -18,7 +18,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res.data || []); callback(res.data || []);
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取音色列表失败:', err); console.error('获取音色列表失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getVoiceList(params, callback); this.getVoiceList(params, callback);
@@ -42,7 +42,7 @@ export default {
.success((res) => { .success((res) => {
callback(res.data); callback(res.data);
}) })
.fail((err) => { .networkFail((err) => {
console.error('保存音色失败:', err); console.error('保存音色失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.saveVoice(params, callback); this.saveVoice(params, callback);
@@ -59,7 +59,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('删除音色失败:', err); console.error('删除音色失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.deleteVoice(ids, callback); this.deleteVoice(ids, callback);
@@ -82,7 +82,7 @@ export default {
.success((res) => { .success((res) => {
callback(res.data); callback(res.data);
}) })
.fail((err) => { .networkFail((err) => {
console.error('修改音色失败:', err); console.error('修改音色失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.updateVoice(params, callback); this.updateVoice(params, callback);
+65 -10
View File
@@ -4,7 +4,7 @@ import RequestService from '../httpRequest'
export default { export default {
// 登录 // 登录
login(loginForm, callback) { login(loginForm, callback, failCallback) {
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/user/login`) .url(`${getServiceUrl()}/user/login`)
.method('POST') .method('POST')
@@ -13,7 +13,11 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail(() => { .fail((err) => {
RequestService.clearRequestTime()
failCallback(err)
})
.networkFail(() => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.login(loginForm, callback) this.login(loginForm, callback)
}) })
@@ -34,12 +38,32 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { // 添加错误参数 .networkFail((err) => { // 添加错误参数
}).send() }).send()
}, },
// 发送短信验证码
sendSmsVerification(data, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/user/smsVerification`)
.method('POST')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
RequestService.clearRequestTime()
failCallback(err)
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.sendSmsVerification(data, callback, failCallback)
})
}).send()
},
// 注册账号 // 注册账号
register(registerForm, callback) { register(registerForm, callback, failCallback) {
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/user/register`) .url(`${getServiceUrl()}/user/register`)
.method('POST') .method('POST')
@@ -48,7 +72,14 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail(() => { .fail((err) => {
RequestService.clearRequestTime()
failCallback(err)
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.register(registerForm, callback, failCallback)
})
}).send() }).send()
}, },
// 保存设备配置 // 保存设备配置
@@ -61,7 +92,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('保存配置失败:', err); console.error('保存配置失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.saveDeviceConfig(device_id, configData, callback); this.saveDeviceConfig(device_id, configData, callback);
@@ -77,7 +108,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
callback(res) callback(res)
}) })
.fail((err) => { .networkFail((err) => {
console.error('接口请求失败:', err) console.error('接口请求失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getUserInfo(callback) this.getUserInfo(callback)
@@ -97,7 +128,7 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
successCallback(res); successCallback(res);
}) })
.fail((error) => { .networkFail((error) => {
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.changePassword(oldPassword, newPassword, successCallback, errorCallback); this.changePassword(oldPassword, newPassword, successCallback, errorCallback);
}); });
@@ -115,7 +146,7 @@ export default {
RequestService.clearRequestTime() RequestService.clearRequestTime()
successCallback(res); successCallback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('修改用户状态失败:', err) console.error('修改用户状态失败:', err)
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.changeUserStatus(status, userIds) this.changeUserStatus(status, userIds)
@@ -131,11 +162,35 @@ export default {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .networkFail((err) => {
console.error('获取公共配置失败:', err); console.error('获取公共配置失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getPubConfig(callback); this.getPubConfig(callback);
}); });
}).send(); }).send();
}, },
// 找回用户密码
retrievePassword(passwordData, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/user/retrieve-password`)
.method('PUT')
.data({
phone: passwordData.phone,
code: passwordData.code,
password: passwordData.password
})
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
RequestService.clearRequestTime();
failCallback(err);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.retrievePassword(passwordData, callback, failCallback);
});
}).send()
}
} }
@@ -28,7 +28,7 @@
</div> </div>
<div class="settings-btn" @click="handleChatHistory" <div class="settings-btn" @click="handleChatHistory"
:class="{ 'disabled-btn': device.memModelId === 'Memory_nomem' }"> :class="{ 'disabled-btn': device.memModelId === 'Memory_nomem' }">
<el-tooltip v-if="device.memModelId === 'Memory_nomem'" content="开启记忆" placement="top"> <el-tooltip v-if="device.memModelId === 'Memory_nomem'" content="请先在“配置角色”界面开启记忆" placement="top">
<span>聊天记录</span> <span>聊天记录</span>
</el-tooltip> </el-tooltip>
<span v-else>聊天记录</span> <span v-else>聊天记录</span>
+15 -2
View File
@@ -35,10 +35,11 @@
OTA管理 OTA管理
</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' }" @visible-change="handleParamDropdownVisibleChange"> :class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' }"
@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' ? 'brightness(0) invert(1)' : 'None' }" /> :style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数字典 参数字典
<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>
@@ -52,6 +53,9 @@
<el-dropdown-item @click.native="goProviderManagement"> <el-dropdown-item @click.native="goProviderManagement">
供应器管理 供应器管理
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item @click.native="goServerSideManagement">
服务端管理
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
</div> </div>
@@ -145,6 +149,9 @@ export default {
goProviderManagement() { goProviderManagement() {
this.$router.push('/provider-management') this.$router.push('/provider-management')
}, },
goServerSideManagement() {
this.$router.push('/server-side-management')
},
// 获取用户信息 // 获取用户信息
fetchUserInfo() { fetchUserInfo() {
userApi.getUserInfo(({ data }) => { userApi.getUserInfo(({ data }) => {
@@ -322,6 +329,12 @@ export default {
line-height: 30px; line-height: 30px;
} }
.custom-search-input::v-deep .el-input__suffix-inner {
display: flex;
align-items: center;
height: 100%;
}
.avatar-img { .avatar-img {
width: 21px; width: 21px;
height: 21px; height: 21px;
@@ -66,9 +66,12 @@
<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;">
<el-input v-model="form.configJson[field.prop]" :placeholder="field.placeholder" :type="field.type" <template v-if="field.type === 'json-textarea'">
class="custom-input-bg" :show-password="field.type === 'password'"> <el-input v-model="fieldJsonMap[field.prop]" type="textarea" :rows="3" placeholder="请输入JSON格式变量(示例:{'key':'value'})"
</el-input> class="custom-input-bg" @change="(val) => handleJsonChange(field.prop, val)"></el-input>
</template>
<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>
</el-form-item> </el-form-item>
</div> </div>
</template> </template>
@@ -112,6 +115,7 @@ export default {
pendingProviderType: null, pendingProviderType: null,
pendingModelData: null, pendingModelData: null,
dynamicCallInfoFields: [], dynamicCallInfoFields: [],
fieldJsonMap: {}, // 用于存储JSON字段的字符串形式
form: { form: {
id: "", id: "",
modelType: "", modelType: "",
@@ -175,9 +179,7 @@ export default {
sort: 0, sort: 0,
configJson: {} configJson: {}
}; };
this.dynamicCallInfoFields.forEach(field => { this.fieldJsonMap = {};
this.$set(this.form.configJson, field.prop, '');
});
}, },
resetProviders() { resetProviders() {
this.providers = []; this.providers = [];
@@ -203,7 +205,14 @@ export default {
handleSave() { handleSave() {
this.saving = true; // 开始保存加载 this.saving = true; // 开始保存加载
const provideCode = this.form.configJson.type; // 处理所有JSON字段
Object.keys(this.fieldJsonMap).forEach(key => {
const parsed = this.validateJson(this.fieldJsonMap[key]);
if (parsed !== null) {
this.form.configJson[key] = parsed;
}
});
const formData = { const formData = {
id: this.modelData.id, id: this.modelData.id,
modelCode: this.form.modelCode, modelCode: this.form.modelCode,
@@ -213,13 +222,11 @@ 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: { configJson: { ...this.form.configJson }
...this.form.configJson,
}
}; };
this.$emit("save", { this.$emit("save", {
provideCode, provideCode: this.form.configJson.type,
formData, formData,
done: () => { done: () => {
this.saving = false; // 保存完成后回调 this.saving = false; // 保存完成后回调
@@ -240,7 +247,6 @@ export default {
value: String(item.providerCode) value: String(item.providerCode)
})); }));
this.providersLoaded = true; this.providersLoaded = true;
this.allProvidersData = data; this.allProvidersData = data;
if (this.pendingProviderType) { if (this.pendingProviderType) {
@@ -255,7 +261,7 @@ export default {
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 === 'password' ? 'password' : 'text', type: f.type === 'dict' ? 'json-textarea' : (f.type === 'password' ? 'password' : 'text'),
placeholder: `请输入${f.label}` placeholder: `请输入${f.label}`
})); }));
@@ -272,6 +278,9 @@ export default {
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') {
this.$set(this.fieldJsonMap, field.prop, this.formatJson(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]);
} }
@@ -287,10 +296,43 @@ 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) {
const parsed = this.validateJson(value);
if (parsed !== null) {
this.form.configJson[field] = parsed;
}
},
validateJson(value) {
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
this.$message.error({
message: '必须输入字典格式(如 {"key":"value"}),保存则使用原数据',
showClose: true
});
return null;
} catch (e) {
this.$message.error({
message: 'JSON格式错误(如 {"key":"value"}),保存则使用原数据',
showClose: true
});
return null;
}
},
formatJson(obj) {
try {
return JSON.stringify(obj, null, 2);
} catch {
return '';
}
},
ensureObject(value) {
return typeof value === 'object' ? value : {};
} }
} }
}; };
@@ -316,7 +358,6 @@ export default {
justify-content: center; justify-content: center;
} }
.custom-close-btn { .custom-close-btn {
position: absolute; position: absolute;
top: 20px; top: 20px;
@@ -452,21 +493,14 @@ export default {
height: 32px; height: 32px;
} }
.custom-form .el-form-item { .custom-form .el-form-item {
margin-bottom: 20px; margin-bottom: 20px;
} }
.custom-input-bg .el-input__inner {
height: 32px;
}
.custom-form .el-form-item__label { .custom-form .el-form-item__label {
color: #3d4566; color: #3d4566;
font-weight: normal; font-weight: normal;
text-align: right; text-align: right;
padding-right: 20px; padding-right: 20px;
} }
</style> </style>
@@ -1,5 +1,6 @@
<template> <template>
<el-dialog :visible="visible" @update:visible="handleVisibleChange" width="57%" center custom-class="custom-dialog" :show-close="false" class="center-dialog"> <el-dialog :visible="visible" @update:visible="handleVisibleChange" width="57%" center custom-class="custom-dialog"
:show-close="false" class="center-dialog">
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;"> <div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -15px; margin-bottom: 20px; text-align: center;"> <div style="font-size: 30px; color: #3d4566; margin-top: -15px; margin-bottom: 20px; text-align: center;">
@@ -10,15 +11,15 @@
<el-form :model="form" label-width="100px" :rules="rules" ref="form" class="custom-form"> <el-form :model="form" label-width="100px" :rules="rules" ref="form" class="custom-form">
<div style="display: flex; gap: 20px; margin-bottom: 20px;"> <div style="display: flex; gap: 20px; margin-bottom: 20px;">
<el-form-item label="类别" prop="model_type" style="flex: 1;"> <el-form-item label="类别" prop="modelType" style="flex: 1;">
<el-select v-model="form.model_type" placeholder="请选择类别" class="custom-input-bg" style="width: 100%;"> <el-select v-model="form.modelType" placeholder="请选择类别" class="custom-input-bg" style="width: 100%;">
<el-option v-for="item in modelTypes" :key="item.value" :label="item.label" :value="item.value"> <el-option v-for="item in modelTypes" :key="item.value" :label="item.label" :value="item.value">
</el-option> </el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="供应器编码" prop="provider_code" style="flex: 1;"> <el-form-item label="供应器编码" prop="providerCode" style="flex: 1;">
<el-input v-model="form.provider_code" placeholder="请输入供应器编码" class="custom-input-bg"></el-input> <el-input v-model="form.providerCode" placeholder="请输入供应器编码" class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</div> </div>
@@ -27,20 +28,24 @@
<el-input v-model="form.name" placeholder="请输入供应器名称" class="custom-input-bg"></el-input> <el-input v-model="form.name" placeholder="请输入供应器名称" class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="排序" prop="sort" style="flex: 1;"> <el-form-item label="排序" prop="sort" style="flex: 1;">
<el-input-number v-model="form.sort" :min="0" controls-position="right" class="custom-input-bg" style="width: 100%;"></el-input-number> <el-input-number v-model="form.sort" :min="0" controls-position="right" class="custom-input-bg"
style="width: 100%;"></el-input-number>
</el-form-item> </el-form-item>
</div> </div>
<div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px;"> <div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px;">
字段配置 字段配置
<div style="display: inline-block; float: right;"> <div style="display: inline-block; float: right;">
<el-button type="primary" @click="addField" size="small" style="background: #5bc98c; border: none;" :disabled="hasIncompleteFields"> <el-button type="primary" @click="addField" size="small" style="background: #5bc98c; border: none;"
:disabled="hasIncompleteFields">
添加 添加
</el-button> </el-button>
<el-button type="primary" @click="toggleSelectAllFields" size="small" style="background: #5f70f3; border: none; margin-left: 10px;"> <el-button type="primary" @click="toggleSelectAllFields" size="small"
style="background: #5f70f3; border: none; margin-left: 10px;">
{{ isAllFieldsSelected ? '取消全选' : '全选' }} {{ isAllFieldsSelected ? '取消全选' : '全选' }}
</el-button> </el-button>
<el-button type="danger" @click="batchRemoveFields" size="small" style="background: red; border: none; margin-left: 10px;"> <el-button type="danger" @click="batchRemoveFields" size="small"
style="background: red; border: none; margin-left: 10px;">
批量删除 批量删除
</el-button> </el-button>
</div> </div>
@@ -135,9 +140,9 @@ export default {
return { return {
saving: false, saving: false,
rules: { rules: {
model_type: [{required: true, message: '请选择类别', trigger: 'change'}], modelType: [{ required: true, message: '请选择类别', trigger: 'change' }],
provider_code: [{required: true, message: '请输入供应器编码', trigger: 'blur'}], providerCode: [{ required: true, message: '请输入供应器编码', trigger: 'blur' }],
name: [{required: true, message: '请输入供应器名称', trigger: 'blur'}] name: [{ required: true, message: '请输入供应器名称', trigger: 'blur' }]
}, },
isAllFieldsSelected: false, isAllFieldsSelected: false,
tableKey: 0 // 用于强制表格重新渲染 tableKey: 0 // 用于强制表格重新渲染
@@ -419,11 +424,12 @@ export default {
display: none; display: none;
} }
.el-table th, .el-table td { .el-table th,
.el-table td {
padding: 8px 0; padding: 8px 0;
} }
.el-button.is-circle{ .el-button.is-circle {
border-radius: 2px; border-radius: 2px;
} }
</style> </style>
@@ -1,23 +1,74 @@
<template> <template>
<div class="copyright"> <div class="copyright">
©2025 xiaozhi-esp32-server v{{ version }} <div class="footer-content">
<span>{{ year }} {{ name }} {{ version }}</span>
<template v-if="beianGaNum !== 'null'">
<span v-if="beianIcpNum !== 'null' || name">|</span>
<a :href="'http://www.beian.gov.cn/portal/registerSystemInfo?recordcode=' + beianGaNum" target="_blank"
rel="noopener" class="beian-link">
<img
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAMAAABhEH5lAAACXlBMVEUAAADax4rGxqP556zmsVD/6KXu5JvwvFPglEFnY3nnpEjosU1+XFTmuVfu13Hrv1jpsFa1k3mne2HlvnPdqmPqz3fu3YrJoWv04J/mz5z246aSi3i2p2/0zGj82WDrtFjar1O7klO4dkfimUJcWnbho0rgr1vntFfmvFflvGOZdFKXZkOUemnmv2XkuWWVe2ziqlXnt1Tr0HDpwl6ObmCkbEKqhVyOc2jdoVHmuljfrmDq0nPapWDUnErqy3XjtWXesXLrzHjz34rqzYfmw3fy4YrqyYDevW/dzoTIhkndsXLRsIneuHjlyIzQroTjuXjlyYfktWvw2prhvoHTmGfx45j3773TuYT/25LdijyKf321oXU+Nm3cumXfuWLtwlzpwVz40FvpvlvqvFfouFXjrlHipEjcmEfknEXPbzfbZyzZQR/YMBrWKBrhJhLdHxDTGg5HV4xBT4YYKYaWk4SDgYIWIYBybH0qLn1KS3tlZHpNS3WGfHIcHXEwLHAnF28rKW07NGuzn2pNQ2ruzWnDrGnuy2jryGjAnGgoH2j1x2RCNGPcrmIsEGGQdl/pu17YtV7FoV3muVxoRVzWqFvSmVp2SVbyw1XbkVTuvFPfo1PgmVPpt1LcnFLWkVLlp1F7W1HpsU92QE5oMUtFAUrpqUjnjUFOAEHUgz+DUj14FzvbczieMTdhATfodjXWcDXcbjXVcTS/Xi/hay7WXS3cWS3vYyvaVSrUVymnJSjXSyeKDSbNSiWfHCPMTyLFQiHkSR/lPxvVLRrPLBnUJRfUHhbaJxXVGxTZJxPNFhBdOhm/AAAAWXRSTlMABQIU/hIJ/v79/Pj29PPx4cC6s7CNfmxZWRv+/v7+/v7+/v78/Pr6+fPz8vHq6ujl5eTj4ODe2NTQzMW6ubKsnJeQkJCJiIOBgX9ubGpoW1lMREM0JR8dDgvYx1gAAAE7SURBVBjTYgADJmZJQUFJZiYGOGD2EzLV1jIT8paCibCJ86zctGPD1D4ecUaICKuPyYLMYznZOfNqzP0jwEJBOt1KB4/mHjqZ3dGsHwxW5M4Zk5l/PPdIQcGMShUPVqBlgQLlMSvyDq+P3HVidjWnkQQTA5MXV1SFYtb+jZGrl02si2J3Y2JgFMmILl68efueLVvXLSqLXirCCBgDow1HT+GS/AP7srblLS+K5bIHCjmodpXUr929d+eaVb3SsuouQCHhufPjShsbJk/rrK2KW8hiBxRyVkvnkI9tnaDQJDNFOU1DDOjRMEfe9Mjpiewz5RIzZmmKMoPcKqGbkJqSkBSfnDqpXS+ACeRpJ76W/uSUpDlpLPFtfK5soLAKFbU05Odm4eblN7YWk4KEGWOIp62FgYCVsG84SAAAL7BaooX965sAAAAASUVORK5CYII="
class="beian-icon" alt="备案图标">
<span class="beian-text">{{ beianGaNum }}</span>
</a>
</template>
<template v-if="beianIcpNum !== 'null'">
<span v-if="name">|</span>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" class="beian-link">
<span class="beian-text">{{ beianIcpNum }}</span>
</a>
</template>
</div> </div>
</div>
</template> </template>
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
export default { export default {
name: 'VersionFooter', name: 'VersionFooter',
computed: { computed: {
...mapState({ ...mapState({
version: state => state.pubConfig.version version: state => state.pubConfig.version,
}) name: state => state.pubConfig.name,
}, beianIcpNum: state => state.pubConfig.beianIcpNum,
mounted() { beianGaNum: state => state.pubConfig.beianGaNum,
this.$store.dispatch('fetchPubConfig'); year: state => state.pubConfig.year
} })
},
mounted() {
this.$store.dispatch('fetchPubConfig')
}
} }
</script> </script>
<style scoped></style> <style scoped>
.copyright {
padding: 10px 0;
}
.footer-content {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.beian-link {
text-decoration: none;
display: inline-flex;
align-items: center;
}
.beian-icon {
width: 16px;
height: 16px;
vertical-align: middle;
}
.beian-text {
color: #000;
font-size: 12px;
vertical-align: middle;
}
</style>
+19
View File
@@ -39,6 +39,13 @@ const routes = [
return import('../views/register.vue') return import('../views/register.vue')
} }
}, },
{
path: '/retrieve-password',
name: 'RetrievePassword',
component: function () {
return import('../views/retrievePassword.vue')
}
},
// 设备管理页面路由 // 设备管理页面路由
{ {
path: '/device-management', path: '/device-management',
@@ -73,6 +80,18 @@ const routes = [
title: '参数管理' title: '参数管理'
} }
}, },
{
path: '/server-side-management',
name: 'ServerSideManager',
component: function () {
return import('../views/ServerSideManager.vue')
},
meta: {
requiresAuth: true,
title: '服务端管理'
}
},
{ {
path: '/ota-management', path: '/ota-management',
name: 'OtaManagement', name: 'OtaManagement',
+2
View File
@@ -13,6 +13,8 @@ export default new Vuex.Store({
isSuperAdmin: false, // 添加superAdmin状态 isSuperAdmin: false, // 添加superAdmin状态
pubConfig: { // 添加公共配置存储 pubConfig: { // 添加公共配置存储
version: '', version: '',
beianIcpNum: 'null',
beianGaNum: 'null',
allowUserRegister: false allowUserRegister: false
} }
}, },
+64
View File
@@ -136,3 +136,67 @@ export function getUUID() {
}) })
} }
/**
* 验证手机号格式
* @param {string} mobile 手机号
* @param {string} areaCode 区号
* @returns {boolean}
*/
export function validateMobile(mobile, areaCode) {
// 移除所有非数字字符
const cleanMobile = mobile.replace(/\D/g, '');
// 根据不同区号使用不同的验证规则
switch (areaCode) {
case '+86': // 中国大陆
return /^1[3-9]\d{9}$/.test(cleanMobile);
case '+852': // 中国香港
return /^[569]\d{7}$/.test(cleanMobile);
case '+853': // 中国澳门
return /^6\d{7}$/.test(cleanMobile);
case '+886': // 中国台湾
return /^9\d{8}$/.test(cleanMobile);
case '+1': // 美国/加拿大
return /^[2-9]\d{9}$/.test(cleanMobile);
case '+44': // 英国
return /^7[1-9]\d{8}$/.test(cleanMobile);
case '+81': // 日本
return /^[7890]\d{8}$/.test(cleanMobile);
case '+82': // 韩国
return /^1[0-9]\d{7}$/.test(cleanMobile);
case '+65': // 新加坡
return /^[89]\d{7}$/.test(cleanMobile);
case '+61': // 澳大利亚
return /^[4578]\d{8}$/.test(cleanMobile);
case '+49': // 德国
return /^1[5-7]\d{8}$/.test(cleanMobile);
case '+33': // 法国
return /^[67]\d{8}$/.test(cleanMobile);
case '+39': // 意大利
return /^3[0-9]\d{8}$/.test(cleanMobile);
case '+34': // 西班牙
return /^[6-9]\d{8}$/.test(cleanMobile);
case '+55': // 巴西
return /^[1-9]\d{10}$/.test(cleanMobile);
case '+91': // 印度
return /^[6-9]\d{9}$/.test(cleanMobile);
case '+971': // 阿联酋
return /^[5]\d{8}$/.test(cleanMobile);
case '+966': // 沙特阿拉伯
return /^[5]\d{8}$/.test(cleanMobile);
case '+880': // 孟加拉国
return /^1[3-9]\d{8}$/.test(cleanMobile);
case '+234': // 尼日利亚
return /^[789]\d{9}$/.test(cleanMobile);
case '+254': // 肯尼亚
return /^[17]\d{8}$/.test(cleanMobile);
case '+255': // 坦桑尼亚
return /^[67]\d{8}$/.test(cleanMobile);
case '+7': // 哈萨克斯坦
return /^[67]\d{9}$/.test(cleanMobile);
default:
// 其他国际号码:至少5位,最多15位
return /^\d{5,15}$/.test(cleanMobile);
}
}
@@ -48,8 +48,7 @@
<el-table ref="dictDataTable" :data="dictDataList" style="width: 100%" <el-table ref="dictDataTable" :data="dictDataList" style="width: 100%"
v-loading="dictDataLoading" element-loading-text="拼命加载中" v-loading="dictDataLoading" element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)" element-loading-background="rgba(255, 255, 255, 0.7)" class="data-table"
class="data-table"
header-row-class-name="table-header"> header-row-class-name="table-header">
<el-table-column label="选择" align="center" width="55"> <el-table-column label="选择" align="center" width="55">
<template slot-scope="scope"> <template slot-scope="scope">
@@ -546,6 +545,7 @@ export default {
:deep(.el-table__body-wrapper) { :deep(.el-table__body-wrapper) {
max-height: calc(var(--table-max-height) - 40px); max-height: calc(var(--table-max-height) - 40px);
overflow-y: auto;
} }
:deep(.el-table__body) { :deep(.el-table__body) {
@@ -5,7 +5,7 @@
<div class="operation-bar"> <div class="operation-bar">
<h2 class="page-title">参数管理</h2> <h2 class="page-title">参数管理</h2>
<div class="right-operations"> <div class="right-operations">
<el-input placeholder="请输入参数编码查询" v-model="searchCode" class="search-input" <el-input placeholder="请输入参数编码或备注查询" v-model="searchCode" class="search-input"
@keyup.enter.native="handleSearch" clearable /> @keyup.enter.native="handleSearch" clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button> <el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div> </div>
+134 -194
View File
@@ -1,13 +1,14 @@
<template> <template>
<div class="welcome"> <div class="welcome">
<HeaderBar/> <HeaderBar />
<div class="operation-bar"> <div class="operation-bar">
<h2 class="page-title">供应器管理</h2> <h2 class="page-title">供应器管理</h2>
<div class="right-operations"> <div class="right-operations">
<el-dropdown trigger="click" @command="handleSelectModelType" @visible-change="handleDropdownVisibleChange"> <el-dropdown trigger="click" @command="handleSelectModelType" @visible-change="handleDropdownVisibleChange">
<el-button class="category-btn"> <el-button class="category-btn">
类别筛选 {{ selectedModelTypeLabel }}<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down':DropdownVisible }"></i> 类别筛选 {{ selectedModelTypeLabel }}<i class="el-icon-arrow-down el-icon--right"
:class="{ 'rotate-down': DropdownVisible }"></i>
</el-button> </el-button>
<el-dropdown-menu slot="dropdown"> <el-dropdown-menu slot="dropdown">
<el-dropdown-item command="">全部</el-dropdown-item> <el-dropdown-item command="">全部</el-dropdown-item>
@@ -16,7 +17,8 @@
</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
<el-input placeholder="请输入供应器名称查询" v-model="searchName" class="search-input" @keyup.enter.native="handleSearch" clearable/> <el-input placeholder="请输入供应器名称查询" v-model="searchName" class="search-input" @keyup.enter.native="handleSearch"
clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button> <el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div> </div>
</div> </div>
@@ -24,37 +26,39 @@
<div class="main-wrapper"> <div class="main-wrapper">
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="params-card" shadow="never"> <el-card class="provider-card" shadow="never">
<el-table ref="providersTable" :data="filteredProvidersList" class="transparent-table" v-loading="loading" <el-table ref="providersTable" :data="filteredProvidersList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading" element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)" element-loading-background="rgba(255, 255, 255, 0.7)" :header-cell-class-name="headerCellClassName">
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<template slot-scope="scope"> <template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox> <el-checkbox v-model="scope.row.selected"></el-checkbox>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="类别" prop="model_type" align="center" width="200">
<el-table-column label="类别" prop="modelType" align="center" width="200">
<template slot="header" slot-scope="scope"> <template slot="header" slot-scope="scope">
<el-dropdown trigger="click" @command="handleSelectModelType" @visible-change="isDropdownOpen = $event"> <el-dropdown trigger="click" @command="handleSelectModelType"
<span class="dropdown-trigger" :class="{ 'active': isDropdownOpen }"> @visible-change="isDropdownOpen = $event">
类别{{ selectedModelTypeLabel }} <i class="dropdown-arrow" :class="{ 'is-active': isDropdownOpen }"></i> <span class="dropdown-trigger" :class="{ 'active': isDropdownOpen }">
</span> 类别{{ selectedModelTypeLabel }} <i class="dropdown-arrow"
<el-dropdown-menu slot="dropdown"> :class="{ 'is-active': isDropdownOpen }"></i>
<el-dropdown-item command="">全部</el-dropdown-item> </span>
<el-dropdown-item v-for="item in modelTypes" :key="item.value" :command="item.value"> <el-dropdown-menu slot="dropdown">
{{ item.label }} <el-dropdown-item command="">全部</el-dropdown-item>
</el-dropdown-item> <el-dropdown-item v-for="item in modelTypes" :key="item.value" :command="item.value">
</el-dropdown-menu> {{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown> </el-dropdown>
</template> </template>
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag :type="getModelTypeTag(scope.row.model_type)"> <el-tag :type="getModelTypeTag(scope.row.modelType)">
{{ getModelTypeLabel(scope.row.model_type) }} {{ getModelTypeLabel(scope.row.modelType) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="供应器编码" prop="provider_code" align="center" width="150"></el-table-column> <el-table-column label="供应器编码" prop="providerCode" align="center" width="150"></el-table-column>
<el-table-column label="名称" prop="name" align="center"></el-table-column> <el-table-column label="名称" prop="name" align="center"></el-table-column>
<el-table-column label="字段配置" align="center"> <el-table-column label="字段配置" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
@@ -97,7 +101,8 @@
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev"> <button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
上一页 上一页
</button> </button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn" :class="{ active: page === currentPage }" @click="goToPage(page)"> <button 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 class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
@@ -112,33 +117,35 @@
</div> </div>
<!-- 新增/编辑供应器对话框 --> <!-- 新增/编辑供应器对话框 -->
<provider-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="providerForm" :model-types="modelTypes" @submit="handleSubmit" @cancel="dialogVisible = false"/> <provider-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="providerForm" :model-types="modelTypes"
@submit="handleSubmit" @cancel="dialogVisible = false" />
<el-footer> <el-footer>
<version-footer/> <version-footer />
</el-footer> </el-footer>
</div> </div>
</template> </template>
<script> <script>
import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import ProviderDialog from "@/components/ProviderDialog.vue"; import ProviderDialog from "@/components/ProviderDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue"; import VersionFooter from "@/components/VersionFooter.vue";
export default { export default {
components: {HeaderBar, ProviderDialog, VersionFooter}, components: { HeaderBar, ProviderDialog, VersionFooter },
data() { data() {
return { return {
searchName: "", searchName: "",
searchModelType: "", searchModelType: "",
providersList: [], providersList: [],
modelTypes: [ modelTypes: [
{value: "ASR", label: "语音识别"}, { value: "ASR", label: "语音识别" },
{value: "TTS", label: "语音合成"}, { value: "TTS", label: "语音合成" },
{value: "LLM", label: "大语言模型"}, { value: "LLM", label: "大语言模型" },
{value: "Intent", label: "意图识别"}, { value: "Intent", label: "意图识别" },
{value: "Memory", label: "记忆模块"}, { value: "Memory", label: "记忆模块" },
{value: "VAD", label: "语音活动检测"} { value: "VAD", label: "语音活动检测" }
], ],
currentPage: 1, currentPage: 1,
loading: false, loading: false,
@@ -152,8 +159,8 @@ export default {
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"],
providerForm: { providerForm: {
id: null, id: null,
model_type: "", modelType: "",
provider_code: "", providerCode: "",
name: "", name: "",
fields: [], fields: [],
sort: 0 sort: 0
@@ -189,123 +196,50 @@ export default {
return pages; return pages;
}, },
filteredProvidersList() { filteredProvidersList() {
let list = this.providersList.filter(item => { return this.providersList;
const nameMatch = item.name.toLowerCase().includes(this.searchName.toLowerCase());
const typeMatch = !this.searchModelType || item.model_type === this.searchModelType;
return nameMatch && typeMatch;
});
list.sort((a, b) => a.sort - b.sort); // let list = this.providersList.filter(item => {
// const nameMatch = item.name.toLowerCase().includes(this.searchName.toLowerCase());
// const typeMatch = !this.searchModelType || item.model_type === this.searchModelType;
// return nameMatch && typeMatch;
// });
// 分页处理 // list.sort((a, b) => a.sort - b.sort);
const start = (this.currentPage - 1) * this.pageSize;
return list.slice(start, start + this.pageSize); // // 分页处理
// const start = (this.currentPage - 1) * this.pageSize;
// return list.slice(start, start + this.pageSize);
} }
}, },
methods: { methods: {
fetchProviders() { fetchProviders() {
this.loading = true; this.loading = true;
// 模拟API请求延迟 Api.model.getModelProvidersPage(
setTimeout(() => { {
this.loading = false; page: this.currentPage,
limit: this.pageSize,
// 模拟数据 - 从数据库结构中提取 name: this.searchName,
this.providersList = [ modelType: this.searchModelType
{ },
id: "SYSTEM_ASR_DoubaoASR", ({ data }) => {
model_type: "ASR", this.loading = false;
provider_code: "doubao", if (data.code === 0) {
name: "火山引擎语音识别", this.providersList = data.data.list.map(item => {
fields: JSON.parse('[{"key": "appid", "type": "string", "label": "应用ID"}, {"key": "access_token", "type": "string", "label": "访问令牌"}, {"key": "cluster", "type": "string", "label": "集群"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'), return {
sort: 3, ...item,
selected: false selected: false,
}, fields: JSON.parse(item.fields)
{ };
id: "SYSTEM_ASR_FunASR", });
model_type: "ASR", this.total = data.data.total;
provider_code: "fun_local", } else {
name: "FunASR语音识别", this.$message.error({
fields: JSON.parse('[{"key": "model_dir", "type": "string", "label": "模型目录"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'), message: data.msg || '获取参数列表失败'
sort: 1, });
selected: false
},
{
id: "SYSTEM_LLM_openai",
model_type: "LLM",
provider_code: "openai",
name: "OpenAI接口",
fields: JSON.parse('[{"key": "base_url", "type": "string", "label": "基础URL"}, {"key": "model_name", "type": "string", "label": "模型名称"}, {"key": "api_key", "type": "string", "label": "API密钥"}, {"key": "temperature", "type": "number", "label": "温度"}, {"key": "max_tokens", "type": "number", "label": "最大令牌数"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_TTS_edge",
model_type: "TTS",
provider_code: "edge",
name: "Edge TTS",
fields: JSON.parse('[{"key": "voice", "type": "string", "label": "音色"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_Memory_mem0ai",
model_type: "Memory",
provider_code: "mem0ai",
name: "Mem0AI记忆",
fields: JSON.parse('[{"key": "api_key", "type": "string", "label": "API密钥"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_VAD_SileroVAD",
model_type: "VAD",
provider_code: "silero",
name: "SileroVAD语音活动检测",
fields: JSON.parse('[{"key": "threshold", "type": "number", "label": "检测阈值"}, {"key": "model_dir", "type": "string", "label": "模型目录"}, {"key": "min_silence_duration_ms", "type": "number", "label": "最小静音时长"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_Intent_nointent",
model_type: "Intent",
provider_code: "nointent",
name: "无意图识别",
fields: [],
sort: 1,
selected: false
},
{
id: "SYSTEM_TTS_aliyun",
model_type: "TTS",
provider_code: "aliyun",
name: "阿里云TTS",
fields: JSON.parse('[{"key": "output_dir", "type": "string", "label": "输出目录"}, {"key": "appkey", "type": "string", "label": "应用密钥"}, {"key": "token", "type": "string", "label": "访问令牌"}, {"key": "voice", "type": "string", "label": "音色"}, {"key": "access_key_id", "type": "string", "label": "访问密钥ID"}, {"key": "access_key_secret", "type": "string", "label": "访问密钥密码"}]'),
sort: 9,
selected: false
},
{
id: "SYSTEM_LLM_coze",
model_type: "LLM",
provider_code: "coze",
name: "Coze接口",
fields: JSON.parse('[{"key": "bot_id", "type": "string", "label": "机器人ID"}, {"key": "user_id", "type": "string", "label": "用户ID"}, {"key": "personal_access_token", "type": "string", "label": "个人访问令牌"}]'),
sort: 6,
selected: false
},
{
id: "SYSTEM_TTS_TencentTTS",
model_type: "TTS",
provider_code: "tencent",
name: "腾讯语音合成",
fields: JSON.parse('[{"key": "appid", "type": "string", "label": "应用ID"}, {"key": "secret_id", "type": "string", "label": "Secret ID"}, {"key": "secret_key", "type": "string", "label": "Secret Key"}, {"key": "output_dir", "type": "string", "label": "输出目录"}, {"key": "region", "type": "string", "label": "区域"}, {"key": "voice", "type": "string", "label": "音色ID"}]'),
sort: 5,
selected: false
} }
]; }
);
this.total = this.providersList.length;
}, 500);
}, },
handleSearch() { handleSearch() {
this.currentPage = 1; this.currentPage = 1;
@@ -326,8 +260,8 @@ export default {
this.dialogTitle = "新增供应器"; this.dialogTitle = "新增供应器";
this.providerForm = { this.providerForm = {
id: null, id: null,
model_type: "", modelType: "",
provider_code: "", providerCode: "",
name: "", name: "",
fields: [], fields: [],
sort: 0 sort: 0
@@ -342,43 +276,36 @@ export default {
}; };
this.dialogVisible = true; this.dialogVisible = true;
}, },
handleSubmit({form, done}) { handleSubmit({ form, done }) {
this.loading = true; this.loading = true;
setTimeout(() => { if (form.id) {
this.loading = false; // 编辑
Api.model.updateModelProvider(form, ({ data }) => {
if (form.id) { if (data.code === 0) {
// 模拟编辑操作 this.fetchProviders(); // 刷新表格
const index = this.providersList.findIndex(p => p.id === form.id);
if (index !== -1) {
this.providersList.splice(index, 1, {
...form,
fields: typeof form.fields === 'string' ? JSON.parse(form.fields) : form.fields
});
this.$message.success({ this.$message.success({
message: "修改成功", message: "修改成功",
showClose: true showClose: true
}); });
} }
} else { });
// 模拟新增操作 } else {
const newId = `SYSTEM_${form.model_type}_${form.provider_code}`; // 新增
this.providersList.unshift({ Api.model.addModelProvider(form, ({ data }) => {
...form, if (data.code === 0) {
id: newId, this.fetchProviders(); // 刷新表格
fields: typeof form.fields === 'string' ? JSON.parse(form.fields) : form.fields, this.$message.success({
selected: false message: "新增成功",
}); showClose: true
this.total += 1; });
this.$message.success({ this.total += 1;
message: "新增成功", }
showClose: true });
}); }
} this.loading = false;
this.dialogVisible = false;
this.dialogVisible = false; done && done();
done && done();
}, 500);
}, },
deleteSelectedProviders() { deleteSelectedProviders() {
const selectedRows = this.providersList.filter(row => row.selected); const selectedRows = this.providersList.filter(row => row.selected);
@@ -398,16 +325,25 @@ export default {
this.$confirm(`确定要删除选中的${providerCount}个供应器吗?`, '警告', { this.$confirm(`确定要删除选中的${providerCount}个供应器吗?`, '警告', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning',
}).then(() => { }).then(() => {
const ids = providers.map(provider => provider.id); const ids = providers.map(provider => provider.id);
// 模拟删除操作 Api.model.deleteModelProviderByIds(ids, ({ data }) => {
this.providersList = this.providersList.filter(p => !ids.includes(p.id)); if (data.code === 0) {
this.total = this.providersList.length;
this.$message.success({ this.isAllSelected = false;
message: `成功删除${providerCount}个供应器`, this.fetchProviders(); // 刷新表格
showClose: true
this.$message.success({
message: `成功删除${providerCount}个参数`,
showClose: true
});
} else {
this.$message.error({
message: data.msg || '删除失败,请重试',
showClose: true
});
}
}); });
}).catch(() => { }).catch(() => {
this.$message({ this.$message({
@@ -434,8 +370,9 @@ export default {
return typeItem ? typeItem.label : type; return typeItem ? typeItem.label : type;
}, },
isSensitiveField(fieldKey) { isSensitiveField(fieldKey) {
if (typeof fieldKey !== 'string') return false;
return this.sensitive_keys.some(key => return this.sensitive_keys.some(key =>
fieldKey.toLowerCase().includes(key.toLowerCase()) fieldKey.toLowerCase().includes(key.toLowerCase())
); );
}, },
handlePageSizeChange(val) { handlePageSizeChange(val) {
@@ -443,7 +380,7 @@ export default {
this.currentPage = 1; this.currentPage = 1;
this.fetchProviders(); this.fetchProviders();
}, },
headerCellClassName({columnIndex}) { headerCellClassName({ columnIndex }) {
if (columnIndex === 0) { if (columnIndex === 0) {
return "custom-selection-header"; return "custom-selection-header";
} }
@@ -461,6 +398,7 @@ export default {
}, },
goNext() { goNext() {
if (this.currentPage < this.pageCount) { if (this.currentPage < this.pageCount) {
console.log("this.currentPage", this.currentPage);
this.currentPage++; this.currentPage++;
this.fetchProviders(); this.fetchProviders();
} }
@@ -552,7 +490,7 @@ export default {
flex-direction: column; flex-direction: column;
} }
.el-card{ .el-card {
border: none; border: none;
} }
@@ -563,6 +501,7 @@ export default {
flex-direction: column; flex-direction: column;
box-shadow: none; box-shadow: none;
overflow: hidden; overflow: hidden;
::v-deep .el-card__body { ::v-deep .el-card__body {
padding: 15px; padding: 15px;
display: flex; display: flex;
@@ -748,7 +687,7 @@ export default {
padding-bottom: 16px; padding-bottom: 16px;
} }
& + tr { &+tr {
margin-top: 10px; margin-top: 10px;
} }
} }
@@ -918,19 +857,20 @@ export default {
.el-icon-arrow-down { .el-icon-arrow-down {
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
.dropdown-trigger {
font-size: 14px;
color: #303133;
cursor: pointer;
display: flex;
align-items: center;
&:hover { .dropdown-trigger {
color: #409EFF; font-size: 14px;
} color: #303133;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
color: #409EFF;
}
} }
.dropdown-trigger.active { .dropdown-trigger.active {
color: #409EFF; color: #409EFF;
} }
</style> </style>
@@ -0,0 +1,475 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">服务端管理</h2>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)" :header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="ws地址" prop="address" align="center"></el-table-column>
<el-table-column label="操作" prop="operator" align="center" show-overflow-tooltip>
<template slot-scope="scope">
<el-button size="medium" type="text" @click="emitAction(scope.row, actionMap.restart)">重启</el-button>
<el-button size="medium" type="text"
@click="emitAction(scope.row, actionMap.update_config)">更新配置</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</div>
</div>
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue";
import ParamDialog from "@/components/ParamDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
components: { HeaderBar, ParamDialog, VersionFooter },
data() {
return {
paramsList: [],
actionMap: {
restart: {
value: 'restart',
title: "重启服务端",
message: "确定要重启服务端吗?",
confirmText: "重启",
},
update_config: {
value: 'update_config',
title: "更新配置",
message: "确定要更新配置吗?",
confirmText: "更新",
}
},
currentPage: 1,
loading: false,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0,
dialogVisible: false,
dialogTitle: "新增参数",
isAllSelected: false,
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
paramForm: {
id: null,
paramCode: "",
paramValue: "",
remark: ""
},
};
},
created() {
this.fetchParams();
},
computed: {
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
const pages = [];
const maxVisible = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.pageCount, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchParams();
},
fetchParams() {
this.loading = true;
Api.admin.getWsServerList(
{},
({ data }) => {
this.loading = false;
if (data.code === 0) {
this.paramsList = data.data.map(item => ({ address: item }));
this.total = data.data.length;
} else {
this.$message.error({
message: data.msg || '获取参数列表失败',
showClose: true
});
}
}
);
},
emitAction(rowItem, actionItem) {
if (actionItem === undefined || rowItem.address === undefined) {
return;
}
// 弹开询问框
this.$confirm(actionItem.message, actionItem.title, {
confirmButtonText: actionItem.confirmText, // 确认按钮文本
}).then(() => {
// 用户点击了确认按钮
Api.admin.sendWsServerAction({
targetWs: rowItem.address,
action: actionItem.value
}, ({ data }) => {
if (data.code !== 0) {
this.$message.error({
message: data.msg || '操作失败',
showClose: true
});
return;
}
this.$message.success({
message: `${actionItem.title}成功`,
showClose: true
})
})
})
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
}
},
};
</script>
<style lang="scss" scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background-size: cover;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.search-input {
width: 240px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.content-panel {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.content-area {
flex: 1;
height: 100%;
min-width: 600px;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.params-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 26px;
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.el-button--primary {
background: #5f70f3;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
:deep(.transparent-table) {
background: white;
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper {
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
color: black;
}
&::before {
display: none;
}
.el-table__body tr {
background-color: white;
td {
border-top: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
}
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
@media (min-width: 1144px) {
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
}
:deep(.transparent-table) {
.el-table__body tr {
td {
padding-top: 16px;
padding-bottom: 16px;
}
&+tr {
margin-top: 10px;
}
}
}
}
:deep(.el-table .el-button--text) {
color: #7079aa;
}
:deep(.el-table .el-button--text:hover) {
color: #5a64b5;
}
.el-button--success {
background: #5bc98c;
color: white;
}
:deep(.el-table .cell) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.page-size-select {
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
:deep(.el-table) {
.el-table__body-wrapper {
transition: height 0.3s ease;
}
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style>
+1 -14
View File
@@ -66,20 +66,7 @@
cursor: pointer; cursor: pointer;
color: #fff; color: #fff;
line-height: 35px; line-height: 35px;
margin: 35px 15px 15px; margin: 15px 30px 15px 30px;
}
.code-send {
width: 70px;
height: 32px;
border-radius: 10px;
background: #e6ebff;
line-height: 32px;
font-weight: 400;
font-size: 14px;
color: #5778ff;
flex-shrink: 0;
cursor: pointer;
} }
.input-box { .input-box {
+105 -19
View File
@@ -20,10 +20,27 @@
</div> </div>
</div> </div>
<div style="padding: 0 30px;"> <div style="padding: 0 30px;">
<div class="input-box"> <!-- 用户名登录 -->
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png" /> <template v-if="!isMobileLogin">
<el-input v-model="form.username" placeholder="请输入用户名" /> <div class="input-box">
</div> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png" />
<el-input v-model="form.username" placeholder="请输入用户名" />
</div>
</template>
<!-- 手机号登录 -->
<template v-else>
<div class="input-box">
<div style="display: flex; align-items: center; width: 100%;">
<el-select v-model="form.areaCode" style="width: 220px; margin-right: 10px;">
<el-option v-for="item in mobileAreaList" :key="item.key" :label="`${item.name} (${item.key})`"
:value="item.key" />
</el-select>
<el-input v-model="form.mobile" placeholder="请输入手机号码" />
</div>
</div>
</template>
<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="请输入密码" type="password" /> <el-input v-model="form.password" placeholder="请输入密码" type="password" />
@@ -39,9 +56,23 @@
<div <div
style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;display: flex;justify-content: space-between;margin-top: 20px;"> style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;display: flex;justify-content: space-between;margin-top: 20px;">
<div v-if="allowUserRegister" style="cursor: pointer;" @click="goToRegister">新用户注册</div> <div v-if="allowUserRegister" style="cursor: pointer;" @click="goToRegister">新用户注册</div>
<div style="cursor: pointer;" @click="goToForgetPassword" v-if="enableMobileRegister">忘记密码?</div>
</div> </div>
</div> </div>
<div class="login-btn" @click="login">登录</div> <div class="login-btn" @click="login">登录</div>
<!-- 登录方式切换按钮 -->
<div class="login-type-container" v-if="enableMobileRegister">
<el-tooltip content="手机号码登录" placement="bottom">
<el-button :type="isMobileLogin ? 'primary' : 'default'" icon="el-icon-mobile" circle
@click="switchLoginType('mobile')"></el-button>
</el-tooltip>
<el-tooltip content="用户名登录" placement="bottom">
<el-button :type="!isMobileLogin ? 'primary' : 'default'" icon="el-icon-user" circle
@click="switchLoginType('username')"></el-button>
</el-tooltip>
</div>
<div style="font-size: 14px;color: #979db1;"> <div style="font-size: 14px;color: #979db1;">
登录即同意 登录即同意
<div style="display: inline-block;color: #5778FF;cursor: pointer;">用户协议</div> <div style="display: inline-block;color: #5778FF;cursor: pointer;">用户协议</div>
@@ -60,7 +91,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 } from '@/utils'; import { getUUID, goToPage, showDanger, showSuccess, validateMobile } from '@/utils';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
export default { export default {
@@ -70,7 +101,9 @@ export default {
}, },
computed: { computed: {
...mapState({ ...mapState({
allowUserRegister: state => state.pubConfig.allowUserRegister allowUserRegister: state => state.pubConfig.allowUserRegister,
enableMobileRegister: state => state.pubConfig.enableMobileRegister,
mobileAreaList: state => state.pubConfig.mobileAreaList
}) })
}, },
data() { data() {
@@ -80,15 +113,21 @@ export default {
username: '', username: '',
password: '', password: '',
captcha: '', captcha: '',
captchaId: '' captchaId: '',
areaCode: '+86',
mobile: ''
}, },
captchaUuid: '', captchaUuid: '',
captchaUrl: '' captchaUrl: '',
isMobileLogin: false
} }
}, },
mounted() { mounted() {
this.fetchCaptcha(); this.fetchCaptcha();
this.$store.dispatch('fetchPubConfig'); this.$store.dispatch('fetchPubConfig').then(() => {
// 根据配置决定默认登录方式
this.isMobileLogin = this.enableMobileRegister;
});
}, },
methods: { methods: {
fetchCaptcha() { fetchCaptcha() {
@@ -110,6 +149,17 @@ export default {
} }
}, },
// 切换登录方式
switchLoginType(type) {
this.isMobileLogin = type === 'mobile';
// 清空表单
this.form.username = '';
this.form.mobile = '';
this.form.password = '';
this.form.captcha = '';
this.fetchCaptcha();
},
// 封装输入验证逻辑 // 封装输入验证逻辑
validateInput(input, message) { validateInput(input, message) {
if (!input.trim()) { if (!input.trim()) {
@@ -120,10 +170,21 @@ export default {
}, },
async login() { async login() {
// 验证用户名 if (this.isMobileLogin) {
if (!this.validateInput(this.form.username, '用户名不能为空')) { // 手机号登录验证
return; if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码');
return;
}
// 拼接手机号作为用户名
this.form.username = this.form.areaCode + this.form.mobile;
} else {
// 用户名登录验证
if (!this.validateInput(this.form.username, '用户名不能为空')) {
return;
}
} }
// 验证密码 // 验证密码
if (!this.validateInput(this.form.password, '密码不能为空')) { if (!this.validateInput(this.form.password, '密码不能为空')) {
return; return;
@@ -135,12 +196,13 @@ export default {
this.form.captchaId = this.captchaUuid this.form.captchaId = this.captchaUuid
Api.user.login(this.form, ({ data }) => { Api.user.login(this.form, ({ data }) => {
if (data.code === 0) { showSuccess('登录成功!');
showSuccess('登录成功!'); this.$store.commit('setToken', JSON.stringify(data.data));
this.$store.commit('setToken', JSON.stringify(data.data)); goToPage('/home');
goToPage('/home'); }, (err) => {
} else { showDanger(err.data.msg || '登录失败')
showDanger(data.msg || '登录失败'); if (err.data != null && err.data.msg != null && err.data.msg.indexOf('图形验证码') > -1) {
this.fetchCaptcha()
} }
}) })
@@ -153,8 +215,32 @@ export default {
goToRegister() { goToRegister() {
goToPage('/register') goToPage('/register')
}, },
goToForgetPassword() {
goToPage('/retrieve-password')
},
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import './auth.scss'; // 添加这行引用</style> @import './auth.scss';
.login-type-container {
margin: 10px 20px;
}
:deep(.el-button--primary) {
background-color: #5778ff;
border-color: #5778ff;
&:hover,
&:focus {
background-color: #4a6ae8;
border-color: #4a6ae8;
}
&:active {
background-color: #3d5cd6;
border-color: #3d5cd6;
}
}
</style>
+180 -48
View File
@@ -23,38 +23,78 @@
</div> </div>
<div style="padding: 0 30px;"> <div style="padding: 0 30px;">
<!-- 用户名输入框 --> <form @submit.prevent="register">
<div class="input-box"> <!-- 用户名/手机号输入框 -->
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png" /> <div class="input-box" v-if="!enableMobileRegister">
<el-input v-model="form.username" placeholder="请输入用户名" /> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png" />
</div> <el-input v-model="form.username" placeholder="请输入用户名" />
<!-- 密码输入框 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.password" placeholder="请输入密码" type="password" />
</div>
<!-- 新增确认密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password" />
</div>
<!-- 验证码部分保持相同 -->
<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;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png" />
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;" />
</div> </div>
<img loading="lazy" v-if="captchaUrl" :src="captchaUrl" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" @click="fetchCaptcha" />
</div>
<!-- 修改底部链接 --> <!-- 手机号注册部分 -->
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;margin-top: 20px;"> <template v-if="enableMobileRegister">
<div style="cursor: pointer;" @click="goToLogin">已有账号立即登录</div> <div class="input-box">
</div> <div style="display: flex; align-items: center; width: 100%;">
<el-select v-model="form.areaCode" style="width: 220px; margin-right: 10px;">
<el-option v-for="item in mobileAreaList" :key="item.key" :label="`${item.name} (${item.key})`"
:value="item.key" />
</el-select>
<el-input v-model="form.mobile" placeholder="请输入手机号码" />
</div>
</div>
<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;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png" />
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;" />
</div>
<img loading="lazy" v-if="captchaUrl" :src="captchaUrl" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" @click="fetchCaptcha" />
</div>
<!-- 手机验证码 -->
<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;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/phone.png" />
<el-input v-model="form.mobileCaptcha" placeholder="请输入手机验证码" style="flex: 1;" maxlength="6" />
</div>
<el-button type="primary" class="send-captcha-btn" :disabled="!canSendMobileCaptcha"
@click="sendMobileCaptcha">
<span>
{{ countdown > 0 ? `${countdown}秒后重试` : '发送验证码' }}
</span>
</el-button>
</div>
</template>
<!-- 密码输入框 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.password" placeholder="请输入密码" type="password" />
</div>
<!-- 新增确认密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password" />
</div>
<!-- 验证码部分保持相同 -->
<div v-if="!enableMobileRegister"
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;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png" />
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;" />
</div>
<img loading="lazy" v-if="captchaUrl" :src="captchaUrl" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" @click="fetchCaptcha" />
</div>
<!-- 修改底部链接 -->
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;margin-top: 20px;">
<div style="cursor: pointer;" @click="goToLogin">已有账号立即登录</div>
</div>
</form>
</div> </div>
<!-- 修改按钮文本 --> <!-- 修改按钮文本 -->
@@ -81,7 +121,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 } from '@/utils'; import { getUUID, goToPage, showDanger, showSuccess, validateMobile } from '@/utils';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
export default { export default {
@@ -91,8 +131,13 @@ export default {
}, },
computed: { computed: {
...mapState({ ...mapState({
allowUserRegister: state => state.pubConfig.allowUserRegister allowUserRegister: state => state.pubConfig.allowUserRegister,
}) enableMobileRegister: state => state.pubConfig.enableMobileRegister,
mobileAreaList: state => state.pubConfig.mobileAreaList
}),
canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
}
}, },
data() { data() {
return { return {
@@ -101,9 +146,14 @@ export default {
password: '', password: '',
confirmPassword: '', confirmPassword: '',
captcha: '', captcha: '',
captchaId: '' captchaId: '',
areaCode: '+86',
mobile: '',
mobileCaptcha: ''
}, },
captchaUrl: '' captchaUrl: '',
countdown: 0,
timer: null
} }
}, },
mounted() { mounted() {
@@ -141,12 +191,70 @@ export default {
} }
return true; return true;
}, },
// 注册逻辑
register() { // 发送手机验证码
// 验证用户名 sendMobileCaptcha() {
if (!this.validateInput(this.form.username, '用户名不能为空')) { if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码');
return; return;
} }
// 验证图形验证码
if (!this.validateInput(this.form.captcha, '请输入图形验证码')) {
this.fetchCaptcha();
return;
}
// 清除可能存在的旧定时器
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
// 开始倒计时
this.countdown = 60;
this.timer = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
} else {
clearInterval(this.timer);
this.timer = null;
}
}, 1000);
// 调用发送验证码接口
Api.user.sendSmsVerification({
phone: this.form.areaCode + this.form.mobile,
captcha: this.form.captcha,
captchaId: this.form.captchaId
}, (res) => {
showSuccess('验证码发送成功');
}, (err) => {
showDanger(err.data.msg || '验证码发送失败');
this.countdown = 0;
this.fetchCaptcha();
});
},
// 注册逻辑
register() {
if (this.enableMobileRegister) {
// 手机号注册验证
if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码');
return;
}
if (!this.form.mobileCaptcha) {
showDanger('请输入手机验证码');
return;
}
} else {
// 用户名注册验证
if (!this.validateInput(this.form.username, '用户名不能为空')) {
return;
}
}
// 验证密码 // 验证密码
if (!this.validateInput(this.form.password, '密码不能为空')) { if (!this.validateInput(this.form.password, '密码不能为空')) {
return; return;
@@ -160,26 +268,50 @@ export default {
return; return;
} }
if (this.enableMobileRegister) {
this.form.username = this.form.areaCode + this.form.mobile
}
Api.user.register(this.form, ({ data }) => { Api.user.register(this.form, ({ data }) => {
if (data.code === 0) { showSuccess('注册成功!')
showSuccess('注册成功!') goToPage('/login')
goToPage('/login') }, (err) => {
} else { showDanger(err.data.msg || '注册失败')
showDanger(data.msg || '注册失败') if (err.data != null && err.data.msg != null && err.data.msg.indexOf('图形验证码') > -1) {
this.fetchCaptcha() this.fetchCaptcha()
} }
}) })
setTimeout(() => {
this.fetchCaptcha()
}, 1000)
}, },
goToLogin() { goToLogin() {
goToPage('/login') goToPage('/login')
} }
},
beforeDestroy() {
if (this.timer) {
clearInterval(this.timer);
}
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import './auth.scss'; // 修改为导入新建的SCSS文件</style> @import './auth.scss';
.send-captcha-btn {
margin-right: -5px;
min-width: 100px;
height: 40px;
line-height: 40px;
border-radius: 4px;
font-size: 14px;
background: rgb(87, 120, 255);
border: none;
padding: 0px;
&:disabled {
background: #c0c4cc;
cursor: not-allowed;
}
}
</style>
@@ -0,0 +1,275 @@
<template>
<div class="welcome" @keyup.enter="retrievePassword">
<el-container style="height: 100%;">
<!-- 保持相同的头部 -->
<el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;" />
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="height: 18px;" />
</div>
</el-header>
<div class="login-person">
<img loading="lazy" alt="" src="@/assets/login/register-person.png" style="width: 100%;" />
</div>
<el-main style="position: relative;">
<form @submit.prevent="retrievePassword">
<div class="login-box">
<!-- 修改标题部分 -->
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;" />
<div class="login-text">重置密码</div>
<div class="login-welcome">
PASSWORD RETRIEVE
</div>
</div>
<div style="padding: 0 30px;">
<!-- 手机号输入 -->
<div class="input-box">
<div style="display: flex; align-items: center; width: 100%;">
<el-select v-model="form.areaCode" style="width: 220px; margin-right: 10px;">
<el-option v-for="item in mobileAreaList" :key="item.key" :label="`${item.name} (${item.key})`"
:value="item.key" />
</el-select>
<el-input v-model="form.mobile" placeholder="请输入手机号码" />
</div>
</div>
<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;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png" />
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;" />
</div>
<img loading="lazy" v-if="captchaUrl" :src="captchaUrl" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" @click="fetchCaptcha" />
</div>
<!-- 手机验证码 -->
<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;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/phone.png" />
<el-input v-model="form.mobileCaptcha" placeholder="请输入手机验证码" style="flex: 1;" maxlength="6" />
</div>
<el-button type="primary" class="send-captcha-btn" :disabled="!canSendMobileCaptcha"
@click="sendMobileCaptcha">
<span>
{{ countdown > 0 ? `${countdown}秒后重试` : '发送验证码' }}
</span>
</el-button>
</div>
<!-- 新密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.newPassword" placeholder="请输入新密码" type="password" />
</div>
<!-- 确认新密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.confirmPassword" placeholder="请确认新密码" type="password" />
</div>
<!-- 修改底部链接 -->
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;margin-top: 20px;">
<div style="cursor: pointer;" @click="goToLogin">返回登录</div>
</div>
</div>
<!-- 修改按钮文本 -->
<div class="login-btn" @click="retrievePassword">立即修改</div>
<!-- 保持相同的协议声明 -->
<div style="font-size: 14px;color: #979db1;">
同意
<div style="display: inline-block;color: #5778FF;cursor: pointer;">用户协议</div>
<div style="display: inline-block;color: #5778FF;cursor: pointer;">隐私政策</div>
</div>
</div>
</form>
</el-main>
<!-- 保持相同的页脚 -->
<el-footer>
<version-footer />
</el-footer>
</el-container>
</div>
</template>
<script>
import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess, validateMobile } from '@/utils';
import { mapState } from 'vuex';
export default {
name: 'retrieve',
components: {
VersionFooter
},
computed: {
...mapState({
allowUserRegister: state => state.pubConfig.allowUserRegister,
mobileAreaList: state => state.pubConfig.mobileAreaList
}),
canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
}
},
data() {
return {
form: {
areaCode: '+86',
mobile: '',
captcha: '',
captchaId: '',
smsCode: '',
newPassword: '',
confirmPassword: ''
},
captchaUrl: '',
countdown: 0,
timer: null
}
},
mounted() {
this.fetchCaptcha();
},
methods: {
// 复用验证码获取方法
fetchCaptcha() {
this.form.captchaId = getUUID();
Api.user.getCaptcha(this.form.captchaId, (res) => {
if (res.status === 200) {
const blob = new Blob([res.data], { type: res.data.type });
this.captchaUrl = URL.createObjectURL(blob);
} else {
console.error('验证码加载异常:', error);
showDanger('验证码加载失败,点击刷新');
}
});
},
// 封装输入验证逻辑
validateInput(input, message) {
if (!input.trim()) {
showDanger(message);
return false;
}
return true;
},
// 发送手机验证码
sendMobileCaptcha() {
if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码');
return;
}
// 验证图形验证码
if (!this.validateInput(this.form.captcha, '请输入图形验证码')) {
this.fetchCaptcha();
return;
}
// 清除可能存在的旧定时器
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
// 开始倒计时
this.countdown = 60;
this.timer = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
} else {
clearInterval(this.timer);
this.timer = null;
}
}, 1000);
// 调用发送验证码接口
Api.user.sendSmsVerification({
phone: this.form.areaCode + this.form.mobile,
captcha: this.form.captcha,
captchaId: this.form.captchaId
}, (res) => {
showSuccess('验证码发送成功');
}, (err) => {
showDanger(err.data.msg || '验证码发送失败');
this.countdown = 0;
this.fetchCaptcha();
});
},
// 修改逻辑
retrievePassword() {
// 验证逻辑
if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码');
return;
}
if (!this.form.captcha) {
showDanger('请输入图形验证码');
return;
}
if (!this.form.mobileCaptcha) {
showDanger('请输入短信验证码');
return;
}
if (this.form.newPassword !== this.form.confirmPassword) {
showDanger('两次输入的密码不一致');
return;
}
Api.user.retrievePassword({
phone: this.form.areaCode + this.form.mobile,
password: this.form.newPassword,
code: this.form.mobileCaptcha
}, (res) => {
showSuccess('密码重置成功');
goToPage('/login');
}, (err) => {
showDanger(err.data.msg || '重置失败');
if (err.data != null && err.data.msg != null && err.data.msg.indexOf('图形验证码') > -1) {
this.fetchCaptcha()
}
});
},
goToLogin() {
goToPage('/login')
}
},
beforeDestroy() {
if (this.timer) {
clearInterval(this.timer);
}
}
}
</script>
<style lang="scss" scoped>
@import './auth.scss';
.send-captcha-btn {
margin-right: -5px;
min-width: 100px;
height: 40px;
line-height: 40px;
border-radius: 4px;
font-size: 14px;
background: rgb(87, 120, 255);
border: none;
padding: 0;
&:disabled {
background: #c0c4cc;
cursor: not-allowed;
}
}
</style>
+1 -1
View File
@@ -102,7 +102,7 @@ async def main():
await asyncio.wait( await asyncio.wait(
[stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task], [stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task],
timeout=3.0, timeout=3.0,
return_when=asyncio.ALL_COMPLETED return_when=asyncio.ALL_COMPLETED,
) )
print("服务器已关闭,程序退出。") print("服务器已关闭,程序退出。")
+17 -10
View File
@@ -411,7 +411,7 @@ LLM:
base_url: https://host/api/v1 base_url: https://host/api/v1
# 你可以在这里找到你的api_key # 你可以在这里找到你的api_key
# https://cloud.tryfastgpt.ai/account/apikey # https://cloud.tryfastgpt.ai/account/apikey
api_key: fastgpt-xxx api_key: 你的fastgpt密钥
variables: variables:
k: "v" k: "v"
k2: "v2" k2: "v2"
@@ -675,17 +675,24 @@ TTS:
speed: 1 speed: 1
output_dir: tmp/ output_dir: tmp/
CustomTTS: CustomTTS:
# 自定义的TTS接口服务,请求参数可自定义 # 自定义的TTS接口服务,请求参数可自定义,可接入众多TTS服务
# 要求接口使用GET方式请求,并返回音频文件 # 以本地部署的KokoroTTS为例
# 如果只有cpu运行:docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
# 如果只有gpu运行:docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest
# 要求接口使用POST方式请求,并返回音频文件
type: custom type: custom
url: "http://127.0.0.1:9880/tts" method: POST
url: "http://127.0.0.1:8880/v1/audio/speech"
params: # 自定义请求参数 params: # 自定义请求参数
# text: "{prompt_text}" # {prompt_text}会被替换为实际的提示词内容 input: "{prompt_text}"
# speaker: jok老师 response_format: "mp3"
# speed: 1 download_format: "mp3"
# foo: bar voice: "zf_xiaoxiao"
# testabc: 123456 lang_code: "z"
return_download_link: true
speed: 1
stream: false
headers: # 自定义请求头 headers: # 自定义请求头
# Authorization: Bearer xxxx # Authorization: Bearer xxxx
format: wav # 接口返回的音频格式 format: mp3 # 接口返回的音频格式
output_dir: tmp/ output_dir: tmp/
@@ -82,6 +82,8 @@ def ensure_directories(config):
# ASR/TTS模块输出目录 # ASR/TTS模块输出目录
for module in ["ASR", "TTS"]: for module in ["ASR", "TTS"]:
if config.get(module) is None:
continue
for provider in config.get(module, {}).values(): for provider in config.get(module, {}).values():
output_dir = provider.get("output_dir", "") output_dir = provider.get("output_dir", "")
if output_dir: if output_dir:
@@ -93,6 +95,10 @@ def ensure_directories(config):
selected_provider = selected_modules.get(module_type) selected_provider = selected_modules.get(module_type)
if not selected_provider: if not selected_provider:
continue continue
if config.get(module) is None:
continue
if config.get(selected_provider) is None:
continue
provider_config = config.get(module_type, {}).get(selected_provider, {}) provider_config = config.get(module_type, {}).get(selected_provider, {})
output_dir = provider_config.get("output_dir") output_dir = provider_config.get("output_dir")
if output_dir: if output_dir:
+1 -1
View File
@@ -4,7 +4,7 @@ from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
from config.settings import check_config_file from config.settings import check_config_file
SERVER_VERSION = "0.4.3" SERVER_VERSION = "0.4.4"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
+5 -36
View File
@@ -22,6 +22,7 @@ from core.utils.util import (
initialize_modules, initialize_modules,
check_vad_update, check_vad_update,
check_asr_update, check_asr_update,
filter_sensitive_info,
) )
from concurrent.futures import ThreadPoolExecutor, TimeoutError from concurrent.futures import ThreadPoolExecutor, TimeoutError
from core.handle.sendAudioHandle import sendAudioMessage from core.handle.sendAudioHandle import sendAudioMessage
@@ -273,9 +274,10 @@ class ConnectionHandler:
await self.websocket.send( await self.websocket.send(
json.dumps( json.dumps(
{ {
"type": "server_response", "type": "server",
"status": "success", "status": "success",
"message": "服务器重启中...", "message": "服务器重启中...",
"content": {"action": "restart"},
} }
) )
) )
@@ -302,9 +304,10 @@ class ConnectionHandler:
await self.websocket.send( await self.websocket.send(
json.dumps( json.dumps(
{ {
"type": "server_response", "type": "server",
"status": "error", "status": "error",
"message": f"Restart failed: {str(e)}", "message": f"Restart failed: {str(e)}",
"content": {"action": "restart"},
} }
) )
) )
@@ -1087,37 +1090,3 @@ class ConnectionHandler:
break break
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}") self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}")
def filter_sensitive_info(config: dict) -> dict:
"""
过滤配置中的敏感信息
Args:
config: 原始配置字典
Returns:
过滤后的配置字典
"""
sensitive_keys = [
"api_key",
"personal_access_token",
"access_token",
"token",
"secret",
"access_key_secret",
"secret_key",
]
def _filter_dict(d: dict) -> dict:
filtered = {}
for k, v in d.items():
if any(sensitive in k.lower() for sensitive in sensitive_keys):
filtered[k] = "***"
elif isinstance(v, dict):
filtered[k] = _filter_dict(v)
elif isinstance(v, list):
filtered[k] = [_filter_dict(i) if isinstance(i, dict) else i for i in v]
else:
filtered[k] = v
return filtered
return _filter_dict(copy.deepcopy(config))
@@ -1,6 +1,12 @@
from config.logger import setup_logging from config.logger import setup_logging
import json import json
from plugins_func.register import FunctionRegistry, ActionResponse, Action, ToolType from plugins_func.register import (
FunctionRegistry,
ActionResponse,
Action,
ToolType,
DeviceTypeRegistry,
)
from plugins_func.functions.hass_init import append_devices_to_prompt from plugins_func.functions.hass_init import append_devices_to_prompt
TAG = __name__ TAG = __name__
@@ -10,6 +16,7 @@ class FunctionHandler:
def __init__(self, conn): def __init__(self, conn):
self.conn = conn self.conn = conn
self.config = conn.config self.config = conn.config
self.device_type_registry = DeviceTypeRegistry()
self.function_registry = FunctionRegistry() self.function_registry = FunctionRegistry()
self.register_nessary_functions() self.register_nessary_functions()
self.register_config_functions() self.register_config_functions()
@@ -54,7 +61,7 @@ class FunctionHandler:
self.function_registry.register_function("plugin_loader") self.function_registry.register_function("plugin_loader")
self.function_registry.register_function("get_time") self.function_registry.register_function("get_time")
self.function_registry.register_function("get_lunar") self.function_registry.register_function("get_lunar")
self.function_registry.register_function("handle_device") self.function_registry.register_function("handle_speaker_volume_or_screen_brightness")
def register_config_functions(self): def register_config_functions(self):
"""注册配置中的函数,可以不同客户端使用不同的配置""" """注册配置中的函数,可以不同客户端使用不同的配置"""
@@ -40,8 +40,8 @@ async def checkWakeupWords(conn, text):
if not enable_wakeup_words_response_cache: if not enable_wakeup_words_response_cache:
return False return False
"""检查是否是唤醒词""" """检查是否是唤醒词"""
_, text = remove_punctuation_and_length(text) _, filtered_text = remove_punctuation_and_length(text)
if text in conn.config.get("wakeup_words"): if filtered_text in conn.config.get("wakeup_words"):
await send_stt_message(conn, text) await send_stt_message(conn, text)
conn.tts_first_text_index = 0 conn.tts_first_text_index = 0
conn.tts_last_text_index = 0 conn.tts_last_text_index = 0
@@ -13,10 +13,11 @@ TAG = __name__
async def handle_user_intent(conn, text): async def handle_user_intent(conn, text):
# 检查是否有明确的退出命令 # 检查是否有明确的退出命令
if await check_direct_exit(conn, text): filtered_text = remove_punctuation_and_length(text)[1]
if await check_direct_exit(conn, filtered_text):
return True return True
# 检查是否是唤醒词 # 检查是否是唤醒词
if await checkWakeupWords(conn, text): if await checkWakeupWords(conn, filtered_text):
return True return True
if conn.intent_type == "function_call": if conn.intent_type == "function_call":
+19 -14
View File
@@ -1,9 +1,8 @@
import json import json
import asyncio import asyncio
from config.logger import setup_logging
from plugins_func.register import ( from plugins_func.register import (
device_type_registry, FunctionItem,
register_function, register_device_function,
ActionResponse, ActionResponse,
Action, Action,
ToolType, ToolType,
@@ -177,7 +176,7 @@ class IotDescriptor:
self.methods.append(method) self.methods.append(method)
def register_device_type(descriptor): def register_device_type(descriptor, device_type_registry):
"""注册设备类型及其功能""" """注册设备类型及其功能"""
device_name = descriptor["name"] device_name = descriptor["name"]
type_id = device_type_registry.generate_device_type_id(descriptor) type_id = device_type_registry.generate_device_type_id(descriptor)
@@ -213,10 +212,12 @@ def register_device_type(descriptor):
}, },
} }
query_func = create_iot_query_function(device_name, prop_name, prop_info) query_func = create_iot_query_function(device_name, prop_name, prop_info)
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)( decorated_func = register_device_function(
query_func func_name, func_desc, ToolType.IOT_CTL
)(query_func)
functions[func_name] = FunctionItem(
func_name, func_desc, decorated_func, ToolType.IOT_CTL
) )
functions[func_name] = decorated_func
# 为每个方法创建控制函数 # 为每个方法创建控制函数
for method_name, method_info in descriptor["methods"].items(): for method_name, method_info in descriptor["methods"].items():
@@ -267,10 +268,12 @@ def register_device_type(descriptor):
}, },
} }
control_func = create_iot_function(device_name, method_name, method_info) control_func = create_iot_function(device_name, method_name, method_info)
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)( decorated_func = register_device_function(
control_func func_name, func_desc, ToolType.IOT_CTL
)(control_func)
functions[func_name] = FunctionItem(
func_name, func_desc, decorated_func, ToolType.IOT_CTL
) )
functions[func_name] = decorated_func
device_type_registry.register_device_type(type_id, functions) device_type_registry.register_device_type(type_id, functions)
return type_id return type_id
@@ -289,7 +292,6 @@ async def handleIotDescriptors(conn, descriptors):
functions_changed = False functions_changed = False
for descriptor in descriptors: for descriptor in descriptors:
# 如果descriptor没有properties和methods,则直接跳过 # 如果descriptor没有properties和methods,则直接跳过
if "properties" not in descriptor and "methods" not in descriptor: if "properties" not in descriptor and "methods" not in descriptor:
continue continue
@@ -319,13 +321,16 @@ async def handleIotDescriptors(conn, descriptors):
if conn.load_function_plugin: if conn.load_function_plugin:
# 注册或获取设备类型 # 注册或获取设备类型
type_id = register_device_type(descriptor) device_type_registry = conn.func_handler.device_type_registry
type_id = register_device_type(descriptor, device_type_registry)
device_functions = device_type_registry.get_device_functions(type_id) device_functions = device_type_registry.get_device_functions(type_id)
# 在连接级注册设备函数 # 在连接级注册设备函数
if hasattr(conn, "func_handler"): if hasattr(conn, "func_handler"):
for func_name in device_functions: for func_name, func_item in device_functions.items():
conn.func_handler.function_registry.register_function(func_name) conn.func_handler.function_registry.register_function(
func_name, func_item
)
conn.logger.bind(tag=TAG).info( conn.logger.bind(tag=TAG).info(
f"注册IOT函数到function handler: {func_name}" f"注册IOT函数到function handler: {func_name}"
) )
@@ -39,14 +39,14 @@ async def handleAudioMessage(conn, audio):
if len(conn.asr_audio) < 15: if len(conn.asr_audio) < 15:
conn.asr_server_receive = True conn.asr_server_receive = True
else: else:
text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id) raw_text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id) # 确保ASR模块返回原始文本
conn.logger.bind(tag=TAG).info(f"识别文本: {text}") conn.logger.bind(tag=TAG).info(f"识别文本: {raw_text}")
text_len, _ = remove_punctuation_and_length(text) text_len, _ = remove_punctuation_and_length(raw_text)
if text_len > 0: if text_len > 0:
# 使用自定义模块进行上报 # 使用自定义模块进行上报
enqueue_asr_report(conn, text, copy.deepcopy(conn.asr_audio)) enqueue_asr_report(conn, raw_text, copy.deepcopy(conn.asr_audio))
await startToChat(conn, text) await startToChat(conn, raw_text)
else: else:
conn.asr_server_receive = True conn.asr_server_receive = True
conn.asr_audio.clear() conn.asr_audio.clear()
+24 -12
View File
@@ -1,7 +1,7 @@
import json import json
from core.handle.abortHandle import handleAbortMessage from core.handle.abortHandle import handleAbortMessage
from core.handle.helloHandle import handleHelloMessage from core.handle.helloHandle import handleHelloMessage
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length, filter_sensitive_info
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
@@ -13,17 +13,20 @@ TAG = __name__
async def handleTextMessage(conn, message): async def handleTextMessage(conn, message):
"""处理文本消息""" """处理文本消息"""
conn.logger.bind(tag=TAG).info(f"收到文本消息:{message}")
try: try:
msg_json = json.loads(message) msg_json = json.loads(message)
if isinstance(msg_json, int): if isinstance(msg_json, int):
conn.logger.bind(tag=TAG).info(f"收到文本消息:{message}")
await conn.websocket.send(message) await conn.websocket.send(message)
return return
if msg_json["type"] == "hello": if msg_json["type"] == "hello":
conn.logger.bind(tag=TAG).info(f"收到hello消息:{message}")
await handleHelloMessage(conn, msg_json) await handleHelloMessage(conn, msg_json)
elif msg_json["type"] == "abort": elif msg_json["type"] == "abort":
conn.logger.bind(tag=TAG).info(f"收到abort消息:{message}")
await handleAbortMessage(conn) await handleAbortMessage(conn)
elif msg_json["type"] == "listen": elif msg_json["type"] == "listen":
conn.logger.bind(tag=TAG).info(f"收到listen消息:{message}")
if "mode" in msg_json: if "mode" in msg_json:
conn.client_listen_mode = msg_json["mode"] conn.client_listen_mode = msg_json["mode"]
conn.logger.bind(tag=TAG).debug( conn.logger.bind(tag=TAG).debug(
@@ -42,17 +45,17 @@ async def handleTextMessage(conn, message):
conn.client_have_voice = False conn.client_have_voice = False
conn.asr_audio.clear() conn.asr_audio.clear()
if "text" in msg_json: if "text" in msg_json:
text = msg_json["text"] original_text = msg_json["text"] # 保留原始文本
_, text = remove_punctuation_and_length(text) filtered_len, filtered_text = remove_punctuation_and_length(original_text)
# 识别是否是唤醒词 # 识别是否是唤醒词
is_wakeup_words = text in conn.config.get("wakeup_words") is_wakeup_words = filtered_text in conn.config.get("wakeup_words")
# 是否开启唤醒词回复 # 是否开启唤醒词回复
enable_greeting = conn.config.get("enable_greeting", True) enable_greeting = conn.config.get("enable_greeting", True)
if is_wakeup_words and not enable_greeting: if is_wakeup_words and not enable_greeting:
# 如果是唤醒词,且关闭了唤醒词回复,就不用回答 # 如果是唤醒词,且关闭了唤醒词回复,就不用回答
await send_stt_message(conn, text) await send_stt_message(conn, original_text)
await send_tts_message(conn, "stop", None) await send_tts_message(conn, "stop", None)
elif is_wakeup_words: elif is_wakeup_words:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据) # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
@@ -60,15 +63,20 @@ async def handleTextMessage(conn, message):
await startToChat(conn, "嘿,你好呀") await startToChat(conn, "嘿,你好呀")
else: else:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据) # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_asr_report(conn, text, []) enqueue_asr_report(conn, original_text, [])
# 否则需要LLM对文字内容进行答复 # 否则需要LLM对文字内容进行答复
await startToChat(conn, text) await startToChat(conn, original_text)
elif msg_json["type"] == "iot": elif msg_json["type"] == "iot":
conn.logger.bind(tag=TAG).info(f"收到iot消息:{message}")
if "descriptors" in msg_json: if "descriptors" in msg_json:
asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"])) asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"]))
if "states" in msg_json: if "states" in msg_json:
asyncio.create_task(handleIotStatus(conn, msg_json["states"])) asyncio.create_task(handleIotStatus(conn, msg_json["states"]))
elif msg_json["type"] == "server": elif msg_json["type"] == "server":
# 记录日志时过滤敏感信息
conn.logger.bind(tag=TAG).info(
f"收到服务器消息:{filter_sensitive_info(msg_json)}"
)
# 如果配置是从API读取的,则需要验证secret # 如果配置是从API读取的,则需要验证secret
if not conn.read_config_from_api: if not conn.read_config_from_api:
return return
@@ -95,9 +103,10 @@ async def handleTextMessage(conn, message):
await conn.websocket.send( await conn.websocket.send(
json.dumps( json.dumps(
{ {
"type": "config_update_response", "type": "server",
"status": "error", "status": "error",
"message": "无法获取服务器实例", "message": "无法获取服务器实例",
"content": {"action": "update_config"},
} }
) )
) )
@@ -107,9 +116,10 @@ async def handleTextMessage(conn, message):
await conn.websocket.send( await conn.websocket.send(
json.dumps( json.dumps(
{ {
"type": "config_update_response", "type": "server",
"status": "error", "status": "error",
"message": "更新服务器配置失败", "message": "更新服务器配置失败",
"content": {"action": "update_config"},
} }
) )
) )
@@ -119,9 +129,10 @@ async def handleTextMessage(conn, message):
await conn.websocket.send( await conn.websocket.send(
json.dumps( json.dumps(
{ {
"type": "config_update_response", "type": "server",
"status": "success", "status": "success",
"message": "配置更新成功", "message": "配置更新成功",
"content": {"action": "update_config"},
} }
) )
) )
@@ -130,9 +141,10 @@ async def handleTextMessage(conn, message):
await conn.websocket.send( await conn.websocket.send(
json.dumps( json.dumps(
{ {
"type": "config_update_response", "type": "server",
"status": "error", "status": "error",
"message": f"更新配置失败: {str(e)}", "message": f"更新配置失败: {str(e)}",
"content": {"action": "update_config"},
} }
) )
) )
@@ -53,6 +53,8 @@ class IntentProvider(IntentProviderBase):
prompt = ( prompt = (
"你是一个意图识别助手。请分析用户的最后一句话,判断用户意图并调用相应的函数。\n\n" "你是一个意图识别助手。请分析用户的最后一句话,判断用户意图并调用相应的函数。\n\n"
"- 如果用户使用疑问词(如'怎么''为什么''如何')询问退出相关的问题(例如'怎么退出了?'),注意这不是让你退出,请返回 {'function_call': {'name': 'continue_chat'}\n"
"- 仅当用户明确使用'退出系统''结束对话''我不想和你说话了'等指令时,才触发 handle_exit_intent\n\n"
f"{functions_desc}\n" f"{functions_desc}\n"
"处理步骤:\n" "处理步骤:\n"
"1. 分析用户输入,确定用户意图\n" "1. 分析用户输入,确定用户意图\n"
@@ -218,6 +220,15 @@ class IntentProvider(IntentProviderBase):
f"llm 识别到意图: {function_name}, 参数: {function_args}" f"llm 识别到意图: {function_name}, 参数: {function_args}"
) )
# 如果是继续聊天,清理工具调用相关的历史消息
if function_name == "continue_chat":
# 保留非工具相关的消息
clean_history = [
msg for msg in conn.dialogue.dialogue
if msg.role not in ["tool", "function"]
]
conn.dialogue.dialogue = clean_history
# 添加到缓存 # 添加到缓存
self.intent_cache[cache_key] = { self.intent_cache[cache_key] = {
"intent": intent, "intent": intent,
@@ -21,27 +21,67 @@ class LLMProvider(LLMProviderBase):
api_key="ollama" # Ollama doesn't need an API key but OpenAI client requires one api_key="ollama" # Ollama doesn't need an API key but OpenAI client requires one
) )
# 检查是否是qwen3模型
self.is_qwen3 = self.model_name and self.model_name.lower().startswith("qwen3")
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
try: try:
# 如果是qwen3模型,在用户最后一条消息中添加/no_think指令
if self.is_qwen3:
# 复制对话列表,避免修改原始对话
dialogue_copy = dialogue.copy()
# 找到最后一条用户消息
for i in range(len(dialogue_copy) - 1, -1, -1):
if dialogue_copy[i]["role"] == "user":
# 在用户消息前添加/no_think指令
dialogue_copy[i]["content"] = "/no_think " + dialogue_copy[i]["content"]
logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令")
break
# 使用修改后的对话
dialogue = dialogue_copy
responses = self.client.chat.completions.create( responses = self.client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=dialogue, messages=dialogue,
stream=True stream=True
) )
is_active=True is_active = True
# 用于处理跨chunk的标签
buffer = ""
for chunk in responses: for chunk in responses:
try: try:
delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None
content = delta.content if hasattr(delta, 'content') else '' content = delta.content if hasattr(delta, 'content') else ''
if content: if content:
if '<think>' in content: # 将内容添加到缓冲区
buffer += content
# 处理缓冲区中的标签
while '<think>' in buffer and '</think>' in buffer:
# 找到完整的<think></think>标签并移除
pre = buffer.split('<think>', 1)[0]
post = buffer.split('</think>', 1)[1]
buffer = pre + post
# 处理只有开始标签的情况
if '<think>' in buffer:
is_active = False is_active = False
content = content.split('<think>')[0] buffer = buffer.split('<think>', 1)[0]
if '</think>' in content:
# 处理只有结束标签的情况
if '</think>' in buffer:
is_active = True is_active = True
content = content.split('</think>')[-1] buffer = buffer.split('</think>', 1)[1]
if is_active:
yield content # 如果当前处于活动状态且缓冲区有内容,则输出
if is_active and buffer:
yield buffer
buffer = "" # 清空缓冲区
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error processing chunk: {e}") logger.bind(tag=TAG).error(f"Error processing chunk: {e}")
@@ -51,6 +91,22 @@ class LLMProvider(LLMProviderBase):
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
try: try:
# 如果是qwen3模型,在用户最后一条消息中添加/no_think指令
if self.is_qwen3:
# 复制对话列表,避免修改原始对话
dialogue_copy = dialogue.copy()
# 找到最后一条用户消息
for i in range(len(dialogue_copy) - 1, -1, -1):
if dialogue_copy[i]["role"] == "user":
# 在用户消息前添加/no_think指令
dialogue_copy[i]["content"] = "/no_think " + dialogue_copy[i]["content"]
logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令")
break
# 使用修改后的对话
dialogue = dialogue_copy
stream = self.client.chat.completions.create( stream = self.client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=dialogue, messages=dialogue,
@@ -58,8 +114,49 @@ class LLMProvider(LLMProviderBase):
tools=functions, tools=functions,
) )
is_active = True
buffer = ""
for chunk in stream: for chunk in stream:
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls try:
delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None
content = delta.content if hasattr(delta, 'content') else None
tool_calls = delta.tool_calls if hasattr(delta, 'tool_calls') else None
# 如果是工具调用,直接传递
if tool_calls:
yield None, tool_calls
continue
# 处理文本内容
if content:
# 将内容添加到缓冲区
buffer += content
# 处理缓冲区中的标签
while '<think>' in buffer and '</think>' in buffer:
# 找到完整的<think></think>标签并移除
pre = buffer.split('<think>', 1)[0]
post = buffer.split('</think>', 1)[1]
buffer = pre + post
# 处理只有开始标签的情况
if '<think>' in buffer:
is_active = False
buffer = buffer.split('<think>', 1)[0]
# 处理只有结束标签的情况
if '</think>' in buffer:
is_active = True
buffer = buffer.split('</think>', 1)[1]
# 如果当前处于活动状态且缓冲区有内容,则输出
if is_active and buffer:
yield buffer, None
buffer = "" # 清空缓冲区
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing function chunk: {e}")
continue
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in Ollama function call: {e}") logger.bind(tag=TAG).error(f"Error in Ollama function call: {e}")
@@ -1,4 +1,5 @@
import os import os
import json
import uuid import uuid
import requests import requests
from config.logger import setup_logging from config.logger import setup_logging
@@ -12,11 +13,21 @@ class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file): def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file) super().__init__(config, delete_audio_file)
self.url = config.get("url") self.url = config.get("url")
self.method = config.get("method", "GET")
self.headers = config.get("headers", {}) self.headers = config.get("headers", {})
self.params = config.get("params")
self.format = config.get("format", "wav") self.format = config.get("format", "wav")
self.output_file = config.get("output_dir", "tmp/") self.output_file = config.get("output_dir", "tmp/")
self.params = config.get("params")
if isinstance(self.params, str):
try:
self.params = json.loads(self.params)
except json.JSONDecodeError:
raise ValueError("Custom TTS配置参数出错,无法将字符串解析为对象")
elif not isinstance(self.params, dict):
raise TypeError("Custom TTS配置参数出错, 请参考配置说明")
def generate_filename(self): def generate_filename(self):
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}.{self.format}") return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}.{self.format}")
@@ -27,7 +38,10 @@ class TTSProvider(TTSProviderBase):
v = v.replace("{prompt_text}", text) v = v.replace("{prompt_text}", text)
request_params[k] = v request_params[k] = v
resp = requests.get(self.url, params=request_params, headers=self.headers) if self.method.upper() == "POST":
resp = requests.post(self.url, json=request_params, headers=self.headers)
else:
resp = requests.get(self.url, params=request_params, headers=self.headers)
if resp.status_code == 200: if resp.status_code == 200:
with open(output_file, "wb") as file: with open(output_file, "wb") as file:
file.write(resp.content) file.write(resp.content)
@@ -130,7 +130,7 @@ class TTSProvider(TTSProviderBase):
"yes", "yes",
) )
self.use_memory_cache = config.get("use_memory_cache", "on") self.use_memory_cache = config.get("use_memory_cache", "on")
self.seed = config.get("seed") or None self.seed = int(config.get("seed")) if config.get("seed") else None
self.api_url = config.get("api_url", "http://127.0.0.1:8080/v1/tts") self.api_url = config.get("api_url", "http://127.0.0.1:8080/v1/tts")
def generate_filename(self, extension=".wav"): def generate_filename(self, extension=".wav"):
+36 -1
View File
@@ -9,6 +9,7 @@ import opuslib_next
from pydub import AudioSegment from pydub import AudioSegment
from typing import Dict, Any from typing import Dict, Any
from core.utils import tts, llm, intent, memory, vad, asr from core.utils import tts, llm, intent, memory, vad, asr
import copy
TAG = __name__ TAG = __name__
emoji_map = { emoji_map = {
@@ -319,7 +320,7 @@ def initialize_modules(
modules["memory"] = memory.create_instance( modules["memory"] = memory.create_instance(
memory_type, memory_type,
config["Memory"][select_memory_module], config["Memory"][select_memory_module],
config.get('summaryMemory', None), config.get("summaryMemory", None),
) )
logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}") logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}")
@@ -956,3 +957,37 @@ def check_asr_update(before_config, new_config):
) )
update_asr = current_asr_type != new_asr_type update_asr = current_asr_type != new_asr_type
return update_asr return update_asr
def filter_sensitive_info(config: dict) -> dict:
"""
过滤配置中的敏感信息
Args:
config: 原始配置字典
Returns:
过滤后的配置字典
"""
sensitive_keys = [
"api_key",
"personal_access_token",
"access_token",
"token",
"secret",
"access_key_secret",
"secret_key",
]
def _filter_dict(d: dict) -> dict:
filtered = {}
for k, v in d.items():
if any(sensitive in k.lower() for sensitive in sensitive_keys):
filtered[k] = "***"
elif isinstance(v, dict):
filtered[k] = _filter_dict(v)
elif isinstance(v, list):
filtered[k] = [_filter_dict(i) if isinstance(i, dict) else i for i in v]
else:
filtered[k] = v
return filtered
return _filter_dict(copy.deepcopy(config))
+5 -3
View File
@@ -82,11 +82,13 @@ class WebSocketServer:
if new_config is None: if new_config is None:
self.logger.bind(tag=TAG).error("获取新配置失败") self.logger.bind(tag=TAG).error("获取新配置失败")
return False return False
self.logger.bind(tag=TAG).info(f"获取新配置成功")
# 检查 VAD 和 ASR 类型是否需要更新 # 检查 VAD 和 ASR 类型是否需要更新
update_vad = check_vad_update(self.config, new_config) update_vad = check_vad_update(self.config, new_config)
update_asr = check_asr_update(self.config, new_config) update_asr = check_asr_update(self.config, new_config)
self.logger.bind(tag=TAG).info(
f"检查VAD和ASR类型是否需要更新: {update_vad} {update_asr}"
)
# 更新配置 # 更新配置
self.config = new_config self.config = new_config
# 重新初始化组件 # 重新初始化组件
@@ -114,7 +116,7 @@ class WebSocketServer:
self._intent = modules["intent"] self._intent = modules["intent"]
if "memory" in modules: if "memory" in modules:
self._memory = modules["memory"] self._memory = modules["memory"]
self.logger.bind(tag=TAG).info(f"更新配置任务执行完毕")
return True return True
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"更新服务器配置失败: {str(e)}") self.logger.bind(tag=TAG).error(f"更新服务器配置失败: {str(e)}")

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