mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 23:53:55 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
783f17e9e8 | ||
|
|
9e305c2840 | ||
|
|
78fa5d3110 | ||
|
|
13c0a966ec | ||
|
|
d987619946 | ||
|
|
8fb9498fe6 | ||
|
|
b8f64a6693 | ||
|
|
e31bb047bf | ||
|
|
6897b813a6 | ||
|
|
83f86406b5 | ||
|
|
dde237ff0d | ||
|
|
6a4887e252 | ||
|
|
d945b94910 | ||
|
|
bc7dcf791d | ||
|
|
66c71fe3b7 | ||
|
|
16a7aeec3b | ||
|
|
94605345e5 | ||
|
|
ec9fa32c81 | ||
|
|
264aa15281 | ||
|
|
180a6ff824 | ||
|
|
5904ee1571 | ||
|
|
bc99341f85 | ||
|
|
8b9174b67e | ||
|
|
6c04a6cf69 | ||
|
|
14a63c4b97 | ||
|
|
098d13a34b | ||
|
|
488f247744 | ||
|
|
c74dbf03cd | ||
|
|
1f836c3235 |
@@ -158,8 +158,10 @@ my_wakeup_words.mp3
|
||||
!main/xiaozhi-server/config/assets/bind_code.wav
|
||||
!main/xiaozhi-server/config/assets/bind_not_found.wav
|
||||
!main/xiaozhi-server/config/assets/bind_code/*.wav
|
||||
!main/xiaozhi-server/config/assets/max_output_size.wav
|
||||
main/manager-api/.vscode
|
||||
|
||||
# Ignore webpack cache directory
|
||||
main/manager-web/.webpack_cache/
|
||||
main/xiaozhi-server/mysql
|
||||
uploadfile
|
||||
|
||||
+10
-1
@@ -4,7 +4,16 @@
|
||||
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
|
||||
|
||||
## 第2步 打开配置文件
|
||||
配置好编译环境后,下载虾哥[xiaozhi-esp32](https://github.com/78/xiaozhi-esp32)项目源码,进入虾哥项目,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
|
||||
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
|
||||
|
||||
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
|
||||
|
||||
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
|
||||
|
||||
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
|
||||
|
||||
下载后,解压缩包,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
|
||||
|
||||
|
||||
## 第3步 修改WEBSOCKET地址
|
||||
找到`WEBSOCKET_URL`的`default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
|
||||
|
||||
@@ -84,6 +84,11 @@ public interface Constant {
|
||||
*/
|
||||
String SERVER_SECRET = "server.secret";
|
||||
|
||||
/**
|
||||
* websocket地址
|
||||
*/
|
||||
String SERVER_WEBSOCKET = "server.websocket";
|
||||
|
||||
/**
|
||||
* 是否允许用户注册
|
||||
*/
|
||||
@@ -163,4 +168,9 @@ public interface Constant {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
public static final String VERSION = "0.3.10";
|
||||
}
|
||||
@@ -75,4 +75,25 @@ public class RedisKeys {
|
||||
public static String getTimbreDetailsKey(String id) {
|
||||
return "timbre:details:" + id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取版本号Key
|
||||
*/
|
||||
public static String getVersionKey() {
|
||||
return "sys:version";
|
||||
}
|
||||
|
||||
/**
|
||||
* OTA固件ID的Key
|
||||
*/
|
||||
public static String getOtaIdKey(String uuid) {
|
||||
return "ota:id:" + uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* OTA固件下载次数的Key
|
||||
*/
|
||||
public static String getOtaDownloadCountKey(String uuid) {
|
||||
return "ota:download:count:" + uuid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package xiaozhi.common.redis;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.data.redis.core.HashOperations;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -27,7 +30,7 @@ public class RedisUtils {
|
||||
/**
|
||||
* 过期时长为1小时,单位:秒
|
||||
*/
|
||||
public final static long HOUR_ONE_EXPIRE = 60 * 60 * 1L;
|
||||
public final static long HOUR_ONE_EXPIRE = (long) 60 * 60;
|
||||
/**
|
||||
* 过期时长为6小时,单位:秒
|
||||
*/
|
||||
@@ -124,4 +127,24 @@ public class RedisUtils {
|
||||
public Object rightPop(String key) {
|
||||
return redisTemplate.opsForList().rightPop(key);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 清空所有 Redis 数据库中的所有键
|
||||
*/
|
||||
public void emptyAll() {
|
||||
// Lua 脚本 FLUSHALL是redis清空所有库的命令
|
||||
String luaScript ="redis.call('FLUSHALL')";
|
||||
|
||||
// 创建 DefaultRedisScript 对象
|
||||
DefaultRedisScript<Void> redisScript = new DefaultRedisScript<>();
|
||||
redisScript.setScriptText(luaScript); // 设置 Lua 脚本内容
|
||||
redisScript.setResultType(Void.class); // 设置返回值类型
|
||||
|
||||
// 执行 Lua 脚本
|
||||
List<String> keys = Collections.emptyList(); // 如果脚本不依赖 key,可以传入空列表
|
||||
redisTemplate.execute(redisScript, keys);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.config.service.ConfigService;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
|
||||
@@ -18,8 +21,20 @@ public class SystemInitConfig {
|
||||
@Autowired
|
||||
private ConfigService configService;
|
||||
|
||||
@Autowired
|
||||
private RedisUtils redisUtils;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// 检查版本号
|
||||
String redisVersion = (String) redisUtils.get(RedisKeys.getVersionKey());
|
||||
if (!Constant.VERSION.equals(redisVersion)) {
|
||||
// 如果版本不一致,清空Redis
|
||||
redisUtils.emptyAll();
|
||||
// 存储新版本号
|
||||
redisUtils.set(RedisKeys.getVersionKey(), Constant.VERSION);
|
||||
}
|
||||
|
||||
sysParamsService.initServerSecret();
|
||||
configService.getConfig(false);
|
||||
}
|
||||
|
||||
+11
-2
@@ -91,6 +91,7 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
}
|
||||
throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND, "not found device");
|
||||
}
|
||||
|
||||
// 获取智能体信息
|
||||
AgentEntity agent = agentService.getAgentById(device.getAgentId());
|
||||
if (agent == null) {
|
||||
@@ -104,7 +105,9 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
}
|
||||
// 构建返回数据
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
// 获取单台设备每天最多输出字数
|
||||
String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
|
||||
result.put("device_max_output_size", deviceMaxOutputSize);
|
||||
// 如果客户端已实例化模型,则不返回
|
||||
String alreadySelectedVadModelId = (String) selectedModule.get("VAD");
|
||||
if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) {
|
||||
@@ -267,6 +270,12 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
|
||||
intentLLMModelId = null;
|
||||
}
|
||||
} else if ("function_call".equals(map.get("type"))) {
|
||||
String functionStr = (String) map.get("functions");
|
||||
if (StringUtils.isNotBlank(functionStr)) {
|
||||
String[] functions = functionStr.split("\\;");
|
||||
map.put("functions", functions);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果是LLM类型,且intentLLMModelId不为空,则添加附加模型
|
||||
@@ -282,7 +291,7 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
|
||||
result.put("selected_module", selectedModule);
|
||||
if (StringUtils.isNotBlank(prompt)) {
|
||||
prompt = prompt.replace("{{assistant_name}}", "小智");
|
||||
prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
|
||||
}
|
||||
result.put("prompt", prompt);
|
||||
}
|
||||
|
||||
+30
-3
@@ -15,25 +15,32 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.device.utils.NetworkUtil;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
|
||||
@Tag(name = "设备管理", description = "OTA 相关接口")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/ota/")
|
||||
public class OTAController {
|
||||
private final DeviceService deviceService;
|
||||
private final SysParamsService sysParamsService;
|
||||
|
||||
@Operation(summary = "检查 OTA 版本和设备激活状态")
|
||||
@Operation(summary = "OTA版本和设备激活状态检查")
|
||||
@PostMapping
|
||||
public ResponseEntity<String> checkOTAVersion(
|
||||
@RequestBody DeviceReportReqDTO deviceReportReqDTO,
|
||||
@@ -54,9 +61,29 @@ public class OTAController {
|
||||
return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
|
||||
}
|
||||
|
||||
@Operation(summary = "设备快速检查激活状态")
|
||||
@PostMapping("activate")
|
||||
public ResponseEntity<String> activateDevice(
|
||||
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
|
||||
@Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
|
||||
if (StringUtils.isBlank(deviceId)) {
|
||||
return ResponseEntity.status(202).build();
|
||||
}
|
||||
DeviceEntity device = deviceService.getDeviceByMacAddress(deviceId);
|
||||
if (device == null) {
|
||||
return ResponseEntity.status(202).build();
|
||||
}
|
||||
return ResponseEntity.ok("success");
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<String> getOTAPrompt() {
|
||||
return createResponse(DeviceReportRespDTO.createError("请提交正确的ota参数"));
|
||||
@Hidden
|
||||
public ResponseEntity<String> getOTA() {
|
||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||
return ResponseEntity.ok("OTA接口不正常,缺少websocket地址");
|
||||
}
|
||||
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
package xiaozhi.modules.device.controller;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.Parameters;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.validator.ValidatorUtils;
|
||||
import xiaozhi.modules.device.entity.OtaEntity;
|
||||
import xiaozhi.modules.device.service.OtaService;
|
||||
|
||||
@Tag(name = "设备管理", description = "OTA 相关接口")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/otaMag")
|
||||
public class OTAMagController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(OTAController.class);
|
||||
private final OtaService otaService;
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询 OTA 固件信息")
|
||||
@Parameters({
|
||||
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
|
||||
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true)
|
||||
})
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<PageData<OtaEntity>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
|
||||
ValidatorUtils.validateEntity(params);
|
||||
PageData<OtaEntity> page = otaService.page(params);
|
||||
return new Result<PageData<OtaEntity>>().ok(page);
|
||||
}
|
||||
|
||||
@GetMapping("{id}")
|
||||
@Operation(summary = "信息 OTA 固件信息")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<OtaEntity> get(@PathVariable("id") String id) {
|
||||
OtaEntity data = otaService.selectById(id);
|
||||
return new Result<OtaEntity>().ok(data);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "保存 OTA 固件信息")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<Void> save(@RequestBody OtaEntity entity) {
|
||||
if (entity == null) {
|
||||
return new Result<Void>().error("固件信息不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(entity.getFirmwareName())) {
|
||||
return new Result<Void>().error("固件名称不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(entity.getType())) {
|
||||
return new Result<Void>().error("固件类型不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(entity.getVersion())) {
|
||||
return new Result<Void>().error("版本号不能为空");
|
||||
}
|
||||
try {
|
||||
otaService.save(entity);
|
||||
return new Result<Void>();
|
||||
} catch (RuntimeException e) {
|
||||
return new Result<Void>().error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "OTA 删除")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<Void> delete(@PathVariable("id") String[] ids) {
|
||||
if (ids == null || ids.length == 0) {
|
||||
return new Result<Void>().error("删除的固件ID不能为空");
|
||||
}
|
||||
otaService.delete(ids);
|
||||
return new Result<Void>();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "修改 OTA 固件信息")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<?> update(@PathVariable("id") String id, @RequestBody OtaEntity entity) {
|
||||
if (entity == null) {
|
||||
return new Result<>().error("固件信息不能为空");
|
||||
}
|
||||
entity.setId(id);
|
||||
try {
|
||||
otaService.update(entity);
|
||||
return new Result<>();
|
||||
} catch (RuntimeException e) {
|
||||
return new Result<>().error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/getDownloadUrl/{id}")
|
||||
@Operation(summary = "获取 OTA 固件下载链接")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<String> getDownloadUrl(@PathVariable("id") String id) {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
redisUtils.set(RedisKeys.getOtaIdKey(uuid), id);
|
||||
return new Result<String>().ok(uuid);
|
||||
}
|
||||
|
||||
@GetMapping("/download/{uuid}")
|
||||
@Operation(summary = "下载固件文件")
|
||||
public ResponseEntity<byte[]> downloadFirmware(@PathVariable("uuid") String uuid) {
|
||||
String id = (String) redisUtils.get(RedisKeys.getOtaIdKey(uuid));
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// 检查下载次数
|
||||
String downloadCountKey = RedisKeys.getOtaDownloadCountKey(uuid);
|
||||
Integer downloadCount = (Integer) redisUtils.get(downloadCountKey);
|
||||
if (downloadCount == null) {
|
||||
downloadCount = 0;
|
||||
}
|
||||
|
||||
// 如果下载次数超过3次,返回404
|
||||
if (downloadCount >= 3) {
|
||||
redisUtils.delete(downloadCountKey);
|
||||
redisUtils.delete(RedisKeys.getOtaIdKey(uuid));
|
||||
logger.warn("Download limit exceeded for UUID: {}", uuid);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
redisUtils.set(downloadCountKey, downloadCount + 1);
|
||||
|
||||
try {
|
||||
// 获取固件信息
|
||||
OtaEntity otaEntity = otaService.selectById(id);
|
||||
if (otaEntity == null || StringUtils.isBlank(otaEntity.getFirmwarePath())) {
|
||||
logger.warn("Firmware not found or path is empty for ID: {}", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// 获取文件路径 - 确保路径是绝对路径或正确的相对路径
|
||||
String firmwarePath = otaEntity.getFirmwarePath();
|
||||
Path path;
|
||||
|
||||
// 检查是否是绝对路径
|
||||
if (Paths.get(firmwarePath).isAbsolute()) {
|
||||
path = Paths.get(firmwarePath);
|
||||
} else {
|
||||
// 如果是相对路径,则从当前工作目录解析
|
||||
path = Paths.get(System.getProperty("user.dir"), firmwarePath);
|
||||
}
|
||||
|
||||
logger.info("Attempting to download firmware for ID: {}, DB path: {}, resolved path: {}",
|
||||
id, firmwarePath, path.toAbsolutePath());
|
||||
|
||||
if (!Files.exists(path) || !Files.isRegularFile(path)) {
|
||||
// 尝试直接从firmware目录下查找文件名
|
||||
String fileName = new File(firmwarePath).getName();
|
||||
Path altPath = Paths.get(System.getProperty("user.dir"), "firmware", fileName);
|
||||
|
||||
logger.info("File not found at primary path, trying alternative path: {}", altPath.toAbsolutePath());
|
||||
|
||||
if (Files.exists(altPath) && Files.isRegularFile(altPath)) {
|
||||
path = altPath;
|
||||
} else {
|
||||
logger.error("Firmware file not found at either path: {} or {}",
|
||||
path.toAbsolutePath(), altPath.toAbsolutePath());
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
byte[] fileContent = Files.readAllBytes(path);
|
||||
|
||||
// 设置响应头
|
||||
String originalFilename = otaEntity.getType() + "_" + otaEntity.getVersion();
|
||||
if (firmwarePath.contains(".")) {
|
||||
String extension = firmwarePath.substring(firmwarePath.lastIndexOf("."));
|
||||
originalFilename += extension;
|
||||
}
|
||||
|
||||
// 清理文件名,移除不安全字符
|
||||
String safeFilename = originalFilename.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
|
||||
logger.info("Providing download for firmware ID: {}, filename: {}, size: {} bytes",
|
||||
id, safeFilename, fileContent.length);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeFilename + "\"")
|
||||
.body(fileContent);
|
||||
} catch (IOException e) {
|
||||
logger.error("Error reading firmware file for ID: {}", id, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error during firmware download for ID: {}", id, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "上传固件文件")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
public Result<String> uploadFirmware(@RequestParam("file") MultipartFile file) {
|
||||
if (file.isEmpty()) {
|
||||
return new Result<String>().error("上传文件不能为空");
|
||||
}
|
||||
|
||||
// 检查文件扩展名
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null) {
|
||||
return new Result<String>().error("文件名不能为空");
|
||||
}
|
||||
|
||||
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
|
||||
if (!extension.equals(".bin") && !extension.equals(".apk")) {
|
||||
return new Result<String>().error("只允许上传.bin和.apk格式的文件");
|
||||
}
|
||||
|
||||
try {
|
||||
// 计算文件的MD5值
|
||||
String md5 = calculateMD5(file);
|
||||
|
||||
// 设置存储路径
|
||||
String uploadDir = "uploadfile";
|
||||
Path uploadPath = Paths.get(uploadDir);
|
||||
|
||||
// 如果目录不存在,创建目录
|
||||
if (!Files.exists(uploadPath)) {
|
||||
Files.createDirectories(uploadPath);
|
||||
}
|
||||
|
||||
// 使用MD5作为文件名,固定使用.bin扩展名
|
||||
String uniqueFileName = md5 + extension;
|
||||
Path filePath = uploadPath.resolve(uniqueFileName);
|
||||
|
||||
// 检查文件是否已存在
|
||||
if (Files.exists(filePath)) {
|
||||
return new Result<String>().ok(filePath.toString());
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
Files.copy(file.getInputStream(), filePath);
|
||||
|
||||
// 返回文件路径
|
||||
return new Result<String>().ok(filePath.toString());
|
||||
} catch (IOException | NoSuchAlgorithmException e) {
|
||||
return new Result<String>().error("文件上传失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(file.getBytes());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package xiaozhi.modules.device.dao;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
import xiaozhi.modules.device.entity.OtaEntity;
|
||||
|
||||
/**
|
||||
* OTA固件管理
|
||||
*/
|
||||
@Mapper
|
||||
public interface OtaDao extends BaseMapper<OtaEntity> {
|
||||
|
||||
}
|
||||
@@ -19,6 +19,9 @@ public class DeviceReportRespDTO {
|
||||
|
||||
@Schema(description = "固件版本信息")
|
||||
private Firmware firmware;
|
||||
|
||||
@Schema(description = "WebSocket配置")
|
||||
private Websocket websocket;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -44,6 +47,8 @@ public class DeviceReportRespDTO {
|
||||
@Schema(description = "激活码信息: 激活地址")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "挑战码")
|
||||
private String challenge;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@@ -58,4 +63,11 @@ public class DeviceReportRespDTO {
|
||||
@Schema(description = "时区偏移量,单位为分钟")
|
||||
private Integer timezone_offset;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Websocket {
|
||||
@Schema(description = "WebSocket服务器地址")
|
||||
private String url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package xiaozhi.modules.device.entity;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("ai_ota")
|
||||
@Schema(description = "固件信息")
|
||||
public class OtaEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
@Schema(description = "ID")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "固件名称")
|
||||
private String firmwareName;
|
||||
|
||||
@Schema(description = "固件类型")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "版本号")
|
||||
private String version;
|
||||
|
||||
@Schema(description = "文件大小(字节)")
|
||||
private Long size;
|
||||
|
||||
@Schema(description = "备注/说明")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "固件路径")
|
||||
private String firmwarePath;
|
||||
|
||||
@Schema(description = "排序")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "更新者")
|
||||
@TableField(fill = FieldFill.UPDATE)
|
||||
private Long updater;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
@TableField(fill = FieldFill.UPDATE)
|
||||
private Date updateDate;
|
||||
|
||||
@Schema(description = "创建者")
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Long creator;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Date createDate;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package xiaozhi.modules.device.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.BaseService;
|
||||
import xiaozhi.modules.device.entity.OtaEntity;
|
||||
|
||||
/**
|
||||
* OTA固件管理
|
||||
*/
|
||||
public interface OtaService extends BaseService<OtaEntity> {
|
||||
PageData<OtaEntity> page(Map<String, Object> params);
|
||||
|
||||
boolean save(OtaEntity entity);
|
||||
|
||||
void update(OtaEntity entity);
|
||||
|
||||
void delete(String[] ids);
|
||||
}
|
||||
+33
-5
@@ -18,6 +18,7 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
@@ -36,6 +37,7 @@ import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
import xiaozhi.modules.sys.service.SysUserUtilService;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService {
|
||||
|
||||
@@ -43,18 +45,18 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
private final SysUserUtilService sysUserUtilService;
|
||||
|
||||
private final String frontedUrl;
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private final SysParamsService sysParamsService;
|
||||
|
||||
// 添加构造函数来初始化 deviceMapper
|
||||
public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
|
||||
SysParamsService sysParamsService,
|
||||
RedisTemplate<String, Object> redisTemplate) {
|
||||
this.deviceDao = deviceDao;
|
||||
this.sysUserUtilService = sysUserUtilService;
|
||||
this.frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.sysParamsService = sysParamsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -132,6 +134,27 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
|
||||
response.setFirmware(firmware);
|
||||
|
||||
// 添加WebSocket配置
|
||||
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
|
||||
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
|
||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||
log.error("WebSocket URL is not configured");
|
||||
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
|
||||
websocket.setUrl(wsUrl);
|
||||
} else {
|
||||
String[] wsUrls = wsUrl.split("\\;");
|
||||
if (wsUrls.length > 0) {
|
||||
// 随机选择一个WebSocket URL
|
||||
websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
|
||||
} else {
|
||||
log.error("WebSocket URL list is empty");
|
||||
websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
|
||||
}
|
||||
}
|
||||
|
||||
response.setWebsocket(websocket);
|
||||
|
||||
DeviceEntity deviceById = getDeviceById(macAddress);
|
||||
if (deviceById != null) { // 如果设备存在,则更新上次连接时间
|
||||
deviceById.setLastConnectedAt(new Date());
|
||||
@@ -250,18 +273,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
if (StringUtils.isNotBlank(cachedCode)) {
|
||||
code.setCode(cachedCode);
|
||||
String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
|
||||
code.setMessage(frontedUrl + "\n" + cachedCode);
|
||||
code.setChallenge(deviceId);
|
||||
} else {
|
||||
String newCode = RandomUtil.randomNumbers(6);
|
||||
code.setCode(newCode);
|
||||
String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
|
||||
code.setMessage(frontedUrl + "\n" + newCode);
|
||||
code.setChallenge(deviceId);
|
||||
|
||||
Map<String, Object> dataMap = new HashMap<>();
|
||||
dataMap.put("id", deviceId);
|
||||
dataMap.put("mac_address", deviceId);
|
||||
|
||||
dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName()
|
||||
: (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown"));
|
||||
dataMap.put("board", (deviceReport.getBoard() != null && deviceReport.getBoard().getType() != null)
|
||||
? deviceReport.getBoard().getType()
|
||||
: (deviceReport.getChipModelName() != null ? deviceReport.getChipModelName() : "unknown"));
|
||||
dataMap.put("app_version", (deviceReport.getApplication() != null)
|
||||
? deviceReport.getApplication().getVersion()
|
||||
: null);
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import io.micrometer.common.util.StringUtils;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.modules.device.dao.OtaDao;
|
||||
import xiaozhi.modules.device.entity.OtaEntity;
|
||||
import xiaozhi.modules.device.service.OtaService;
|
||||
|
||||
@Service
|
||||
public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implements OtaService {
|
||||
|
||||
@Override
|
||||
public PageData<OtaEntity> page(Map<String, Object> params) {
|
||||
IPage<OtaEntity> page = baseDao.selectPage(
|
||||
getPage(params, "update_date", true),
|
||||
getWrapper(params));
|
||||
|
||||
return new PageData<>(page.getRecords(), page.getTotal());
|
||||
}
|
||||
|
||||
private QueryWrapper<OtaEntity> getWrapper(Map<String, Object> params) {
|
||||
String firmwareName = (String) params.get("firmwareName");
|
||||
|
||||
QueryWrapper<OtaEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.like(StringUtils.isNotBlank(firmwareName), "firmware_name", firmwareName);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(OtaEntity entity) {
|
||||
// 检查是否存在相同名称、类型和版本的固件(排除当前记录)
|
||||
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
|
||||
.eq("firmware_name", entity.getFirmwareName())
|
||||
.eq("type", entity.getType())
|
||||
.eq("version", entity.getVersion())
|
||||
.ne("id", entity.getId()); // 排除当前记录
|
||||
|
||||
if (baseDao.selectCount(queryWrapper) > 0) {
|
||||
throw new RuntimeException("已存在相同名称、类型和版本的固件,请修改后重试");
|
||||
}
|
||||
|
||||
baseDao.updateById(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String[] ids) {
|
||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean save(OtaEntity entity) {
|
||||
// 检查是否存在相同名称、类型和版本的固件
|
||||
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
|
||||
.eq("firmware_name", entity.getFirmwareName())
|
||||
.eq("type", entity.getType())
|
||||
.eq("version", entity.getVersion());
|
||||
|
||||
if (baseDao.selectCount(queryWrapper) > 0) {
|
||||
throw new RuntimeException("已存在相同名称、类型和版本的固件,请勿重复添加");
|
||||
}
|
||||
|
||||
return baseDao.insert(entity) > 0;
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ public class ShiroConfig {
|
||||
*/
|
||||
Map<String, String> filterMap = new LinkedHashMap<>();
|
||||
filterMap.put("/ota/**", "anon");
|
||||
filterMap.put("/otaMag/download/**", "anon");
|
||||
filterMap.put("/webjars/**", "anon");
|
||||
filterMap.put("/druid/**", "anon");
|
||||
filterMap.put("/v3/api-docs/**", "anon");
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.TokenDTO;
|
||||
@@ -121,7 +122,7 @@ public class LoginController {
|
||||
@Operation(summary = "公共配置")
|
||||
public Result<Map<String, Object>> pubConfig() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("version", "0.3.6");
|
||||
config.put("version", Constant.VERSION);
|
||||
config.put("allowUserRegister", sysUserService.getAllowUserRegister());
|
||||
return new Result<Map<String, Object>>().ok(config);
|
||||
}
|
||||
|
||||
+35
@@ -2,6 +2,7 @@ package xiaozhi.modules.sys.controller;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -20,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.annotation.LogOperation;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.validator.AssertUtils;
|
||||
@@ -30,6 +32,7 @@ import xiaozhi.common.validator.group.UpdateGroup;
|
||||
import xiaozhi.modules.config.service.ConfigService;
|
||||
import xiaozhi.modules.sys.dto.SysParamsDTO;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
import xiaozhi.modules.sys.utils.WebSocketValidator;
|
||||
|
||||
/**
|
||||
* 参数管理
|
||||
@@ -91,11 +94,43 @@ public class SysParamsController {
|
||||
// 效验数据
|
||||
ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class);
|
||||
|
||||
// 验证WebSocket地址列表
|
||||
validateWebSocketUrls(dto.getParamCode(), dto.getParamValue());
|
||||
|
||||
sysParamsService.update(dto);
|
||||
configService.getConfig(false);
|
||||
return new Result<Void>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证WebSocket地址列表
|
||||
*
|
||||
* @param urls WebSocket地址列表,以分号分隔
|
||||
* @return 验证结果
|
||||
*/
|
||||
private void validateWebSocketUrls(String paramCode, String urls) {
|
||||
if (!paramCode.equals(Constant.SERVER_WEBSOCKET)) {
|
||||
return;
|
||||
}
|
||||
String[] wsUrls = urls.split("\\;");
|
||||
if (wsUrls.length == 0) {
|
||||
throw new RenException("WebSocket地址列表不能为空");
|
||||
}
|
||||
for (String url : wsUrls) {
|
||||
if (StringUtils.isNotBlank(url)) {
|
||||
// 验证WebSocket地址格式
|
||||
if (!WebSocketValidator.validateUrlFormat(url)) {
|
||||
throw new RenException("WebSocket地址格式不正确: " + url);
|
||||
}
|
||||
|
||||
// 测试WebSocket连接
|
||||
if (!WebSocketValidator.testConnection(url)) {
|
||||
throw new RenException("WebSocket连接测试失败: " + url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
@Operation(summary = "删除")
|
||||
@LogOperation("删除")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package xiaozhi.modules.sys.utils;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.WebSocketHandler;
|
||||
import org.springframework.web.socket.WebSocketMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
|
||||
public class WebSocketTestHandler implements WebSocketHandler {
|
||||
private final CompletableFuture<Boolean> future;
|
||||
|
||||
public WebSocketTestHandler(CompletableFuture<Boolean> future) {
|
||||
this.future = future;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) {
|
||||
future.complete(true);
|
||||
try {
|
||||
session.close();
|
||||
} catch (Exception e) {
|
||||
// 忽略关闭异常
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) {
|
||||
// 不需要处理消息
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) {
|
||||
future.complete(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
|
||||
// 连接关闭时不做任何处理
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPartialMessages() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package xiaozhi.modules.sys.utils;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.socket.client.WebSocketClient;
|
||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||
|
||||
public class WebSocketValidator {
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebSocketValidator.class);
|
||||
|
||||
// WebSocket URL正则表达式
|
||||
private static final Pattern WS_URL_PATTERN = Pattern
|
||||
.compile("^wss?://[\\w.-]+(?:\\.[\\w.-]+)*(?::\\d+)?(?:/[\\w.-]*)*$");
|
||||
|
||||
/**
|
||||
* 验证WebSocket地址格式
|
||||
*
|
||||
* @param url WebSocket地址
|
||||
* @return 是否有效
|
||||
*/
|
||||
public static boolean validateUrlFormat(String url) {
|
||||
if (StringUtils.isBlank(url)) {
|
||||
return false;
|
||||
}
|
||||
return WS_URL_PATTERN.matcher(url.trim()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试WebSocket连接
|
||||
*
|
||||
* @param url WebSocket地址
|
||||
* @return 是否可连接
|
||||
*/
|
||||
public static boolean testConnection(String url) {
|
||||
if (!validateUrlFormat(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
WebSocketClient client = new StandardWebSocketClient();
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
|
||||
client.doHandshake(new WebSocketTestHandler(future), null, URI.create(url));
|
||||
|
||||
// 等待最多5秒获取连接结果
|
||||
return future.get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
logger.error("WebSocket连接测试失败: {}", url, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ public class TimbreDataDTO {
|
||||
private String name;
|
||||
|
||||
@Schema(description = "备注")
|
||||
@NotBlank(message = "{timbre.remark.require}")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "排序")
|
||||
@@ -40,6 +39,5 @@ public class TimbreDataDTO {
|
||||
private String ttsVoice;
|
||||
|
||||
@Schema(description = "音频播放地址")
|
||||
@NotBlank(message = "{timbre.voiceDemo.require}")
|
||||
private String voiceDemo;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 对0.3.0版本之前的参数进行修改
|
||||
update `sys_params` set param_value = '.mp3;.wav;.p3' where param_code = 'plugins.play_music.music_ext';
|
||||
update `ai_model_config` set config_json = '{\"type\": \"intent_llm\", \"llm\": \"LLM_ChatGLMLLM\"}' where id = 'Intent_intent_llm';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 本文件用于初始化固件信息表,无需手动执行,在项目启动时会自动执行
|
||||
-- -------------------------------------------------------
|
||||
-- 初始化固件信息表
|
||||
drop table if exists `ai_ota`;
|
||||
CREATE TABLE `ai_ota` (
|
||||
`id` varchar(32) NOT NULL COMMENT 'ID',
|
||||
`firmware_name` varchar(100) DEFAULT NULL COMMENT '固件名称',
|
||||
`type` varchar(50) DEFAULT NULL COMMENT '固件类型',
|
||||
`version` varchar(50) DEFAULT NULL COMMENT '版本号',
|
||||
`size` bigint DEFAULT NULL COMMENT '文件大小(字节)',
|
||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注/说明',
|
||||
`firmware_path` varchar(255) DEFAULT NULL COMMENT '固件路径',
|
||||
`sort` int unsigned DEFAULT '0' COMMENT '排序',
|
||||
`updater` bigint DEFAULT NULL COMMENT '更新者',
|
||||
`update_date` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`creator` bigint DEFAULT NULL COMMENT '创建者',
|
||||
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='固件信息表';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 调整意图识别配置
|
||||
delete from `ai_model_config` where id = 'Intent_function_call';
|
||||
INSERT INTO `ai_model_config` VALUES ('Intent_function_call', 'Intent', 'function_call', '函数调用意图识别', 0, 1, '{\"type\": \"function_call\", \"functions\": \"change_role;get_weather;get_news;play_music\"}', NULL, NULL, 3, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 增加单台设备每天最多聊天句数
|
||||
delete from `sys_params` where id = 105;
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (105, 'device_max_output_size', '0', 'number', 1, '单台设备每天最多输出字数,0表示不限制');
|
||||
@@ -0,0 +1,88 @@
|
||||
-- 删除无用模型供应器
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_LLM_doubao';
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_LLM_chatglm';
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_TTS_302ai';
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_TTS_gizwits';
|
||||
|
||||
-- 添加模型供应器
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_ASR_TencentASR';
|
||||
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||
('SYSTEM_ASR_TencentASR', 'ASR', 'tencent', '腾讯语音识别', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 4, 1, NOW(), 1, NOW());
|
||||
|
||||
-- 添加腾讯语音合成模型供应器
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_TTS_TencentTTS';
|
||||
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||
('SYSTEM_TTS_TencentTTS', 'TTS', 'tencent', '腾讯语音合成', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"region","label":"区域","type":"string"},{"key":"voice","label":"音色ID","type":"string"}]', 5, 1, NOW(), 1, NOW());
|
||||
|
||||
|
||||
-- 添加edge音色
|
||||
delete from `ai_tts_voice` where id in ('TTS_EdgeTTS0001', 'TTS_EdgeTTS0002', 'TTS_EdgeTTS0003', 'TTS_EdgeTTS0004', 'TTS_EdgeTTS0005', 'TTS_EdgeTTS0006', 'TTS_EdgeTTS0007', 'TTS_EdgeTTS0008', 'TTS_EdgeTTS0009', 'TTS_EdgeTTS0010', 'TTS_EdgeTTS0011');
|
||||
INSERT INTO `ai_tts_voice` VALUES
|
||||
('TTS_EdgeTTS0001', 'TTS_EdgeTTS', 'EdgeTTS女声-晓晓', 'zh-CN-XiaoxiaoNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0002', 'TTS_EdgeTTS', 'EdgeTTS男声-云扬', 'zh-CN-YunyangNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0003', 'TTS_EdgeTTS', 'EdgeTTS女声-晓伊', 'zh-CN-XiaoyiNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0004', 'TTS_EdgeTTS', 'EdgeTTS男声-云健', 'zh-CN-YunjianNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0005', 'TTS_EdgeTTS', 'EdgeTTS男声-云希', 'zh-CN-YunxiNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0006', 'TTS_EdgeTTS', 'EdgeTTS男声-云夏', 'zh-CN-YunxiaNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0007', 'TTS_EdgeTTS', 'EdgeTTS女声-辽宁小贝', 'zh-CN-liaoning-XiaobeiNeural', '辽宁', NULL, NULL, 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0008', 'TTS_EdgeTTS', 'EdgeTTS女声-陕西小妮', 'zh-CN-shaanxi-XiaoniNeural', '陕西', NULL, NULL, 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0009', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海佳', 'zh-HK-HiuGaaiNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0010', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海曼', 'zh-HK-HiuMaanNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
|
||||
('TTS_EdgeTTS0011', 'TTS_EdgeTTS', 'EdgeTTS男声-香港万龙', 'zh-HK-WanLungNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- DoubaoTTS音色
|
||||
delete from `ai_tts_voice` where id in ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS0002', 'TTS_DoubaoTTS0003', 'TTS_DoubaoTTS0004', 'TTS_DoubaoTTS0005');
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS', '通用女声', 'BV001_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV001.mp3', NULL, 3, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0002', 'TTS_DoubaoTTS', '通用男声', 'BV002_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV002.mp3', NULL, 2, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0003', 'TTS_DoubaoTTS', '阳光男生', 'BV056_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV056.mp3', NULL, 4, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0004', 'TTS_DoubaoTTS', '奶气萌娃', 'BV051_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV051.mp3', NULL, 5, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0005', 'TTS_DoubaoTTS', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '普通话', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 6, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 修正CosyVoiceSiliconflow音色
|
||||
delete from `ai_tts_voice` where id in ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow0002');
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'FunAudioLLM/CosyVoice2-0.5B:alex', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0002', 'TTS_CosyVoiceSiliconflow', 'CosyVoice女声', 'FunAudioLLM/CosyVoice2-0.5B:bella', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- CozeCnTTS音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_CozeCnTTS0001';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_CozeCnTTS0001', 'TTS_CozeCnTTS', 'CozeCn音色', '7426720361733046281', '中文', NULL, NULL, 7, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- MinimaxTTS音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_MinimaxTTS0001';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxTTS0001', 'TTS_MinimaxTTS', 'Minimax少女音', 'female-shaonv', '中文', NULL, NULL, 8, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- AliyunTTS音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_AliyunTTS0001';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunTTS0001', 'TTS_AliyunTTS', '阿里云小云', 'xiaoyun', '中文', NULL, NULL, 9, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- TTS302AI音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_TTS302AI0001';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_TTS302AI0001', 'TTS_TTS302AI', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 10, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- GizwitsTTS音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_GizwitsTTS0001';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_GizwitsTTS0001', 'TTS_GizwitsTTS', '机智云湾湾', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 11, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- ACGNTTS音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_ACGNTTS0001';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_ACGNTTS0001', 'TTS_ACGNTTS', 'ACG音色', '1695', '中文', NULL, NULL, 12, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- OpenAITTS音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_OpenAITTS0001';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_OpenAITTS0001', 'TTS_OpenAITTS', 'OpenAI男声', 'onyx', '中文', NULL, NULL, 13, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 添加腾讯语音合成音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_TencentTTS0001';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_TencentTTS0001', 'TTS_TencentTTS', '智瑜', '101001', '中文', NULL, NULL, 1, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 其他音色
|
||||
delete from `ai_tts_voice` where id = 'TTS_FishSpeech0000';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_FishSpeech0000', 'TTS_FishSpeech', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
|
||||
delete from `ai_tts_voice` where id = 'TTS_GPT_SOVITS_V20000';
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V20000', 'TTS_GPT_SOVITS_V2', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
|
||||
delete from `ai_tts_voice` where id in ('TTS_GPT_SOVITS_V30000', 'TTS_CustomTTS0000');
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V30000', 'TTS_GPT_SOVITS_V3', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_CustomTTS0000', 'TTS_CustomTTS', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 删除server.ip和server.port,改成在xiaozhi-server中配置,方便运行多个websocket
|
||||
delete from `sys_params` where id = 100;
|
||||
delete from `sys_params` where id = 101;
|
||||
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (106, 'server.websocket', 'null', 'string', 1, 'websocket地址,多个用;分隔');
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
-- 本文件用于初始化音色数据,无需手动执行,在项目启动时会自动执行
|
||||
-- ----------------------------------------------------
|
||||
|
||||
-- 初始化音色数据
|
||||
DELETE FROM `ai_tts_voice`;
|
||||
|
||||
-- EdgeTTS音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_EdgeTTS0001', 'TTS_EdgeTTS', 'EdgeTTS女声', 'zh-CN-XiaoxiaoNeural', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/EdgeTTS.mp3', NULL, 1, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- DoubaoTTS音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS', '通用女声', 'BV001_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV001.mp3', NULL, 3, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0002', 'TTS_DoubaoTTS', '通用男声', 'BV002_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV002.mp3', NULL, 2, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0003', 'TTS_DoubaoTTS', '阳光男生', 'BV056_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV056.mp3', NULL, 4, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0004', 'TTS_DoubaoTTS', '奶气萌娃', 'BV051_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV051.mp3', NULL, 5, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- CosyVoiceSiliconflow音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'alex', '中文', 'https://example.com/cosyvoice/alex.mp3', NULL, 6, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- CozeCnTTS音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_CozeCnTTS0001', 'TTS_CozeCnTTS', 'CozeCn音色', '7426720361733046281', '中文', 'https://example.com/cozecn/voice.mp3', NULL, 7, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- MinimaxTTS音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxTTS0001', 'TTS_MinimaxTTS', 'Minimax少女音', 'female-shaonv', '中文', 'https://example.com/minimax/female-shaonv.mp3', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- AliyunTTS音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunTTS0001', 'TTS_AliyunTTS', '阿里云小云', 'xiaoyun', '中文', 'https://example.com/aliyun/xiaoyun.mp3', NULL, 9, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- TTS302AI音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_TTS302AI0001', 'TTS_TTS302AI', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://example.com/302ai/wanwanxiaohe.mp3', NULL, 10, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- GizwitsTTS音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_GizwitsTTS0001', 'TTS_GizwitsTTS', '机智云湾湾', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://example.com/gizwits/wanwanxiaohe.mp3', NULL, 11, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- ACGNTTS音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_ACGNTTS0001', 'TTS_ACGNTTS', 'ACG音色', '1695', '中文', 'https://example.com/acgn/voice.mp3', NULL, 12, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- OpenAITTS音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_OpenAITTS0001', 'TTS_OpenAITTS', 'OpenAI男声', 'onyx', '中文', 'https://example.com/openai/onyx.mp3', NULL, 13, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- 其他音色
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_FishSpeech0000', 'TTS_FishSpeech', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V20000', 'TTS_GPT_SOVITS_V2', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V30000', 'TTS_GPT_SOVITS_V3', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `ai_tts_voice` VALUES ('TTS_CustomTTS0000', 'TTS_CustomTTS', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
|
||||
@@ -16,13 +16,6 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202503141346.sql
|
||||
- changeSet:
|
||||
id: 2025_tts_voive
|
||||
author: John
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/2025_tts_voive.sql
|
||||
- changeSet:
|
||||
id: 202504082211
|
||||
author: John
|
||||
@@ -58,3 +51,31 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202504151206.sql
|
||||
- changeSet:
|
||||
id: 202504181536
|
||||
author: John
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202504181536.sql
|
||||
- changeSet:
|
||||
id: 202504180758
|
||||
author: jiangkunyin
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202504180758.sql
|
||||
- changeSet:
|
||||
id: 202504221135
|
||||
author: John
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202504221135.sql
|
||||
- changeSet:
|
||||
id: 202504221646
|
||||
author: John
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202504221646.sql
|
||||
@@ -28,10 +28,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
|
||||
|
||||
timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
|
||||
ota.device.not.found=\u8BBE\u5907\u672A\u627E\u5230
|
||||
ota.device.need.bind={0}
|
||||
|
||||
@@ -27,10 +27,8 @@ sysuser.uuid.require=The unique identifier cannot be empty
|
||||
|
||||
timbre.languages.require=The language of the timbre cannot be empty
|
||||
timbre.name.require=The name of the timbre cannot be empty
|
||||
timbre.remark.require=The remark of the timbre cannot be empty
|
||||
timbre.ttsModelId.require=The TTS model ID of the timbre cannot be empty
|
||||
timbre.ttsVoice.require=The TTS voice of the timbre cannot be empty
|
||||
timbre.voiceDemo.require=The voice demo of the timbre cannot be empty
|
||||
|
||||
ota.device.not.found=Device not found
|
||||
ota.device.need.bind={0}
|
||||
@@ -27,10 +27,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
|
||||
|
||||
timbre.languages.require=\u97F3\u8272\u7684\u8A9E\u8A00\u4E0D\u53EF\u4EE5\u70BA\u7A7A
|
||||
timbre.name.require=\u97F3\u8272\u7684\u540D\u7A31\u4E0D\u53EF\u4EE5\u70BA\u7A7A
|
||||
timbre.remark.require=\u97F3\u8272\u7684\u5099\u6CE8\u4E0D\u53EF\u4EE5\u70BA\u7A7A
|
||||
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u9375\u4E0D\u53EF\u4EE5\u70BA\u7A7A
|
||||
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7DE8\u78BC\u4E0D\u53EF\u4EE5\u70BA\u7A7A
|
||||
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653E\u5730\u5740\u4E0D\u53EF\u4EE5\u70BA\u7A7A
|
||||
|
||||
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
|
||||
ota.device.need.bind={0}
|
||||
@@ -27,10 +27,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
|
||||
|
||||
timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
|
||||
|
||||
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
|
||||
ota.device.need.bind={0}
|
||||
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ import device from './module/device.js'
|
||||
import model from './module/model.js'
|
||||
import timbre from "./module/timbre.js"
|
||||
import user from './module/user.js'
|
||||
import ota from './module/ota.js'
|
||||
|
||||
/**
|
||||
* 接口地址
|
||||
@@ -31,4 +32,5 @@ export default {
|
||||
device,
|
||||
model,
|
||||
timbre,
|
||||
ota
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { getServiceUrl } from '../api';
|
||||
import RequestService from '../httpRequest';
|
||||
|
||||
export default {
|
||||
// 分页查询OTA固件信息
|
||||
getOtaList(params, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/otaMag`)
|
||||
.method('GET')
|
||||
.data(params)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取OTA固件列表失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getOtaList(params, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取单个OTA固件信息
|
||||
getOtaInfo(id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/otaMag/${id}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取OTA固件信息失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getOtaInfo(id, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 保存OTA固件信息
|
||||
saveOta(entity, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/otaMag`)
|
||||
.method('POST')
|
||||
.data(entity)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('保存OTA固件信息失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.saveOta(entity, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 更新OTA固件信息
|
||||
updateOta(id, entity, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/otaMag/${id}`)
|
||||
.method('PUT')
|
||||
.data(entity)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('更新OTA固件信息失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.updateOta(id, entity, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 删除OTA固件
|
||||
deleteOta(id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/otaMag/${id}`)
|
||||
.method('DELETE')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('删除OTA固件失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.deleteOta(id, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 上传固件文件
|
||||
uploadFirmware(file, callback) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/otaMag/upload`)
|
||||
.method('POST')
|
||||
.data(formData)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('上传固件文件失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.uploadFirmware(file, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取固件下载链接
|
||||
getDownloadUrl(id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/otaMag/getDownloadUrl/${id}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取下载链接失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getDownloadUrl(id, callback);
|
||||
});
|
||||
}).send();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -77,7 +77,12 @@
|
||||
</div>
|
||||
|
||||
<div style="display: flex;justify-content: center;">
|
||||
<el-button type="primary" @click="confirm" class="save-btn">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="confirm"
|
||||
class="save-btn"
|
||||
:loading="saving"
|
||||
:disabled="saving">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -94,6 +99,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
saving: false,
|
||||
providers: [],
|
||||
dialogVisible: false,
|
||||
providersLoaded: false,
|
||||
@@ -175,6 +181,7 @@ export default {
|
||||
},
|
||||
|
||||
handleClose() {
|
||||
this.saving = false;
|
||||
this.$emit('update:visible', false);
|
||||
},
|
||||
initDynamicConfig() {
|
||||
@@ -185,8 +192,11 @@ export default {
|
||||
this.formData.configJson = newConfig;
|
||||
},
|
||||
confirm() {
|
||||
this.saving = true;
|
||||
|
||||
if (!this.formData.supplier) {
|
||||
this.$message.error('请选择供应器');
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,11 +216,18 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
this.$emit('confirm', submitData);
|
||||
this.$emit('update:visible', false);
|
||||
this.resetForm();
|
||||
try {
|
||||
this.$emit('confirm', submitData);
|
||||
this.$emit('update:visible', false);
|
||||
this.resetForm();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
resetForm() {
|
||||
this.saving = false;
|
||||
this.formData = {
|
||||
modelName: '',
|
||||
modelCode: '',
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div style="height: 1px;background: #e8f0ff;" />
|
||||
<div style="margin: 22px 15px;">
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
|
||||
<div style="color: red;display: inline-block;">*</div> 智慧体名称:
|
||||
<div style="color: red;display: inline-block;">*</div> 智能体名称:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input ref="inputRef" placeholder="请输入智能体名称.." v-model="wisdomBodyName" @keyup.enter.native="confirm" />
|
||||
|
||||
@@ -23,12 +23,6 @@
|
||||
<div class="settings-btn" @click="handleConfigure">
|
||||
配置角色
|
||||
</div>
|
||||
<!-- <div class="settings-btn">-->
|
||||
<!-- 声纹识别-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="settings-btn">-->
|
||||
<!-- 历史对话-->
|
||||
<!-- </div>-->
|
||||
<div class="settings-btn" @click="handleDeviceManage">
|
||||
设备管理({{ device.deviceCount }})
|
||||
</div>
|
||||
@@ -80,7 +74,7 @@ export default {
|
||||
|
||||
.settings-btn {
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
color: #5778ff;
|
||||
background: #e6ebff;
|
||||
width: auto;
|
||||
@@ -95,7 +89,7 @@ export default {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 15px;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
color: #979db1;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="固件名称" prop="firmwareName">
|
||||
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="固件类型" prop="type">
|
||||
<el-select v-model="form.type" placeholder="请选择固件类型" style="width: 100%;" filterable>
|
||||
<el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本号" prop="version">
|
||||
<el-input v-model="form.version" placeholder="请输入版本号(x.x.x格式)"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="固件文件" prop="firmwarePath">
|
||||
<el-upload class="upload-demo" action="#" :http-request="handleUpload" :before-upload="beforeUpload"
|
||||
:accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true">
|
||||
<el-button size="small" type="primary">点击上传</el-button>
|
||||
<div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk),且不超过100MB</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input type="textarea" v-model="form.remark" placeholder="请输入备注信息"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="handleCancel">取 消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from '@/apis/api';
|
||||
import { FIRMWARE_TYPES } from '@/utils';
|
||||
|
||||
export default {
|
||||
name: 'FirmwareDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
firmwareTypes: FIRMWARE_TYPES,
|
||||
rules: {
|
||||
firmwareName: [
|
||||
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
|
||||
],
|
||||
type: [
|
||||
{ required: true, message: '请选择固件类型', trigger: 'change' }
|
||||
],
|
||||
version: [
|
||||
{ required: true, message: '请输入版本号', trigger: 'blur' },
|
||||
{ pattern: /^\d+\.\d+\.\d+$/, message: '版本号格式不正确,请输入x.x.x格式', trigger: 'blur' }
|
||||
],
|
||||
firmwarePath: [
|
||||
{ required: false, message: '请上传固件文件', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$refs.form.clearValidate();
|
||||
this.$emit('cancel');
|
||||
},
|
||||
handleCancel() {
|
||||
this.$refs.form.clearValidate();
|
||||
this.$emit('cancel');
|
||||
},
|
||||
handleSubmit() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (valid) {
|
||||
// 如果是新增模式且没有上传文件,则提示错误
|
||||
if (!this.form.id && !this.form.firmwarePath) {
|
||||
this.$message.error('请上传固件文件')
|
||||
return
|
||||
}
|
||||
// 提交成功后将关闭对话框的逻辑交给父组件处理
|
||||
this.$emit('submit', this.form)
|
||||
}
|
||||
})
|
||||
},
|
||||
beforeUpload(file) {
|
||||
const isValidSize = file.size / 1024 / 1024 < 100
|
||||
const isValidType = ['.bin', '.apk'].some(ext => file.name.toLowerCase().endsWith(ext))
|
||||
|
||||
if (!isValidType) {
|
||||
this.$message.error('只能上传.bin/.apk格式的固件文件!')
|
||||
return false
|
||||
}
|
||||
if (!isValidSize) {
|
||||
this.$message.error('固件文件大小不能超过100MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
handleUpload(options) {
|
||||
const { file } = options
|
||||
Api.ota.uploadFirmware(file, (res) => {
|
||||
res = res.data
|
||||
if (res.code === 0) {
|
||||
this.form.firmwarePath = res.data
|
||||
this.form.size = file.size
|
||||
this.$message.success('固件文件上传成功')
|
||||
} else {
|
||||
this.$message.error(res.msg || '文件上传失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-demo {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.el-upload__tip {
|
||||
line-height: 1.2;
|
||||
padding-top: 5px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,12 @@
|
||||
:style="{ filter: $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
参数管理
|
||||
</div>
|
||||
<div v-if="isSuperAdmin" class="equipment-management"
|
||||
:class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement">
|
||||
<img loading="lazy" alt="" src="@/assets/header/firmware_update.png"
|
||||
:style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
OTA管理
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧元素 -->
|
||||
@@ -105,6 +111,9 @@ export default {
|
||||
goParamManagement() {
|
||||
this.$router.push('/params-management')
|
||||
},
|
||||
goOtaManagement() {
|
||||
this.$router.push('/ota-management')
|
||||
},
|
||||
// 获取用户信息
|
||||
fetchUserInfo() {
|
||||
userApi.getUserInfo(({ data }) => {
|
||||
@@ -170,7 +179,7 @@ export default {
|
||||
.header {
|
||||
background: #f6fcfe66;
|
||||
border: 1px solid #fff;
|
||||
height: 53px !important;
|
||||
height: 63px !important;
|
||||
min-width: 900px;
|
||||
/* 设置最小宽度防止过度压缩 */
|
||||
overflow: hidden;
|
||||
@@ -197,7 +206,7 @@ export default {
|
||||
}
|
||||
|
||||
.brand-img {
|
||||
height: 18px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
@@ -219,13 +228,13 @@ export default {
|
||||
|
||||
.equipment-management {
|
||||
padding: 0 9px;
|
||||
width: 82px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
width: px;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
background: #deeafe;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
gap: 7px;
|
||||
color: #3d4566;
|
||||
@@ -235,6 +244,7 @@ export default {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
/* 防止导航按钮被压缩 */
|
||||
padding: 0px 15px;
|
||||
}
|
||||
|
||||
.equipment-management.active-tab {
|
||||
|
||||
@@ -76,7 +76,12 @@
|
||||
</div>
|
||||
|
||||
<div style="display: flex;justify-content: center;">
|
||||
<el-button type="primary" @click="handleSave" class="save-btn">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSave"
|
||||
class="save-btn"
|
||||
:loading="saving"
|
||||
:disabled="saving">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -102,6 +107,7 @@ export default {
|
||||
dialogVisible: this.visible,
|
||||
providers: [],
|
||||
providersLoaded: false,
|
||||
saving: false,
|
||||
allProvidersData: null,
|
||||
pendingProviderType: null,
|
||||
pendingModelData: null,
|
||||
@@ -195,6 +201,8 @@ export default {
|
||||
}
|
||||
},
|
||||
handleSave() {
|
||||
this.saving = true; // 开始保存加载
|
||||
|
||||
const provideCode = this.form.configJson.type;
|
||||
const formData = {
|
||||
id: this.modelData.id,
|
||||
@@ -209,8 +217,19 @@ export default {
|
||||
...this.form.configJson,
|
||||
}
|
||||
};
|
||||
this.$emit("save", { provideCode, formData });
|
||||
this.dialogVisible = false;
|
||||
|
||||
this.$emit("save", {
|
||||
provideCode,
|
||||
formData,
|
||||
done: () => {
|
||||
this.saving = false; // 保存完成后回调
|
||||
}
|
||||
});
|
||||
|
||||
// 如果父组件不处理done回调,3秒后自动关闭加载状态
|
||||
setTimeout(() => {
|
||||
this.saving = false;
|
||||
}, 3000);
|
||||
},
|
||||
loadProviders() {
|
||||
if (this.providersLoaded) return;
|
||||
|
||||
@@ -40,7 +40,12 @@
|
||||
</el-form>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submit" class="save-btn">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="submit"
|
||||
class="save-btn"
|
||||
:loading="saving"
|
||||
:disabled="saving">
|
||||
保存
|
||||
</el-button>
|
||||
<el-button @click="cancel" class="cancel-btn">
|
||||
@@ -76,6 +81,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
dialogKey: Date.now(),
|
||||
saving: false,
|
||||
valueTypeOptions: [
|
||||
{ value: 'string', label: '字符串(string)' },
|
||||
{ value: 'number', label: '数字(number)' },
|
||||
@@ -100,11 +106,22 @@ export default {
|
||||
submit() {
|
||||
this.$refs.form.validate((valid) => {
|
||||
if (valid) {
|
||||
this.$emit('submit', this.form);
|
||||
this.saving = true; // 开始加载
|
||||
this.$emit('submit', {
|
||||
form: this.form,
|
||||
done: () => {
|
||||
this.saving = false; // 加载完成
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.saving = false;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
this.saving = false; // 取消时重置状态
|
||||
this.$emit('cancel');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,13 +14,16 @@
|
||||
class="table-container"
|
||||
ref="tableContainer"
|
||||
@scroll="handleScroll">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="filteredTtsModels"
|
||||
style="width: 100%;"
|
||||
class="data-table"
|
||||
header-row-class-name="table-header"
|
||||
:fit="true">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="filteredTtsModels"
|
||||
style="width: 100%;"
|
||||
class="data-table"
|
||||
header-row-class-name="table-header"
|
||||
:fit="true"
|
||||
element-loading-text="拼命加载中"
|
||||
element-loading-spinner="el-icon-loading"
|
||||
element-loading-background="rgba(0, 0, 0, 0.8)">
|
||||
<el-table-column label="选择" width="50" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||
@@ -187,28 +190,28 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
const params = {
|
||||
ttsModelId: this.ttsModelId,
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
name: this.searchQuery
|
||||
};
|
||||
|
||||
Api.timbre.getVoiceList(params, (data) => {
|
||||
if (data.code === 0) {
|
||||
this.ttsModels = data.data.list
|
||||
.map(item => ({
|
||||
id: item.id || '',
|
||||
voiceCode: item.ttsVoice || '',
|
||||
voiceName: item.name || '未命名音色',
|
||||
languageType: item.languages || '',
|
||||
remark: item.remark || '',
|
||||
voiceDemo: item.voiceDemo || '',
|
||||
selected: false,
|
||||
editing: false,
|
||||
sort: Number(item.sort)
|
||||
}))
|
||||
.sort((a, b) => a.sort - b.sort);
|
||||
.map(item => ({
|
||||
id: item.id || '',
|
||||
voiceCode: item.ttsVoice || '',
|
||||
voiceName: item.name || '未命名音色',
|
||||
languageType: item.languages || '',
|
||||
remark: item.remark || '',
|
||||
voiceDemo: item.voiceDemo || '',
|
||||
selected: false,
|
||||
editing: false,
|
||||
sort: Number(item.sort)
|
||||
}))
|
||||
.sort((a, b) => a.sort - b.sort);
|
||||
this.total = data.total;
|
||||
} else {
|
||||
this.$message.error({
|
||||
@@ -216,12 +219,14 @@ export default {
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
this.loading = false;
|
||||
}, (err) => {
|
||||
console.error('加载失败:', err);
|
||||
this.$message.error({
|
||||
message: '加载音色数据失败',
|
||||
showClose: true
|
||||
});
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -62,13 +62,6 @@ const routes = [
|
||||
return import('../views/ModelConfig.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/test',
|
||||
name: 'TestServer',
|
||||
component: function () {
|
||||
return import('../views/test.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/params-management',
|
||||
name: 'ParamsManagement',
|
||||
@@ -80,6 +73,17 @@ const routes = [
|
||||
title: '参数管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/ota-management',
|
||||
name: 'OtaManagement',
|
||||
component: function () {
|
||||
return import('../views/OtaManagement.vue')
|
||||
},
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: 'OTA管理'
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
const router = new VueRouter({
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// 日期格式化函数
|
||||
export function formatDate(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hour = String(d.getHours()).padStart(2, '0');
|
||||
const minute = String(d.getMinutes()).padStart(2, '0');
|
||||
const second = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
// 文件大小格式化函数
|
||||
export function formatFileSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i];
|
||||
}
|
||||
@@ -136,3 +136,63 @@ export function getUUID() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 固件类型选项
|
||||
*/
|
||||
export const FIRMWARE_TYPES = [
|
||||
{ "key": "bread-compact-wifi", "name": "面包板新版接线(WiFi)" },
|
||||
{ "key": "bread-compact-wifi-lcd", "name": "面包板新版接线(WiFi)+ LCD" },
|
||||
{ "key": "bread-compact-ml307", "name": "面包板新版接线(ML307 AT)" },
|
||||
{ "key": "bread-compact-esp32", "name": "面包板(WiFi) ESP32 DevKit" },
|
||||
{ "key": "bread-compact-esp32-lcd", "name": "面包板(WiFi+ LCD) ESP32 DevKit" },
|
||||
{ "key": "df-k10", "name": "DFRobot 行空板 k10" },
|
||||
{ "key": "esp32-cgc", "name": "ESP32 CGC" },
|
||||
{ "key": "esp-box-3", "name": "ESP BOX 3" },
|
||||
{ "key": "esp-box", "name": "ESP BOX" },
|
||||
{ "key": "esp-box-lite", "name": "ESP BOX Lite" },
|
||||
{ "key": "kevin-box-1", "name": "Kevin Box 1" },
|
||||
{ "key": "kevin-box-2", "name": "Kevin Box 2" },
|
||||
{ "key": "kevin-c3", "name": "Kevin C3" },
|
||||
{ "key": "kevin-sp-v3-dev", "name": "Kevin SP V3开发板" },
|
||||
{ "key": "kevin-sp-v4-dev", "name": "Kevin SP V4开发板" },
|
||||
{ "key": "kevin-yuying-313lcd", "name": "鱼鹰科技3.13LCD开发板" },
|
||||
{ "key": "lichuang-dev", "name": "立创·实战派ESP32-S3开发板" },
|
||||
{ "key": "lichuang-c3-dev", "name": "立创·实战派ESP32-C3开发板" },
|
||||
{ "key": "magiclick-2p4", "name": "神奇按钮 Magiclick_2.4" },
|
||||
{ "key": "magiclick-2p5", "name": "神奇按钮 Magiclick_2.5" },
|
||||
{ "key": "magiclick-c3", "name": "神奇按钮 Magiclick_C3" },
|
||||
{ "key": "magiclick-c3-v2", "name": "神奇按钮 Magiclick_C3_v2" },
|
||||
{ "key": "m5stack-core-s3", "name": "M5Stack CoreS3" },
|
||||
{ "key": "atoms3-echo-base", "name": "AtomS3 + Echo Base" },
|
||||
{ "key": "atoms3r-echo-base", "name": "AtomS3R + Echo Base" },
|
||||
{ "key": "atoms3r-cam-m12-echo-base", "name": "AtomS3R CAM/M12 + Echo Base" },
|
||||
{ "key": "atommatrix-echo-base", "name": "AtomMatrix + Echo Base" },
|
||||
{ "key": "xmini-c3", "name": "虾哥 Mini C3" },
|
||||
{ "key": "esp32s3-korvo2-v3", "name": "ESP32S3_KORVO2_V3开发板" },
|
||||
{ "key": "esp-sparkbot", "name": "ESP-SparkBot开发板" },
|
||||
{ "key": "esp-spot-s3", "name": "ESP-Spot-S3" },
|
||||
{ "key": "esp32-s3-touch-amoled-1.8", "name": "Waveshare ESP32-S3-Touch-AMOLED-1.8" },
|
||||
{ "key": "esp32-s3-touch-lcd-1.85c", "name": "Waveshare ESP32-S3-Touch-LCD-1.85C" },
|
||||
{ "key": "esp32-s3-touch-lcd-1.85", "name": "Waveshare ESP32-S3-Touch-LCD-1.85" },
|
||||
{ "key": "esp32-s3-touch-lcd-1.46", "name": "Waveshare ESP32-S3-Touch-LCD-1.46" },
|
||||
{ "key": "esp32-s3-touch-lcd-3.5", "name": "Waveshare ESP32-S3-Touch-LCD-3.5" },
|
||||
{ "key": "tudouzi", "name": "土豆子" },
|
||||
{ "key": "lilygo-t-circle-s3", "name": "LILYGO T-Circle-S3" },
|
||||
{ "key": "lilygo-t-cameraplus-s3", "name": "LILYGO T-CameraPlus-S3" },
|
||||
{ "key": "movecall-moji-esp32s3", "name": "Movecall Moji 小智AI衍生版" },
|
||||
{ "key": "movecall-cuican-esp32s3", "name": "Movecall CuiCan 璀璨·AI吊坠" },
|
||||
{ "key": "atk-dnesp32s3", "name": "正点原子DNESP32S3开发板" },
|
||||
{ "key": "atk-dnesp32s3-box", "name": "正点原子DNESP32S3-BOX" },
|
||||
{ "key": "du-chatx", "name": "嘟嘟开发板CHATX(wifi)" },
|
||||
{ "key": "taiji-pi-s3", "name": "太极小派esp32s3" },
|
||||
{ "key": "xingzhi-cube-0.85tft-wifi", "name": "无名科技星智0.85(WIFI)" },
|
||||
{ "key": "xingzhi-cube-0.85tft-ml307", "name": "无名科技星智0.85(ML307)" },
|
||||
{ "key": "xingzhi-cube-0.96oled-wifi", "name": "无名科技星智0.96(WIFI)" },
|
||||
{ "key": "xingzhi-cube-0.96oled-ml307", "name": "无名科技星智0.96(ML307)" },
|
||||
{ "key": "xingzhi-cube-1.54tft-wifi", "name": "无名科技星智1.54(WIFI)" },
|
||||
{ "key": "xingzhi-cube-1.54tft-ml307", "name": "无名科技星智1.54(ML307)" },
|
||||
{ "key": "sensecap-watcher", "name": "SenseCAP Watcher" },
|
||||
{ "key": "doit-s3-aibox", "name": "四博智联AI陪伴盒子" },
|
||||
{ "key": "mixgo-nova", "name": "元控·青春" }
|
||||
]
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">设备管理</h2>
|
||||
<div class="right-operations">
|
||||
<el-input placeholder="请输入设备型号或Mac地址查询" v-model="searchKeyword"
|
||||
class="search-input" @keyup.enter.native="handleSearch" clearable />
|
||||
<el-input placeholder="请输入设备型号或Mac地址查询" v-model="searchKeyword" class="search-input"
|
||||
@keyup.enter.native="handleSearch" clearable />
|
||||
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,11 +18,22 @@
|
||||
<el-table
|
||||
ref="deviceTable"
|
||||
:data="paginatedDeviceList"
|
||||
@selection-change="handleSelectionChange"
|
||||
class="transparent-table"
|
||||
:header-cell-class-name="headerCellClassName">
|
||||
<el-table-column type="selection" align="center" width="120"></el-table-column>
|
||||
<el-table-column label="设备型号" prop="model" align="center"></el-table-column>
|
||||
:header-cell-class-name="headerCellClassName"
|
||||
v-loading="loading"
|
||||
element-loading-text="拼命加载中"
|
||||
element-loading-spinner="el-icon-loading"
|
||||
element-loading-background="rgba(255, 255, 255, 0.7)">
|
||||
<el-table-column label="选择" align="center" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备型号" prop="model" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ getFirmwareTypeName(scope.row.model) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="固件版本" prop="firmwareVersion" align="center" ></el-table-column>
|
||||
<el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column>
|
||||
<el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column>
|
||||
@@ -32,7 +43,8 @@
|
||||
<el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini"
|
||||
@blur="stopEditRemark(scope.$index)"></el-input>
|
||||
<span v-else>
|
||||
<i v-if="!scope.row.remark" class="el-icon-edit" @click="startEditRemark(scope.$index, scope.row)"></i>
|
||||
<i v-if="!scope.row.remark" class="el-icon-edit"
|
||||
@click="startEditRemark(scope.$index, scope.row)"></i>
|
||||
<span v-else @click="startEditRemark(scope.$index, scope.row)">
|
||||
{{ scope.row.remark }}
|
||||
</span>
|
||||
@@ -56,32 +68,23 @@
|
||||
|
||||
<div class="table_bottom">
|
||||
<div class="ctrl_btn">
|
||||
<el-button size="mini" type="primary" class="select-all-btn" @click="toggleAllSelection">
|
||||
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</el-button>
|
||||
<el-button type="success" size="mini" class="add-device-btn" @click="handleAddDevice">
|
||||
新增
|
||||
</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete"
|
||||
@click="deleteSelected">解绑</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelected">解绑</el-button>
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||
<el-option
|
||||
v-for="item in pageSizeOptions"
|
||||
:key="item"
|
||||
:label="`${item}条/页`"
|
||||
:value="item">
|
||||
</el-option>
|
||||
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
|
||||
</el-option>
|
||||
</el-select>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
|
||||
<button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
class="pagination-btn"
|
||||
:class="{ active: page === currentPage }"
|
||||
@click="goToPage(page)">
|
||||
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
|
||||
:class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
|
||||
@@ -103,6 +106,7 @@
|
||||
import Api from '@/apis/api';
|
||||
import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import { FIRMWARE_TYPES } from "@/utils";
|
||||
|
||||
export default {
|
||||
components: { HeaderBar, AddDeviceDialog },
|
||||
@@ -120,7 +124,6 @@ export default {
|
||||
deviceList: [],
|
||||
loading: false,
|
||||
userApi: null,
|
||||
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -136,7 +139,10 @@ export default {
|
||||
paginatedDeviceList() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.filteredDeviceList.slice(start, end);
|
||||
return this.filteredDeviceList.slice(start, end).map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}));
|
||||
},
|
||||
pageCount() {
|
||||
return Math.ceil(this.filteredDeviceList.length / this.pageSize);
|
||||
@@ -173,15 +179,16 @@ export default {
|
||||
this.currentPage = 1;
|
||||
},
|
||||
|
||||
handleSelectionChange(val) {
|
||||
this.selectedDevices = val;
|
||||
this.isAllSelected = val.length === this.paginatedDeviceList.length;
|
||||
},
|
||||
toggleAllSelection() {
|
||||
this.$refs.deviceTable.toggleAllSelection();
|
||||
handleSelectAll() {
|
||||
this.isAllSelected = !this.isAllSelected;
|
||||
this.paginatedDeviceList.forEach(row => {
|
||||
row.selected = this.isAllSelected;
|
||||
});
|
||||
this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
|
||||
},
|
||||
|
||||
deleteSelected() {
|
||||
this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
|
||||
if (this.selectedDevices.length === 0) {
|
||||
this.$message.warning({
|
||||
message: '请至少选择一条记录',
|
||||
@@ -301,7 +308,7 @@ export default {
|
||||
rawBindTime: new Date(device.createDate).getTime()
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.rawBindTime - b.rawBindTime);
|
||||
.sort((a, b) => a.rawBindTime - b.rawBindTime);
|
||||
this.activeSearchKeyword = "";
|
||||
this.searchKeyword = "";
|
||||
} else {
|
||||
@@ -309,12 +316,16 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
headerCellClassName({columnIndex}) {
|
||||
headerCellClassName({ columnIndex }) {
|
||||
if (columnIndex === 0) {
|
||||
return "custom-selection-header";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
},
|
||||
getFirmwareTypeName(type) {
|
||||
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
|
||||
return firmwareType ? firmwareType.name : type;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -383,12 +394,12 @@ export default {
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
::v-deep .page-size-select{
|
||||
::v-deep .page-size-select {
|
||||
width: 100px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
::v-deep .page-size-select .el-input__inner{
|
||||
::v-deep .page-size-select .el-input__inner {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
border-radius: 4px;
|
||||
@@ -397,7 +408,8 @@ export default {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
::v-deep .page-size-select .el-input__suffix{
|
||||
|
||||
::v-deep .page-size-select .el-input__suffix {
|
||||
right: 6px;
|
||||
width: 15px;
|
||||
height: 20px;
|
||||
@@ -408,13 +420,14 @@ export default {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::v-deep .page-size-select .el-input__suffix-inner{
|
||||
::v-deep .page-size-select .el-input__suffix-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
::v-deep .page-size-select .el-icon-arrow-up:before{
|
||||
|
||||
::v-deep .page-size-select .el-icon-arrow-up:before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
border-left: 6px solid transparent;
|
||||
@@ -459,6 +472,13 @@ export default {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
::v-deep .el-card__body {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
@@ -468,6 +488,7 @@ export default {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
.ctrl_btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -613,13 +634,7 @@ export default {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.custom-selection-header::after) {
|
||||
content: "选择";
|
||||
display: inline-block;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
|
||||
:deep(.el-table .el-button--text) {
|
||||
color: #7079aa;
|
||||
@@ -656,4 +671,25 @@ export default {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
background-color: #eeeeee !important;
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner:hover) {
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background-color: #5f70f3 !important;
|
||||
border-color: #5f70f3 !important;
|
||||
}
|
||||
|
||||
::v-deep .el-table--border::after,
|
||||
::v-deep .el-table--group::after,
|
||||
::v-deep .el-table::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -44,12 +44,22 @@
|
||||
<!-- 右侧内容 -->
|
||||
<div class="content-area">
|
||||
<el-card class="model-card" shadow="never">
|
||||
<el-table ref="modelTable" style="width: 100%" :header-cell-style="{ background: 'transparent' }"
|
||||
:data="modelList" class="data-table" header-row-class-name="table-header"
|
||||
:header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange">
|
||||
<el-table
|
||||
ref="modelTable"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
element-loading-text="拼命加载中"
|
||||
element-loading-spinner="el-icon-loading"
|
||||
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||
:header-cell-style="{ background: 'transparent' }"
|
||||
:data="modelList"
|
||||
class="data-table"
|
||||
header-row-class-name="table-header"
|
||||
:header-cell-class-name="headerCellClassName"
|
||||
@selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center"></el-table-column>
|
||||
<el-table-column label="模型ID" prop="id" align="center"></el-table-column>
|
||||
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
|
||||
<el-table-column label="模型编码" prop="modelCode" align="center"></el-table-column>
|
||||
<el-table-column label="提供商" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.configJson.type || '未知' }}
|
||||
@@ -86,41 +96,37 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<div class="batch-actions">
|
||||
<el-button size="mini" type="primary" @click="selectAll">
|
||||
{{ isAllSelected ?
|
||||
'取消全选' : '全选' }}
|
||||
</el-button>
|
||||
<el-button type="success" size="mini" @click="addModel" class="add-btn">
|
||||
新增
|
||||
</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
<div class="batch-actions">
|
||||
<el-button size="mini" type="primary" @click="selectAll">
|
||||
{{ isAllSelected ?
|
||||
'取消全选' : '全选' }}
|
||||
</el-button>
|
||||
<el-button type="success" size="mini" @click="addModel" class="add-btn">
|
||||
新增
|
||||
</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
|
||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||
<el-option
|
||||
v-for="item in pageSizeOptions"
|
||||
:key="item"
|
||||
:label="`${item}条/页`"
|
||||
:value="item">
|
||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-select>
|
||||
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
|
||||
|
||||
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
|
||||
:class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
|
||||
:class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
|
||||
<span class="total-text">共{{ total }}条记录</span>
|
||||
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
|
||||
<span class="total-text">共{{ total }}条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +163,8 @@ export default {
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
selectedModels: [],
|
||||
isAllSelected: false
|
||||
isAllSelected: false,
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
|
||||
@@ -301,9 +308,10 @@ export default {
|
||||
this.currentPage = page;
|
||||
this.$refs.modelTable.clearSelection();
|
||||
},
|
||||
handleModelSave({ provideCode, formData }) {
|
||||
handleModelSave({ provideCode, formData, done }) {
|
||||
const modelType = this.activeTab;
|
||||
const id = formData.id;
|
||||
|
||||
Api.model.updateModel(
|
||||
{ modelType, provideCode, id, formData },
|
||||
({ data }) => {
|
||||
@@ -314,6 +322,7 @@ export default {
|
||||
} else {
|
||||
this.$message.error(data.msg || '保存失败');
|
||||
}
|
||||
done && done(); // 调用done回调关闭加载状态
|
||||
}
|
||||
);
|
||||
},
|
||||
@@ -385,6 +394,7 @@ export default {
|
||||
|
||||
// 获取模型配置列表
|
||||
loadData() {
|
||||
this.loading = true; // 开始加载
|
||||
const params = {
|
||||
modelType: this.activeTab,
|
||||
modelName: this.search,
|
||||
@@ -393,6 +403,7 @@ export default {
|
||||
};
|
||||
|
||||
Api.model.getModelList(params, ({ data }) => {
|
||||
this.loading = false; // 结束加载
|
||||
if (data.code === 0) {
|
||||
this.modelList = data.data.list;
|
||||
this.total = data.data.total;
|
||||
@@ -592,12 +603,12 @@ export default {
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
::v-deep .page-size-select{
|
||||
::v-deep .page-size-select {
|
||||
width: 100px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
::v-deep .page-size-select .el-input__inner{
|
||||
::v-deep .page-size-select .el-input__inner {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
border-radius: 4px;
|
||||
@@ -606,7 +617,8 @@ export default {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
::v-deep .page-size-select .el-input__suffix{
|
||||
|
||||
::v-deep .page-size-select .el-input__suffix {
|
||||
right: 6px;
|
||||
width: 15px;
|
||||
height: 20px;
|
||||
@@ -617,13 +629,14 @@ export default {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::v-deep .page-size-select .el-input__suffix-inner{
|
||||
::v-deep .page-size-select .el-input__suffix-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
::v-deep .page-size-select .el-icon-arrow-up:before{
|
||||
|
||||
::v-deep .page-size-select .el-icon-arrow-up:before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
border-left: 6px solid transparent;
|
||||
@@ -884,7 +897,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.model-card{
|
||||
.model-card {
|
||||
background: white;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -894,7 +907,7 @@ export default {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-card ::v-deep .el-card__body{
|
||||
.model-card ::v-deep .el-card__body {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -912,4 +925,21 @@ export default {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
::v-deep .el-loading-mask {
|
||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
::v-deep .el-loading-spinner .circular {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
::v-deep .el-loading-spinner .path {
|
||||
stroke: #6b8cff;
|
||||
}
|
||||
::v-deep .el-loading-text {
|
||||
color: #6b8cff !important;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<HeaderBar />
|
||||
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">固件管理</h2>
|
||||
<div class="right-operations">
|
||||
<el-input placeholder="请输入固件名称查询" v-model="searchName" class="search-input"
|
||||
@keyup.enter.native="handleSearch" clearable />
|
||||
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="params-card" shadow="never">
|
||||
<el-table ref="paramsTable" :data="paramsList" class="transparent-table"
|
||||
:header-cell-class-name="headerCellClassName">
|
||||
<el-table-column label="选择" align="center" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="固件名称" prop="firmwareName" align="center"></el-table-column>
|
||||
<el-table-column label="固件类型" prop="type" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ getFirmwareTypeName(scope.row.type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本号" prop="version" align="center"></el-table-column>
|
||||
<el-table-column label="文件大小" prop="size" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ formatFileSize(scope.row.size) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" align="center"
|
||||
show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="创建时间" prop="createDate" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ formatDate(scope.row.createDate) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text"
|
||||
@click="downloadFirmware(scope.row)">下载</el-button>
|
||||
<el-button size="mini" type="text" @click="editParam(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="text" @click="deleteParam(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table_bottom">
|
||||
<div class="ctrl_btn">
|
||||
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</el-button>
|
||||
<el-button size="mini" type="success" @click="showAddDialog"
|
||||
style="background: #5bc98c;border: None;">新增</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete"
|
||||
@click="deleteSelectedParams">删除</el-button>
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
|
||||
:value="item">
|
||||
</el-option>
|
||||
</el-select>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
|
||||
首页
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
|
||||
上一页
|
||||
</button>
|
||||
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
|
||||
:class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
|
||||
下一页
|
||||
</button>
|
||||
<span class="total-text">共{{ total }}条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑固件对话框 -->
|
||||
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit"
|
||||
@cancel="dialogVisible = false" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from "@/apis/api";
|
||||
import FirmwareDialog from "@/components/FirmwareDialog.vue";
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import { FIRMWARE_TYPES } from "@/utils";
|
||||
import { formatDate, formatFileSize } from "@/utils/format";
|
||||
|
||||
export default {
|
||||
components: { HeaderBar, FirmwareDialog },
|
||||
data() {
|
||||
return {
|
||||
searchName: "",
|
||||
paramsList: [],
|
||||
firmwareList: [],
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
total: 0,
|
||||
dialogVisible: false,
|
||||
dialogTitle: "新增固件",
|
||||
isAllSelected: false,
|
||||
firmwareForm: {
|
||||
id: null,
|
||||
firmwareName: "",
|
||||
type: "",
|
||||
version: "",
|
||||
size: 0,
|
||||
remark: "",
|
||||
firmwarePath: ""
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.fetchFirmwareList();
|
||||
},
|
||||
|
||||
computed: {
|
||||
pageCount() {
|
||||
return Math.ceil(this.total / this.pageSize);
|
||||
},
|
||||
visiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 3;
|
||||
let start = Math.max(1, this.currentPage - 1);
|
||||
let end = Math.min(this.pageCount, start + maxVisible - 1);
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handlePageSizeChange(val) {
|
||||
this.pageSize = val;
|
||||
this.currentPage = 1;
|
||||
this.fetchFirmwareList();
|
||||
},
|
||||
fetchFirmwareList() {
|
||||
const params = {
|
||||
pageNum: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
firmwareName: this.searchName || "",
|
||||
orderField: "create_date",
|
||||
order: "desc"
|
||||
};
|
||||
Api.ota.getOtaList(params, (res) => {
|
||||
res = res.data
|
||||
if (res.code === 0) {
|
||||
this.firmwareList = res.data.list.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}));
|
||||
this.paramsList = this.firmwareList;
|
||||
this.total = res.data.total || 0;
|
||||
} else {
|
||||
this.firmwareList = [];
|
||||
this.paramsList = [];
|
||||
this.total = 0;
|
||||
this.$message.error({
|
||||
message: res?.data?.msg || '获取固件列表失败',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSearch() {
|
||||
this.currentPage = 1;
|
||||
this.fetchFirmwareList();
|
||||
},
|
||||
handleSelectAll() {
|
||||
this.isAllSelected = !this.isAllSelected;
|
||||
this.firmwareList.forEach(row => {
|
||||
row.selected = this.isAllSelected;
|
||||
});
|
||||
},
|
||||
showAddDialog() {
|
||||
this.dialogTitle = "新增固件";
|
||||
// 完全重置表单数据
|
||||
this.firmwareForm = {
|
||||
id: null,
|
||||
firmwareName: "",
|
||||
type: "",
|
||||
version: "",
|
||||
size: 0,
|
||||
remark: "",
|
||||
firmwarePath: ""
|
||||
};
|
||||
this.$nextTick(() => {
|
||||
// 重置表单的校验状态
|
||||
if (this.$refs.firmwareDialog && this.$refs.firmwareDialog.$refs.form) {
|
||||
this.$refs.firmwareDialog.$refs.form.clearValidate();
|
||||
}
|
||||
});
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
editParam(row) {
|
||||
this.dialogTitle = "编辑固件";
|
||||
this.firmwareForm = { ...row };
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
handleSubmit(form) {
|
||||
if (form.id) {
|
||||
// 编辑
|
||||
Api.ota.updateOta(form.id, form, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success({
|
||||
message: "修改成功",
|
||||
showClose: true
|
||||
});
|
||||
this.dialogVisible = false;
|
||||
this.fetchFirmwareList();
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: res.msg || "修改失败",
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
Api.ota.saveOta(form, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success({
|
||||
message: "新增成功",
|
||||
showClose: true
|
||||
});
|
||||
this.dialogVisible = false;
|
||||
this.fetchFirmwareList();
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: res.msg || "新增失败",
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteSelectedParams() {
|
||||
const selectedRows = this.firmwareList.filter(row => row.selected);
|
||||
if (selectedRows.length === 0) {
|
||||
this.$message.warning({
|
||||
message: "请先选择需要删除的固件",
|
||||
showClose: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.deleteParam(selectedRows);
|
||||
},
|
||||
deleteParam(row) {
|
||||
// 处理单个参数或参数数组
|
||||
const params = Array.isArray(row) ? row : [row];
|
||||
|
||||
if (Array.isArray(row) && row.length === 0) {
|
||||
this.$message.warning({
|
||||
message: "请先选择需要删除的参数",
|
||||
showClose: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const paramCount = params.length;
|
||||
this.$confirm(`确定要删除选中的${paramCount}个固件吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
distinguishCancelAndClose: true
|
||||
}).then(() => {
|
||||
const ids = params.map(param => param.id);
|
||||
if (ids.some(id => !id)) {
|
||||
this.$message.error({
|
||||
message: '存在无效的参数ID',
|
||||
showClose: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Api.ota.deleteOta(ids, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success({
|
||||
message: `成功删除${paramCount}个固件`,
|
||||
showClose: true
|
||||
});
|
||||
this.fetchFirmwareList();
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: res.msg || '删除失败,请重试',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch(action => {
|
||||
if (action === 'cancel') {
|
||||
this.$message({
|
||||
type: 'info',
|
||||
message: '已取消删除操作',
|
||||
duration: 1000
|
||||
});
|
||||
} else {
|
||||
this.$message({
|
||||
type: 'info',
|
||||
message: '操作已关闭',
|
||||
duration: 1000
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
headerCellClassName({ columnIndex }) {
|
||||
if (columnIndex === 0) {
|
||||
return "custom-selection-header";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
goFirst() {
|
||||
this.currentPage = 1;
|
||||
this.fetchFirmwareList();
|
||||
},
|
||||
goPrev() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.fetchFirmwareList();
|
||||
}
|
||||
},
|
||||
goNext() {
|
||||
if (this.currentPage < this.pageCount) {
|
||||
this.currentPage++;
|
||||
this.fetchFirmwareList();
|
||||
}
|
||||
},
|
||||
goToPage(page) {
|
||||
this.currentPage = page;
|
||||
this.fetchFirmwareList();
|
||||
},
|
||||
downloadFirmware(firmware) {
|
||||
if (!firmware || !firmware.id) {
|
||||
this.$message.error('固件信息不完整');
|
||||
return;
|
||||
}
|
||||
// 先获取下载链接
|
||||
Api.ota.getDownloadUrl(firmware.id, (res) => {
|
||||
if (res.data.code === 0) {
|
||||
const uuid = res.data.data;
|
||||
const baseUrl = process.env.VUE_APP_API_BASE_URL || '';
|
||||
window.open(`${window.location.origin}${baseUrl}/otaMag/download/${uuid}`);
|
||||
} else {
|
||||
this.$message.error('获取下载链接失败');
|
||||
}
|
||||
});
|
||||
},
|
||||
formatDate,
|
||||
formatFileSize,
|
||||
getFirmwareTypeName(type) {
|
||||
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
|
||||
return firmwareType ? firmwareType.name : type;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.welcome {
|
||||
min-width: 900px;
|
||||
min-height: 506px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
background-size: cover;
|
||||
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
|
||||
-webkit-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
margin: 5px 22px;
|
||||
border-radius: 15px;
|
||||
min-height: calc(100vh - 24vh);
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
background: rgba(237, 242, 255, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.operation-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.right-operations {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: linear-gradient(135deg, #6b8cff, #a966ff);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
overflow: auto;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.params-card {
|
||||
background: white;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.ctrl_btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-left: 26px;
|
||||
|
||||
.el-button {
|
||||
min-width: 72px;
|
||||
height: 32px;
|
||||
padding: 7px 12px 7px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: #5f70f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
background: #fd5b63;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.el-select {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.pagination-btn:first-child,
|
||||
.pagination-btn:nth-child(2),
|
||||
.pagination-btn:nth-last-child(2),
|
||||
.pagination-btn:nth-child(3) {
|
||||
min-width: 60px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #dee7ff;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #d7dce6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||
min-width: 28px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(245, 247, 250, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #5f70f3 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #5f70f3 !important;
|
||||
|
||||
&:hover {
|
||||
background: #6d7cf5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.total-text {
|
||||
margin-left: 10px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.custom-selection-header {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
background-color: transparent;
|
||||
|
||||
.el-table__header-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.el-table__header th {
|
||||
background-color: white !important;
|
||||
color: black;
|
||||
font-weight: 600;
|
||||
height: 40px;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.el-table__body-wrapper {
|
||||
td {
|
||||
background-color: transparent;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
padding: 8px 0;
|
||||
height: 40px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table__row:hover>td {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table .el-button--text) {
|
||||
color: #7079aa !important;
|
||||
}
|
||||
|
||||
:deep(.el-table .el-button--text:hover) {
|
||||
color: #5a64b5 !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
background-color: #eeeeee !important;
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner:hover) {
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background-color: #5f70f3 !important;
|
||||
border-color: #5f70f3 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -15,8 +15,15 @@
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="params-card" shadow="never">
|
||||
<el-table ref="paramsTable" :data="paramsList" class="transparent-table"
|
||||
:header-cell-class-name="headerCellClassName">
|
||||
<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>
|
||||
@@ -92,6 +99,7 @@ export default {
|
||||
searchCode: "",
|
||||
paramsList: [],
|
||||
currentPage: 1,
|
||||
loading: false,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
total: 0,
|
||||
@@ -138,27 +146,29 @@ export default {
|
||||
this.fetchParams();
|
||||
},
|
||||
fetchParams() {
|
||||
Api.admin.getParamsList(
|
||||
{
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
paramCode: this.searchCode,
|
||||
},
|
||||
({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.paramsList = data.data.list.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}));
|
||||
this.total = data.data.total;
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: data.msg || '获取参数列表失败',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
this.loading = true;
|
||||
Api.admin.getParamsList(
|
||||
{
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
paramCode: this.searchCode,
|
||||
},
|
||||
({ data }) => {
|
||||
this.loading = false;
|
||||
if (data.code === 0) {
|
||||
this.paramsList = data.data.list.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}));
|
||||
this.total = data.data.total;
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: data.msg || '获取参数列表失败',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
handleSearch() {
|
||||
this.currentPage = 1;
|
||||
@@ -185,32 +195,35 @@ export default {
|
||||
this.paramForm = { ...row };
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
handleSubmit(form) {
|
||||
if (form.id) {
|
||||
// 编辑
|
||||
Api.admin.updateParam(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message:"修改成功",
|
||||
showClose:true
|
||||
});
|
||||
this.dialogVisible = false;
|
||||
this.fetchParams();
|
||||
}
|
||||
|
||||
handleSubmit({ form, done }) {
|
||||
if (form.id) {
|
||||
// 编辑
|
||||
Api.admin.updateParam(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message:"修改成功",
|
||||
showClose:true
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
Api.admin.addParam(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message:"新增成功",
|
||||
showClose:true
|
||||
});
|
||||
this.dialogVisible = false;
|
||||
this.fetchParams();
|
||||
}
|
||||
this.dialogVisible = false;
|
||||
this.fetchParams();
|
||||
}
|
||||
done && done();
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
Api.admin.addParam(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message:"新增成功",
|
||||
showClose:true
|
||||
});
|
||||
}
|
||||
this.dialogVisible = false;
|
||||
this.fetchParams();
|
||||
}
|
||||
done && done();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteSelectedParams() {
|
||||
@@ -389,13 +402,21 @@ export default {
|
||||
}
|
||||
|
||||
.params-card {
|
||||
background: white;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: white;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
|
||||
::v-deep .el-card__body {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.table_bottom {
|
||||
@@ -656,4 +677,25 @@ export default {
|
||||
max-height: calc(var(--table-max-height) - 40px);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-loading-mask) {
|
||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
:deep(.el-loading-spinner .circular) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
:deep(.el-loading-spinner .path) {
|
||||
stroke: #6b8cff;
|
||||
}
|
||||
|
||||
:deep(.el-loading-text) {
|
||||
color: #6b8cff !important;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="user-card" shadow="never">
|
||||
<el-table ref="userTable" :data="userList" class="transparent-table">
|
||||
<el-table
|
||||
ref="userTable"
|
||||
:data="userList"
|
||||
class="transparent-table"
|
||||
v-loading="loading"
|
||||
element-loading-text="拼命加载中"
|
||||
element-loading-spinner="el-icon-loading"
|
||||
element-loading-background="rgba(255, 255, 255, 0.7)">
|
||||
<el-table-column label="选择" align="center" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||
@@ -103,7 +110,8 @@ export default {
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
isAllSelected: false
|
||||
isAllSelected: false,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
@@ -137,22 +145,24 @@ export default {
|
||||
},
|
||||
|
||||
fetchUsers() {
|
||||
Api.admin.getUserList(
|
||||
{
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
mobile: this.searchPhone,
|
||||
},
|
||||
({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.userList = data.data.list.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}));
|
||||
this.total = data.data.total;
|
||||
}
|
||||
}
|
||||
);
|
||||
this.loading = true;
|
||||
Api.admin.getUserList(
|
||||
{
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
mobile: this.searchPhone,
|
||||
},
|
||||
({ data }) => {
|
||||
this.loading = false; // 结束加载
|
||||
if (data.code === 0) {
|
||||
this.userList = data.data.list.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}));
|
||||
this.total = data.data.total;
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
handleSearch() {
|
||||
this.currentPage = 1;
|
||||
@@ -415,12 +425,20 @@ export default {
|
||||
|
||||
.user-card {
|
||||
background: white;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
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 {
|
||||
@@ -682,5 +700,21 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
: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>
|
||||
|
||||
@@ -31,8 +31,30 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-list-container">
|
||||
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig"
|
||||
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" />
|
||||
<template v-if="isLoading">
|
||||
<div
|
||||
v-for="i in skeletonCount"
|
||||
:key="'skeleton-'+i"
|
||||
class="skeleton-item"
|
||||
>
|
||||
<div class="skeleton-image"></div>
|
||||
<div class="skeleton-content">
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line-short"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<DeviceItem
|
||||
v-for="(item, index) in devices"
|
||||
:key="index"
|
||||
:device="item"
|
||||
@configure="goToRoleConfig"
|
||||
@deviceManage="handleDeviceManage"
|
||||
@delete="handleDeleteAgent"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" />
|
||||
@@ -60,7 +82,9 @@ export default {
|
||||
devices: [],
|
||||
originalDevices: [],
|
||||
isSearching: false,
|
||||
searchRegex: null
|
||||
searchRegex: null,
|
||||
isLoading: true,
|
||||
skeletonCount: localStorage.getItem('skeletonCount') || 8,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -109,12 +133,26 @@ export default {
|
||||
},
|
||||
// 获取智能体列表
|
||||
fetchAgentList() {
|
||||
this.isLoading = true;
|
||||
Api.agent.getAgentList(({ data }) => {
|
||||
this.originalDevices = data.data.map(item => ({
|
||||
...item,
|
||||
agentId: item.id // 字段映射
|
||||
}));
|
||||
this.handleSearchReset(); // 重置搜索状态
|
||||
if (data?.data) {
|
||||
this.originalDevices = data.data.map(item => ({
|
||||
...item,
|
||||
agentId: item.id
|
||||
}));
|
||||
|
||||
// 动态设置骨架屏数量(可选)
|
||||
this.skeletonCount = Math.min(
|
||||
Math.max(this.originalDevices.length, 3), // 最少3个
|
||||
10 // 最多10个
|
||||
);
|
||||
|
||||
this.handleSearchReset();
|
||||
}
|
||||
this.isLoading = false;
|
||||
}, (error) => {
|
||||
console.error('Failed to fetch agent list:', error);
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
// 删除智能体
|
||||
@@ -199,7 +237,7 @@ export default {
|
||||
|
||||
.hi-hint {
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
color: #818cae;
|
||||
margin-left: 75px;
|
||||
@@ -220,7 +258,7 @@ export default {
|
||||
border-radius: 17px;
|
||||
background: #5778ff;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 34px;
|
||||
@@ -262,6 +300,65 @@ export default {
|
||||
/* 居中显示 */
|
||||
}
|
||||
|
||||
/* 骨架屏动画 */
|
||||
@keyframes shimmer {
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
height: 120px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
float: left;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
margin-left: 100px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 16px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
width: 70%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-line-short {
|
||||
height: 12px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.skeleton-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0),
|
||||
rgba(255,255,255,0.3),
|
||||
rgba(255,255,255,0)
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,9 @@
|
||||
<img loading="lazy" src="@/assets/home/setting-user.png" alt="">
|
||||
</div>
|
||||
<span class="header-title">{{ form.agentName }}</span>
|
||||
<button class="custom-close-btn" @click="goToHome">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
@@ -165,6 +168,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToHome() {
|
||||
this.$router.push('/home');
|
||||
},
|
||||
saveConfig() {
|
||||
const configData = {
|
||||
agentCode: this.form.agentCode,
|
||||
@@ -423,6 +429,7 @@ export default {
|
||||
}
|
||||
|
||||
.config-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
@@ -560,8 +567,35 @@ export default {
|
||||
background: none;
|
||||
position: absolute;
|
||||
font-size: 12px;
|
||||
bottom: -10px;
|
||||
right: 21px;
|
||||
bottom: -10%;
|
||||
right: 3%;
|
||||
}
|
||||
|
||||
.custom-close-btn {
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #cfcfcf;
|
||||
background: none;
|
||||
font-size: 30px;
|
||||
font-weight: lighter;
|
||||
color: #cfcfcf;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.custom-close-btn:hover {
|
||||
color: #409EFF;
|
||||
border-color: #409EFF;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,400 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1 class="title">XiaoZhi ESP32 Server 测试助手</h1>
|
||||
<div class="chat-container" ref="chatContainer">
|
||||
<div v-for="(message, index) in messages" :key="index"
|
||||
:class="['message', message.role === 'user' ? 'user' : 'assistant']">
|
||||
<span>{{ message.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button @click="toggleRecording" :disabled="wsStatus !== 'connected'" :class="{ recording: isRecording }">
|
||||
{{ isRecording ? '停止录音' : '开始录音' }}
|
||||
</button>
|
||||
<p>WebSocket: {{ wsStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OpusDecoder } from 'opus-decoder';
|
||||
import Recorder from 'opus-recorder';
|
||||
|
||||
export default {
|
||||
name: 'TestPage',
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
wsStatus: 'disconnected',
|
||||
isRecording: false,
|
||||
ws: null,
|
||||
recorder: null,
|
||||
stream: null,
|
||||
audioContext: null,
|
||||
sourceNode: null,
|
||||
audioDecoder: null,
|
||||
audioBufferQueue: [], // 当前句子的音频缓冲区
|
||||
playbackQueue: [], // 播放队列,存储待播放的句子
|
||||
isPlaying: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
connectWebSocket() {
|
||||
this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.onopen = () => {
|
||||
this.wsStatus = 'connected';
|
||||
console.log('WebSocket 连接成功');
|
||||
this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
|
||||
this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
|
||||
};
|
||||
this.ws.onmessage = async (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data);
|
||||
console.log('收到文本消息:', msg);
|
||||
|
||||
if (msg.type === 'stt') {
|
||||
this.messages.push({ role: 'user', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
} else if (msg.type === 'llm') {
|
||||
this.messages.push({ role: 'assistant', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
} else if (msg.type === 'tts') {
|
||||
if (msg.state === 'sentence_start') {
|
||||
this.audioBufferQueue = [];
|
||||
const lastMessage = this.messages[this.messages.length - 1];
|
||||
if (!lastMessage || lastMessage.content !== msg.text) {
|
||||
this.messages.push({ role: 'assistant', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
}
|
||||
} else if (msg.state === 'sentence_end') {
|
||||
if (this.audioBufferQueue.length > 0) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
} else if (msg.state === 'stop') {
|
||||
console.log('TTS 任务结束');
|
||||
if (this.audioBufferQueue.length > 0) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'hello') {
|
||||
console.log('收到服务器初始化消息:', msg);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log('收到音频帧,大小:', event.data.byteLength, '字节');
|
||||
const opusFrame = new Uint8Array(event.data);
|
||||
const frameHead = Array.from(opusFrame.slice(0, 8))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
console.log('音频帧前8字节:', frameHead);
|
||||
|
||||
try {
|
||||
const decoded = this.audioDecoder.decodeFrame(opusFrame);
|
||||
console.log('解码结果:', decoded);
|
||||
if (decoded && decoded.channelData && decoded.channelData[0]) {
|
||||
const pcmData = decoded.channelData[0];
|
||||
if (pcmData.length > 0) {
|
||||
this.audioBufferQueue.push(pcmData);
|
||||
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
|
||||
if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
} else {
|
||||
console.warn('解码成功,但 PCM 数据长度为 0');
|
||||
}
|
||||
} else {
|
||||
console.warn('解码结果无效:', decoded);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Opus 解码错误:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error);
|
||||
this.wsStatus = 'error';
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.wsStatus = 'disconnected';
|
||||
console.log('WebSocket 断开');
|
||||
setTimeout(this.connectWebSocket, 1000);
|
||||
};
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.chatContainer) {
|
||||
this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
},
|
||||
async playNextInQueue() {
|
||||
if (this.isPlaying || this.playbackQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.isPlaying = true;
|
||||
|
||||
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
}
|
||||
|
||||
const pcmBuffers = this.playbackQueue.shift();
|
||||
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
|
||||
if (totalLength === 0) {
|
||||
console.error('音频缓冲区总长度为 0,无法播放');
|
||||
this.isPlaying = false;
|
||||
this.playNextInQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedPcm = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const pcm of pcmBuffers) {
|
||||
mergedPcm.set(pcm, offset);
|
||||
offset += pcm.length;
|
||||
}
|
||||
|
||||
const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
|
||||
audioBuffer.getChannelData(0).set(mergedPcm);
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(this.audioContext.destination);
|
||||
source.onended = () => {
|
||||
this.isPlaying = false;
|
||||
console.log('音频播放结束');
|
||||
this.playNextInQueue();
|
||||
};
|
||||
source.start();
|
||||
console.log('开始播放音频,总长度:', totalLength);
|
||||
},
|
||||
stripOggContainer(data) {
|
||||
let arrayBuffer;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
arrayBuffer = data;
|
||||
} else if (data.buffer instanceof ArrayBuffer) {
|
||||
arrayBuffer = data.buffer;
|
||||
} else {
|
||||
console.error('输入数据类型不支持:', data);
|
||||
return [data];
|
||||
}
|
||||
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
const frames = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < arrayBuffer.byteLength) {
|
||||
if (dataView.getUint32(offset) === 0x4F676753) { // 'OggS'
|
||||
const segmentTableLength = dataView.getUint8(offset + 26);
|
||||
const segmentTableOffset = offset + 27;
|
||||
let segmentOffset = segmentTableOffset + segmentTableLength;
|
||||
|
||||
for (let i = 0; i < segmentTableLength; i++) {
|
||||
const segmentLength = dataView.getUint8(segmentTableOffset + i);
|
||||
const segmentData = arrayBuffer.slice(segmentOffset, segmentOffset + segmentLength);
|
||||
segmentOffset += segmentLength;
|
||||
|
||||
const header = new TextDecoder().decode(segmentData.slice(0, 8));
|
||||
if (header === 'OpusHead' || header === 'OpusTags') {
|
||||
console.log(`跳过 ${header},大小: ${segmentLength} 字节`);
|
||||
} else if (segmentLength >= 50 && segmentLength <= 300) {
|
||||
frames.push(segmentData);
|
||||
}
|
||||
}
|
||||
offset = segmentOffset;
|
||||
} else {
|
||||
const remainingLength = arrayBuffer.byteLength - offset;
|
||||
if (remainingLength >= 50 && remainingLength <= 300) {
|
||||
frames.push(arrayBuffer.slice(offset));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (frames.length === 0) {
|
||||
console.warn('未找到有效裸 Opus 帧,返回原始数据');
|
||||
return [arrayBuffer];
|
||||
}
|
||||
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
|
||||
return frames;
|
||||
},
|
||||
async initRecorder() {
|
||||
console.log('开始初始化录音');
|
||||
try {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log('获取麦克风权限成功,stream:', this.stream);
|
||||
|
||||
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
}
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
|
||||
|
||||
this.recorder = new Recorder({
|
||||
encoderPath: '/encoderWorker.min.js',
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
frameSize: 960,
|
||||
encoderApplication: 2048,
|
||||
encoderFrameSize: 60,
|
||||
encoderBitRate: 24000,
|
||||
monitorGain: 0,
|
||||
sourceNode: this.sourceNode,
|
||||
ogg: false,
|
||||
streamPages: false,
|
||||
maxFramesPerPage: 1,
|
||||
});
|
||||
|
||||
this.recorder.ondataavailable = (data) => {
|
||||
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
|
||||
const frames = this.stripOggContainer(data);
|
||||
frames.forEach((frame, index) => {
|
||||
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
console.log(`帧 ${index} 大小: ${frame.byteLength} 字节,前8字节: ${frameHead}`);
|
||||
|
||||
if (frame.byteLength < 50 || frame.byteLength > 300) {
|
||||
console.warn(`帧 ${index} 大小异常: ${frame.byteLength} 字节,预期 50-300 字节`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(frame);
|
||||
console.log('发送裸 Opus 帧:', frame.byteLength);
|
||||
} else {
|
||||
console.warn('WebSocket 未连接,跳过发送');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.recorder.onstart = () => {
|
||||
console.log('录音已启动');
|
||||
};
|
||||
|
||||
this.recorder.onstop = () => {
|
||||
console.log('录音停止');
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
this.sourceNode = null;
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
console.log('Recorder 初始化成功');
|
||||
} catch (err) {
|
||||
console.error('初始化录音失败:', err);
|
||||
alert('无法访问麦克风或录音初始化失败,请检查权限');
|
||||
}
|
||||
},
|
||||
async toggleRecording() {
|
||||
console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
|
||||
if (!this.recorder) {
|
||||
await this.initRecorder();
|
||||
if (!this.recorder) {
|
||||
console.error('recorder 初始化失败');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.isRecording) {
|
||||
console.log('停止录音');
|
||||
this.recorder.stop();
|
||||
this.isRecording = false;
|
||||
} else {
|
||||
try {
|
||||
console.log('开始录音');
|
||||
await this.initRecorder();
|
||||
await this.recorder.start();
|
||||
console.log('录音开始后,状态:', this.recorder.state);
|
||||
this.isRecording = true;
|
||||
} catch (err) {
|
||||
console.error('录音启动失败:', err);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
console.log('组件挂载,初始化 WebSocket');
|
||||
this.connectWebSocket();
|
||||
},
|
||||
destroyed() {
|
||||
if (this.ws) this.ws.close();
|
||||
if (this.stream) this.stream.getTracks().forEach(track => track.stop());
|
||||
if (this.recorder) this.recorder.stop();
|
||||
if (this.audioContext) this.audioContext.close();
|
||||
if (this.audioDecoder) this.audioDecoder.destroy();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 10px 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.user {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
background: #e9ecef;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.controls {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.recording {
|
||||
background: #dc3545;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
@@ -69,6 +69,11 @@ def get_config_from_api(config):
|
||||
"url": config["manager-api"].get("url", ""),
|
||||
"secret": config["manager-api"].get("secret", ""),
|
||||
}
|
||||
if config.get("server"):
|
||||
config_data["server"] = {
|
||||
"ip": config["server"].get("ip", ""),
|
||||
"port": config["server"].get("port", ""),
|
||||
}
|
||||
return config_data
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
from loguru import logger
|
||||
from config.config_loader import load_config
|
||||
|
||||
SERVER_VERSION = "0.3.6"
|
||||
SERVER_VERSION = "0.3.10"
|
||||
|
||||
|
||||
def get_module_abbreviation(module_name, module_dict):
|
||||
|
||||
@@ -43,6 +43,14 @@ def check_config_file():
|
||||
missing_keys = find_missing_keys(new_config, old_config)
|
||||
read_config_from_api = old_config.get("read_config_from_api", False)
|
||||
if read_config_from_api:
|
||||
old_config_origin = read_config(old_config_file)
|
||||
if old_config_origin.get("selected_module") is not None:
|
||||
missing_keys_str = "\n".join(f"- {key}" for key in missing_keys)
|
||||
error_msg = "您的配置文件好像既包含智控台的配置又包含本地配置:\n"
|
||||
error_msg += "\n建议您:\n"
|
||||
error_msg += "1、将根目录的config_from_api.yaml文件复制到data下,重命名为.config.yaml\n"
|
||||
error_msg += "2、按教程配置好接口地址和密钥\n"
|
||||
raise ValueError(error_msg)
|
||||
return
|
||||
|
||||
if missing_keys:
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
# 先启动manager-api和manager-web,注册一个账号,第一个注册的账号为管理员
|
||||
# 使用管理员,进入【参数管理】页面,找到【server.secret】,复制它到参数值,注意每次从零部署,server.secret都会变化
|
||||
# 打开本data目录下的.config.yaml文件,修改manager-api.secret为刚才复制出来的server.secret
|
||||
server:
|
||||
ip: 0.0.0.0
|
||||
port: 8000
|
||||
manager-api:
|
||||
# 你的manager-api的地址,最好使用局域网ip
|
||||
url: http://127.0.0.1:8002/xiaozhi
|
||||
|
||||
@@ -29,6 +29,7 @@ from core.auth import AuthMiddleware, AuthenticationError
|
||||
from core.mcp.manager import MCPManager
|
||||
from config.config_loader import get_private_config_from_api
|
||||
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
|
||||
from core.utils.output_counter import add_device_output
|
||||
|
||||
TAG = __name__
|
||||
|
||||
@@ -57,6 +58,7 @@ class ConnectionHandler:
|
||||
self.session_id = None
|
||||
self.prompt = None
|
||||
self.welcome_msg = None
|
||||
self.max_output_size = 0
|
||||
|
||||
# 客户端状态相关
|
||||
self.client_abort = False
|
||||
@@ -214,8 +216,11 @@ class ConnectionHandler:
|
||||
|
||||
def _initialize_components(self, private_config):
|
||||
"""初始化组件"""
|
||||
self._initialize_models(private_config)
|
||||
|
||||
if private_config is not None:
|
||||
self._initialize_models(private_config)
|
||||
else:
|
||||
self.prompt = self.config["prompt"]
|
||||
self.change_system_prompt(self.prompt)
|
||||
"""加载记忆"""
|
||||
self._initialize_memory()
|
||||
"""加载意图识别"""
|
||||
@@ -238,8 +243,8 @@ class ConnectionHandler:
|
||||
begin_time = time.time()
|
||||
private_config = get_private_config_from_api(
|
||||
self.config,
|
||||
self.headers.get("device-id", None),
|
||||
self.headers.get("client-id", None),
|
||||
self.headers.get("device-id"),
|
||||
self.headers.get("client-id", self.headers.get("device-id")),
|
||||
)
|
||||
private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
|
||||
self.logger.bind(tag=TAG).info(
|
||||
@@ -312,7 +317,6 @@ class ConnectionHandler:
|
||||
self.config["selected_module"]["LLM"] = private_config["selected_module"][
|
||||
"LLM"
|
||||
]
|
||||
|
||||
if private_config.get("Memory", None) is not None:
|
||||
init_memory = True
|
||||
self.config["Memory"] = private_config["Memory"]
|
||||
@@ -325,6 +329,8 @@ class ConnectionHandler:
|
||||
self.config["selected_module"]["Intent"] = private_config[
|
||||
"selected_module"
|
||||
]["Intent"]
|
||||
if private_config.get("device_max_output_size", None) is not None:
|
||||
self.max_output_size = int(private_config["device_max_output_size"])
|
||||
try:
|
||||
modules = initialize_modules(
|
||||
self.logger,
|
||||
@@ -616,7 +622,7 @@ class ConnectionHandler:
|
||||
)
|
||||
if not bHasError:
|
||||
response_message.clear()
|
||||
self.logger.bind(tag=TAG).info(
|
||||
self.logger.bind(tag=TAG).debug(
|
||||
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}"
|
||||
)
|
||||
function_call_data = {
|
||||
@@ -842,6 +848,8 @@ class ConnectionHandler:
|
||||
self.logger.bind(tag=TAG).error(f"tts转换失败,{text}")
|
||||
return None, text, text_index
|
||||
self.logger.bind(tag=TAG).debug(f"TTS 文件生成完毕: {tts_file}")
|
||||
if self.max_output_size > 0:
|
||||
add_device_output(self.headers.get("device-id"), len(text))
|
||||
return tts_file, text, text_index
|
||||
|
||||
def clearSpeakStatus(self):
|
||||
|
||||
@@ -79,7 +79,7 @@ class FunctionHandler:
|
||||
func = funcItem.func
|
||||
arguments = function_call_data["arguments"]
|
||||
arguments = json.loads(arguments) if arguments else {}
|
||||
logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}")
|
||||
logger.bind(tag=TAG).debug(f"调用函数: {function_name}, 参数: {arguments}")
|
||||
if (
|
||||
funcItem.type == ToolType.SYSTEM_CTL
|
||||
or funcItem.type == ToolType.IOT_CTL
|
||||
|
||||
@@ -57,7 +57,7 @@ def create_iot_function(device_name, method_name, method_info):
|
||||
response_failure = "操作失败"
|
||||
|
||||
# 打印响应参数
|
||||
logger.bind(tag=TAG).info(
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
|
||||
)
|
||||
|
||||
@@ -146,7 +146,7 @@ class IotDescriptor:
|
||||
# 根据描述创建属性
|
||||
if properties is not None:
|
||||
for key, value in properties.items():
|
||||
property_item = globals()[key] = {}
|
||||
property_item = {}
|
||||
property_item["name"] = key
|
||||
property_item["description"] = value["description"]
|
||||
if value["type"] == "number":
|
||||
@@ -160,18 +160,17 @@ class IotDescriptor:
|
||||
# 根据描述创建方法
|
||||
if methods is not None:
|
||||
for key, value in methods.items():
|
||||
method = globals()[key] = {}
|
||||
method = {}
|
||||
method["description"] = value["description"]
|
||||
method["name"] = key
|
||||
for k, v in value["parameters"].items():
|
||||
method[k] = {}
|
||||
method[k]["description"] = v["description"]
|
||||
if v["type"] == "number":
|
||||
method[k]["value"] = 0
|
||||
elif v["type"] == "boolean":
|
||||
method[k]["value"] = False
|
||||
else:
|
||||
method[k]["value"] = ""
|
||||
# 检查方法是否有参数
|
||||
if "parameters" in value:
|
||||
method["parameters"] = {}
|
||||
for k, v in value["parameters"].items():
|
||||
method["parameters"][k] = {
|
||||
"description": v["description"],
|
||||
"type": v["type"],
|
||||
}
|
||||
self.methods.append(method)
|
||||
|
||||
|
||||
@@ -221,13 +220,19 @@ def register_device_type(descriptor):
|
||||
func_name = f"{device_name.lower()}_{method_name.lower()}"
|
||||
|
||||
# 创建参数字典,添加原有参数
|
||||
parameters = {
|
||||
param_name: {
|
||||
"type": param_info["type"],
|
||||
"description": param_info["description"],
|
||||
parameters = {}
|
||||
required_params = []
|
||||
|
||||
# 如果方法有参数,则添加参数信息
|
||||
if "parameters" in method_info:
|
||||
parameters = {
|
||||
param_name: {
|
||||
"type": param_info["type"],
|
||||
"description": param_info["description"],
|
||||
}
|
||||
for param_name, param_info in method_info["parameters"].items()
|
||||
}
|
||||
for param_name, param_info in method_info["parameters"].items()
|
||||
}
|
||||
required_params = list(method_info["parameters"].keys())
|
||||
|
||||
# 添加响应参数
|
||||
parameters.update(
|
||||
@@ -244,7 +249,6 @@ def register_device_type(descriptor):
|
||||
)
|
||||
|
||||
# 构建必须参数列表(原有参数 + 响应参数)
|
||||
required_params = list(method_info["parameters"].keys())
|
||||
required_params.extend(["response_success", "response_failure"])
|
||||
|
||||
func_desc = {
|
||||
@@ -282,6 +286,25 @@ async def handleIotDescriptors(conn, descriptors):
|
||||
functions_changed = False
|
||||
|
||||
for descriptor in descriptors:
|
||||
|
||||
# 如果descriptor没有properties和methods,则直接跳过
|
||||
if "properties" not in descriptor and "methods" not in descriptor:
|
||||
continue
|
||||
|
||||
# 处理缺失properties的情况
|
||||
if "properties" not in descriptor:
|
||||
descriptor["properties"] = {}
|
||||
# 从methods中提取所有参数作为properties
|
||||
if "methods" in descriptor:
|
||||
for method_name, method_info in descriptor["methods"].items():
|
||||
if "parameters" in method_info:
|
||||
for param_name, param_info in method_info["parameters"].items():
|
||||
# 将参数信息转换为属性信息
|
||||
descriptor["properties"][param_name] = {
|
||||
"description": param_info["description"],
|
||||
"type": param_info["type"],
|
||||
}
|
||||
|
||||
# 创建IOT设备描述符
|
||||
iot_descriptor = IotDescriptor(
|
||||
descriptor["name"],
|
||||
@@ -375,19 +398,17 @@ async def send_iot_conn(conn, name, method_name, parameters):
|
||||
for method in value.methods:
|
||||
# 找到了方法
|
||||
if method["name"] == method_name:
|
||||
await conn.websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "iot",
|
||||
"commands": [
|
||||
{
|
||||
"name": name,
|
||||
"method": method_name,
|
||||
"parameters": parameters,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
# 构建命令对象
|
||||
command = {
|
||||
"name": name,
|
||||
"method": method_name,
|
||||
}
|
||||
|
||||
# 只有当参数不为空时才添加parameters字段
|
||||
if parameters:
|
||||
command["parameters"] = parameters
|
||||
send_message = json.dumps({"type": "iot", "commands": [command]})
|
||||
await conn.websocket.send(send_message)
|
||||
logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
|
||||
return
|
||||
logger.bind(tag=TAG).error(f"未找到方法{method_name}")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from config.logger import setup_logging
|
||||
import time
|
||||
import asyncio
|
||||
from core.utils.util import remove_punctuation_and_length
|
||||
from core.handle.sendAudioHandle import send_stt_message
|
||||
from core.handle.intentHandler import handle_user_intent
|
||||
from core.utils.output_counter import check_device_output_limit
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -51,6 +51,15 @@ async def startToChat(conn, text):
|
||||
if conn.need_bind:
|
||||
await check_bind_device(conn)
|
||||
return
|
||||
|
||||
# 如果当日的输出字数大于限定的字数
|
||||
if conn.max_output_size > 0:
|
||||
if check_device_output_limit(
|
||||
conn.headers.get("device-id"), conn.max_output_size
|
||||
):
|
||||
await max_out_size(conn)
|
||||
return
|
||||
|
||||
# 首先进行意图分析
|
||||
intent_handled = await handle_user_intent(conn, text)
|
||||
|
||||
@@ -89,6 +98,18 @@ async def no_voice_close_connect(conn):
|
||||
await startToChat(conn, prompt)
|
||||
|
||||
|
||||
async def max_out_size(conn):
|
||||
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
|
||||
await send_stt_message(conn, text)
|
||||
conn.tts_first_text_index = 0
|
||||
conn.tts_last_text_index = 0
|
||||
conn.llm_finish_task = True
|
||||
file_path = "config/assets/max_output_size.wav"
|
||||
opus_packets, _ = conn.tts.audio_to_opus_data(file_path)
|
||||
conn.audio_play_queue.put((opus_packets, text, 0))
|
||||
conn.close_after_chat = True
|
||||
|
||||
|
||||
async def check_bind_device(conn):
|
||||
if conn.bind_code:
|
||||
# 确保bind_code是6位数字
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import openai
|
||||
from openai.types import CompletionUsage
|
||||
from config.logger import setup_logging
|
||||
from core.utils.util import check_model_key
|
||||
from core.providers.llm.base import LLMProviderBase
|
||||
@@ -70,7 +71,17 @@ class LLMProvider(LLMProviderBase):
|
||||
)
|
||||
|
||||
for chunk in stream:
|
||||
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
|
||||
# 检查是否存在有效的choice且content不为空
|
||||
if getattr(chunk, "choices", None):
|
||||
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
|
||||
# 存在 CompletionUsage 消息时,生成 Token 消耗 log
|
||||
elif isinstance(getattr(chunk, 'usage', None), CompletionUsage):
|
||||
usage_info = getattr(chunk, 'usage', None)
|
||||
logger.bind(tag=TAG).info(
|
||||
f"Token 消耗:输入 {getattr(usage_info, 'prompt_tokens', '未知')},"
|
||||
f"输出 {getattr(usage_info, 'completion_tokens', '未知')},"
|
||||
f"共计 {getattr(usage_info, 'total_tokens', '未知')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"Error in function call streaming: {e}")
|
||||
|
||||
@@ -15,7 +15,10 @@ logger = setup_logging()
|
||||
class TTSProvider(TTSProviderBase):
|
||||
def __init__(self, config, delete_audio_file):
|
||||
super().__init__(config, delete_audio_file)
|
||||
self.appid = config.get("appid")
|
||||
if config.get("appid"):
|
||||
self.appid = int(config.get("appid"))
|
||||
else:
|
||||
self.appid = ""
|
||||
self.access_token = config.get("access_token")
|
||||
self.cluster = config.get("cluster")
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import requests
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from core.providers.tts.base import TTSProviderBase
|
||||
from config.logger import setup_logging
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class TTSProvider(TTSProviderBase):
|
||||
@@ -54,6 +58,7 @@ class TTSProvider(TTSProviderBase):
|
||||
|
||||
resp = requests.request("POST", url, data=payload)
|
||||
if resp.status_code != 200:
|
||||
logger.bind(tag=TAG).error(f"TTS请求失败: {resp.text}")
|
||||
return None
|
||||
resp_json = resp.json()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import datetime
|
||||
from typing import Dict, Tuple
|
||||
|
||||
# 全局字典,用于存储每个设备的每日输出字数
|
||||
_device_daily_output: Dict[Tuple[str, datetime.date], int] = {}
|
||||
# 记录最后一次检查的日期
|
||||
_last_check_date: datetime.date = None
|
||||
|
||||
|
||||
def reset_device_output():
|
||||
"""
|
||||
重置所有设备的每日输出字数
|
||||
每天0点调用此函数
|
||||
"""
|
||||
_device_daily_output.clear()
|
||||
|
||||
|
||||
def get_device_output(device_id: str) -> int:
|
||||
"""
|
||||
获取设备当日的输出字数
|
||||
"""
|
||||
current_date = datetime.datetime.now().date()
|
||||
return _device_daily_output.get((device_id, current_date), 0)
|
||||
|
||||
|
||||
def add_device_output(device_id: str, char_count: int):
|
||||
"""
|
||||
增加设备的输出字数
|
||||
"""
|
||||
current_date = datetime.datetime.now().date()
|
||||
global _last_check_date
|
||||
|
||||
# 如果是第一次调用或者日期发生变化,清空计数器
|
||||
if _last_check_date is None or _last_check_date != current_date:
|
||||
_device_daily_output.clear()
|
||||
_last_check_date = current_date
|
||||
|
||||
current_count = _device_daily_output.get((device_id, current_date), 0)
|
||||
_device_daily_output[(device_id, current_date)] = current_count + char_count
|
||||
|
||||
|
||||
def check_device_output_limit(device_id: str, max_output_size: int) -> bool:
|
||||
"""
|
||||
检查设备是否超过输出限制
|
||||
:return: True 如果超过限制,False 如果未超过
|
||||
"""
|
||||
if not device_id:
|
||||
return False
|
||||
current_output = get_device_output(device_id)
|
||||
return current_output >= max_output_size
|
||||
@@ -24,7 +24,7 @@ class WebSocketServer:
|
||||
|
||||
async def start(self):
|
||||
server_config = self.config["server"]
|
||||
host = server_config["ip"]
|
||||
host = server_config.get("ip", "0.0.0.0")
|
||||
port = int(server_config.get("port", 8000))
|
||||
|
||||
self.logger.bind(tag=TAG).info(
|
||||
|
||||
@@ -47,6 +47,10 @@ services:
|
||||
- SPRING_DATASOURCE_DRUID_PASSWORD=123456
|
||||
- SPRING_DATA_REDIS_HOST=xiaozhi-esp32-server-redis
|
||||
- SPRING_DATA_REDIS_PORT=6379
|
||||
volumes:
|
||||
# 配置文件目录
|
||||
- ./uploadfile:/app/uploadfile
|
||||
|
||||
xiaozhi-esp32-server-db:
|
||||
image: mysql:latest
|
||||
container_name: xiaozhi-esp32-server-db
|
||||
@@ -59,7 +63,7 @@ services:
|
||||
networks:
|
||||
- default
|
||||
expose:
|
||||
- "3306:3306"
|
||||
- 3306
|
||||
volumes:
|
||||
- ./mysql/data:/var/lib/mysql
|
||||
environment:
|
||||
|
||||
@@ -25,3 +25,4 @@ sherpa_onnx==1.11.0
|
||||
mcp==1.4.1
|
||||
cnlunar==0.2.0
|
||||
PySocks==1.7.1
|
||||
dashscope==1.23.1
|
||||
Reference in New Issue
Block a user