mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 02:23:56 +08:00
Compare commits
4
Commits
py-test-mqtt
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de45f73efd | ||
|
|
1dc46e30d7 | ||
|
|
18e5c47a06 | ||
|
|
4a2c90ec87 |
@@ -21,14 +21,14 @@
|
|||||||
<junit.version>5.10.1</junit.version>
|
<junit.version>5.10.1</junit.version>
|
||||||
<druid.version>1.2.20</druid.version>
|
<druid.version>1.2.20</druid.version>
|
||||||
<mybatisplus.version>3.5.17</mybatisplus.version>
|
<mybatisplus.version>3.5.17</mybatisplus.version>
|
||||||
<hutool.version>5.8.24</hutool.version>
|
<hutool.version>5.8.46</hutool.version>
|
||||||
<jsoup.version>1.19.1</jsoup.version>
|
<jsoup.version>1.22.2</jsoup.version>
|
||||||
<knife4j.version>4.6.0</knife4j.version>
|
<knife4j.version>4.6.0</knife4j.version>
|
||||||
<springdoc.version>2.8.8</springdoc.version>
|
<springdoc.version>2.8.8</springdoc.version>
|
||||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
<commons-lang3.version>3.20.0</commons-lang3.version>
|
||||||
<shiro.version>2.0.2</shiro.version>
|
<shiro.version>2.0.2</shiro.version>
|
||||||
<captcha.version>1.6.2</captcha.version>
|
<captcha.version>1.6.2</captcha.version>
|
||||||
<guava.version>33.0.0-jre</guava.version>
|
<guava.version>33.6.0-jre</guava.version>
|
||||||
<liquibase-core.version>4.20.0</liquibase-core.version>
|
<liquibase-core.version>4.20.0</liquibase-core.version>
|
||||||
<aliyun-sms-version>4.1.0</aliyun-sms-version>
|
<aliyun-sms-version>4.1.0</aliyun-sms-version>
|
||||||
<okio-version>3.4.0</okio-version>
|
<okio-version>3.4.0</okio-version>
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package xiaozhi.common.utils;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 哈希加密算法的工具类
|
|
||||||
* @author zjy
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public class HashEncryptionUtil {
|
|
||||||
/**
|
|
||||||
* 使用md5进行加密
|
|
||||||
* @param context 被加密的内容
|
|
||||||
* @return 哈希值
|
|
||||||
*/
|
|
||||||
public static String Md5hexDigest(String context){
|
|
||||||
return hexDigest(context,"MD5");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 指定哈希算法进行加密
|
|
||||||
* @param context 被加密的内容
|
|
||||||
* @param algorithm 哈希算法
|
|
||||||
* @return 哈希值
|
|
||||||
*/
|
|
||||||
public static String hexDigest(String context,String algorithm ){
|
|
||||||
// 获取MD5算法实例
|
|
||||||
MessageDigest md = null;
|
|
||||||
try {
|
|
||||||
md = MessageDigest.getInstance(algorithm);
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
log.error("加密失败的算法:{}",algorithm);
|
|
||||||
throw new RuntimeException("加密失败,"+ algorithm +"哈希算法系统不支持");
|
|
||||||
}
|
|
||||||
// 计算智能体id的MD5值
|
|
||||||
byte[] messageDigest = md.digest(context.getBytes());
|
|
||||||
// 将字节数组转换为十六进制字符串
|
|
||||||
StringBuilder hexString = new StringBuilder();
|
|
||||||
for (byte b : messageDigest) {
|
|
||||||
String hex = Integer.toHexString(0xFF & b);
|
|
||||||
if (hex.length() == 1) {
|
|
||||||
hexString.append('0');
|
|
||||||
}
|
|
||||||
hexString.append(hex);
|
|
||||||
}
|
|
||||||
return hexString.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package xiaozhi.common.utils;
|
|
||||||
|
|
||||||
import cn.hutool.core.util.ReUtil;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用工具类
|
|
||||||
*/
|
|
||||||
public class ToolUtil {
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ToolUtil.class);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对象是否不为空(新增)
|
|
||||||
*/
|
|
||||||
public static boolean isNotEmpty(Object o) {
|
|
||||||
return !isEmpty(o);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对象是否为空
|
|
||||||
*/
|
|
||||||
public static boolean isEmpty(Object o) {
|
|
||||||
if (o == null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (o instanceof String) {
|
|
||||||
if (o.toString().trim().equals("")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (o instanceof List) {
|
|
||||||
if (((List) o).size() == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (o instanceof Map) {
|
|
||||||
if (((Map) o).size() == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (o instanceof Set) {
|
|
||||||
if (((Set) o).size() == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (o instanceof Object[]) {
|
|
||||||
if (((Object[]) o).length == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (o instanceof int[]) {
|
|
||||||
if (((int[]) o).length == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (o instanceof long[]) {
|
|
||||||
if (((long[]) o).length == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对象组中是否存在空对象
|
|
||||||
*/
|
|
||||||
public static boolean isOneEmpty(Object... os) {
|
|
||||||
for (Object o : os) {
|
|
||||||
if (isEmpty(o)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对象组中是否全是空对象
|
|
||||||
*/
|
|
||||||
public static boolean isAllEmpty(Object... os) {
|
|
||||||
for (Object o : os) {
|
|
||||||
if (!isEmpty(o)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -5,6 +5,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import cn.hutool.core.collection.ListUtil;
|
import cn.hutool.core.collection.ListUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -20,7 +21,6 @@ import xiaozhi.common.constant.Constant;
|
|||||||
import xiaozhi.common.page.PageData;
|
import xiaozhi.common.page.PageData;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
import xiaozhi.common.utils.JsonUtils;
|
import xiaozhi.common.utils.JsonUtils;
|
||||||
import xiaozhi.common.utils.ToolUtil;
|
|
||||||
import xiaozhi.modules.agent.Enums.AgentChatHistoryType;
|
import xiaozhi.modules.agent.Enums.AgentChatHistoryType;
|
||||||
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
|
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
|
||||||
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||||
@@ -107,7 +107,7 @@ public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHisto
|
|||||||
if (deleteAudio) {
|
if (deleteAudio) {
|
||||||
// 分批删除音频,避免超时
|
// 分批删除音频,避免超时
|
||||||
List<String> audioIds = baseMapper.getAudioIdsByAgentId(agentId);
|
List<String> audioIds = baseMapper.getAudioIdsByAgentId(agentId);
|
||||||
if (ToolUtil.isNotEmpty(audioIds)) {
|
if (CollUtil.isNotEmpty(audioIds)) {
|
||||||
// 每批删除1000条
|
// 每批删除1000条
|
||||||
List<List<String>> batch = ListUtil.split(audioIds, 1000);
|
List<List<String>> batch = ListUtil.split(audioIds, 1000);
|
||||||
batch.forEach(dataList -> {
|
batch.forEach(dataList -> {
|
||||||
|
|||||||
+2
-2
@@ -12,11 +12,11 @@ import java.util.stream.Collectors;
|
|||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import cn.hutool.crypto.digest.DigestUtil;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import xiaozhi.common.constant.Constant;
|
import xiaozhi.common.constant.Constant;
|
||||||
import xiaozhi.common.utils.AESUtils;
|
import xiaozhi.common.utils.AESUtils;
|
||||||
import xiaozhi.common.utils.HashEncryptionUtil;
|
|
||||||
import xiaozhi.common.utils.JsonUtils;
|
import xiaozhi.common.utils.JsonUtils;
|
||||||
import xiaozhi.modules.agent.Enums.XiaoZhiMcpJsonRpcJson;
|
import xiaozhi.modules.agent.Enums.XiaoZhiMcpJsonRpcJson;
|
||||||
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
|
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
|
||||||
@@ -226,7 +226,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
|||||||
*/
|
*/
|
||||||
private static String encryptToken(String agentId, String key) {
|
private static String encryptToken(String agentId, String key) {
|
||||||
// 使用md5对智能体id进行加密
|
// 使用md5对智能体id进行加密
|
||||||
String md5 = HashEncryptionUtil.Md5hexDigest(agentId);
|
String md5 = DigestUtil.md5Hex(agentId);
|
||||||
// aes需要加密文本
|
// aes需要加密文本
|
||||||
String json = "{\"agentId\": \"%s\"}".formatted(md5);
|
String json = "{\"agentId\": \"%s\"}".formatted(md5);
|
||||||
// 加密后成token值
|
// 加密后成token值
|
||||||
|
|||||||
+4
-4
@@ -18,6 +18,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import xiaozhi.common.constant.Constant;
|
import xiaozhi.common.constant.Constant;
|
||||||
import xiaozhi.common.exception.ErrorCode;
|
import xiaozhi.common.exception.ErrorCode;
|
||||||
@@ -29,7 +30,6 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
|
|||||||
import xiaozhi.common.user.UserDetail;
|
import xiaozhi.common.user.UserDetail;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
import xiaozhi.common.utils.JsonUtils;
|
import xiaozhi.common.utils.JsonUtils;
|
||||||
import xiaozhi.common.utils.ToolUtil;
|
|
||||||
import xiaozhi.modules.agent.dao.AgentDao;
|
import xiaozhi.modules.agent.dao.AgentDao;
|
||||||
import xiaozhi.modules.agent.dao.AgentTagDao;
|
import xiaozhi.modules.agent.dao.AgentTagDao;
|
||||||
import xiaozhi.modules.agent.dto.AgentCreateDTO;
|
import xiaozhi.modules.agent.dto.AgentCreateDTO;
|
||||||
@@ -243,13 +243,13 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
|||||||
.map(DeviceEntity::getAgentId)
|
.map(DeviceEntity::getAgentId)
|
||||||
.distinct()
|
.distinct()
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if (ToolUtil.isNotEmpty(agentIds)) {
|
if (CollUtil.isNotEmpty(agentIds)) {
|
||||||
w.or().in("id", agentIds);
|
w.or().in("id", agentIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按标签名搜索
|
// 按标签名搜索
|
||||||
List<String> tagAgentIds = agentTagService.getAgentIdsByTagName(keyword);
|
List<String> tagAgentIds = agentTagService.getAgentIdsByTagName(keyword);
|
||||||
if (ToolUtil.isNotEmpty(tagAgentIds)) {
|
if (CollUtil.isNotEmpty(tagAgentIds)) {
|
||||||
w.or().in("id", tagAgentIds);
|
w.or().in("id", tagAgentIds);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -291,7 +291,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
|||||||
|
|
||||||
// 获取标签列表
|
// 获取标签列表
|
||||||
List<AgentTagEntity> tags = agentTagDao.selectByAgentId(agent.getId());
|
List<AgentTagEntity> tags = agentTagDao.selectByAgentId(agent.getId());
|
||||||
if (ToolUtil.isNotEmpty(tags)) {
|
if (CollUtil.isNotEmpty(tags)) {
|
||||||
dto.setTags(tags.stream().map(this::convertTagToDTO).collect(Collectors.toList()));
|
dto.setTags(tags.stream().map(this::convertTagToDTO).collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -5,8 +5,6 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -31,6 +29,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import cn.hutool.crypto.digest.DigestUtil;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.Parameters;
|
import io.swagger.v3.oas.annotations.Parameters;
|
||||||
@@ -288,7 +287,7 @@ public class OTAMagController {
|
|||||||
|
|
||||||
// 返回文件路径
|
// 返回文件路径
|
||||||
return new Result<String>().ok(filePath.toString());
|
return new Result<String>().ok(filePath.toString());
|
||||||
} catch (IOException | NoSuchAlgorithmException e) {
|
} catch (IOException e) {
|
||||||
return new Result<String>().error("文件上传失败:" + e.getMessage());
|
return new Result<String>().error("文件上传失败:" + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,13 +328,7 @@ public class OTAMagController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
|
private String calculateMD5(MultipartFile file) throws IOException {
|
||||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
return DigestUtil.md5Hex(file.getBytes());
|
||||||
byte[] digest = md.digest(file.getBytes());
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (byte b : digest) {
|
|
||||||
sb.append(String.format("%02x", b));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import cn.hutool.core.map.MapUtil;
|
import cn.hutool.core.map.MapUtil;
|
||||||
import cn.hutool.core.util.RandomUtil;
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
@@ -51,7 +52,6 @@ import xiaozhi.common.user.UserDetail;
|
|||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
import xiaozhi.common.utils.DateUtils;
|
import xiaozhi.common.utils.DateUtils;
|
||||||
import xiaozhi.common.utils.JsonUtils;
|
import xiaozhi.common.utils.JsonUtils;
|
||||||
import xiaozhi.common.utils.ToolUtil;
|
|
||||||
import xiaozhi.modules.device.dao.DeviceDao;
|
import xiaozhi.modules.device.dao.DeviceDao;
|
||||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||||
import xiaozhi.modules.device.dto.DevicePageUserDTO;
|
import xiaozhi.modules.device.dto.DevicePageUserDTO;
|
||||||
@@ -103,15 +103,15 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
throw new RenException(ErrorCode.ACTIVATION_CODE_EMPTY);
|
throw new RenException(ErrorCode.ACTIVATION_CODE_EMPTY);
|
||||||
}
|
}
|
||||||
String deviceKey = RedisKeys.getOtaActivationCode(activationCode);
|
String deviceKey = RedisKeys.getOtaActivationCode(activationCode);
|
||||||
Object cacheDeviceId = redisUtils.get(deviceKey);
|
String cacheDeviceId = (String) redisUtils.get(deviceKey);
|
||||||
if (ToolUtil.isEmpty(cacheDeviceId)) {
|
if (StringUtils.isBlank(cacheDeviceId)) {
|
||||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||||
}
|
}
|
||||||
String deviceId = (String) cacheDeviceId;
|
String deviceId = cacheDeviceId;
|
||||||
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
||||||
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
|
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
|
||||||
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(cacheDeviceKey));
|
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(cacheDeviceKey));
|
||||||
if (ToolUtil.isEmpty(cacheMap)) {
|
if (MapUtil.isEmpty(cacheMap)) {
|
||||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||||
}
|
}
|
||||||
String cachedCode = (String) cacheMap.get("activation_code");
|
String cachedCode = (String) cacheMap.get("activation_code");
|
||||||
@@ -181,7 +181,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
.builder(new HashMap<String, Set<String>>())
|
.builder(new HashMap<String, Set<String>>())
|
||||||
.put("clientIds", deviceIds).build();
|
.put("clientIds", deviceIds).build();
|
||||||
|
|
||||||
if (ToolUtil.isNotEmpty(deviceIds)) {
|
if (CollUtil.isNotEmpty(deviceIds)) {
|
||||||
return postToMqttGateway(url, params);
|
return postToMqttGateway(url, params);
|
||||||
}
|
}
|
||||||
// 返回响应
|
// 返回响应
|
||||||
|
|||||||
+2
-2
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -23,7 +24,6 @@ import xiaozhi.common.exception.ErrorCode;
|
|||||||
import xiaozhi.common.exception.RenException;
|
import xiaozhi.common.exception.RenException;
|
||||||
import xiaozhi.common.page.PageData;
|
import xiaozhi.common.page.PageData;
|
||||||
import xiaozhi.common.utils.Result;
|
import xiaozhi.common.utils.Result;
|
||||||
import xiaozhi.common.utils.ToolUtil;
|
|
||||||
import xiaozhi.modules.knowledge.dto.KnowledgeBaseDTO;
|
import xiaozhi.modules.knowledge.dto.KnowledgeBaseDTO;
|
||||||
import xiaozhi.modules.knowledge.service.KnowledgeBaseService;
|
import xiaozhi.modules.knowledge.service.KnowledgeBaseService;
|
||||||
import xiaozhi.modules.knowledge.service.KnowledgeManagerService;
|
import xiaozhi.modules.knowledge.service.KnowledgeManagerService;
|
||||||
@@ -140,7 +140,7 @@ public class KnowledgeBaseController {
|
|||||||
List<String> idList = Arrays.asList(ids.split(","));
|
List<String> idList = Arrays.asList(ids.split(","));
|
||||||
List<KnowledgeBaseDTO> knowledgeBaseDTOs = Optional.ofNullable(knowledgeBaseService.getByDatasetIdList(idList))
|
List<KnowledgeBaseDTO> knowledgeBaseDTOs = Optional.ofNullable(knowledgeBaseService.getByDatasetIdList(idList))
|
||||||
.orElseGet(ArrayList::new);
|
.orElseGet(ArrayList::new);
|
||||||
if (ToolUtil.isNotEmpty(knowledgeBaseDTOs)) {
|
if (CollUtil.isNotEmpty(knowledgeBaseDTOs)) {
|
||||||
knowledgeBaseDTOs.forEach(item -> {
|
knowledgeBaseDTOs.forEach(item -> {
|
||||||
// 检查权限:用户只能删除自己创建的知识库
|
// 检查权限:用户只能删除自己创建的知识库
|
||||||
if (item.getCreator() == null || !item.getCreator().equals(currentUserId)) {
|
if (item.getCreator() == null || !item.getCreator().equals(currentUserId)) {
|
||||||
|
|||||||
+4
-20
@@ -1,8 +1,7 @@
|
|||||||
package xiaozhi.modules.security.service.impl;
|
package xiaozhi.modules.security.service.impl;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Random;
|
import java.time.Duration;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -13,6 +12,7 @@ import com.google.common.cache.CacheBuilder;
|
|||||||
import com.wf.captcha.SpecCaptcha;
|
import com.wf.captcha.SpecCaptcha;
|
||||||
import com.wf.captcha.base.Captcha;
|
import com.wf.captcha.base.Captcha;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import xiaozhi.common.constant.Constant;
|
import xiaozhi.common.constant.Constant;
|
||||||
@@ -41,7 +41,7 @@ public class CaptchaServiceImpl implements CaptchaService {
|
|||||||
* Local Cache 5分钟过期
|
* Local Cache 5分钟过期
|
||||||
*/
|
*/
|
||||||
Cache<String, String> localCache = CacheBuilder.newBuilder().maximumSize(1000)
|
Cache<String, String> localCache = CacheBuilder.newBuilder().maximumSize(1000)
|
||||||
.expireAfterAccess(5, TimeUnit.MINUTES).build();
|
.expireAfterAccess(Duration.ofMinutes(5)).build();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void create(HttpServletResponse response, String uuid) throws IOException {
|
public void create(HttpServletResponse response, String uuid) throws IOException {
|
||||||
@@ -113,7 +113,7 @@ public class CaptchaServiceImpl implements CaptchaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String key = RedisKeys.getSMSValidateCodeKey(phone);
|
String key = RedisKeys.getSMSValidateCodeKey(phone);
|
||||||
String validateCodes = generateValidateCode(6);
|
String validateCodes = RandomUtil.randomNumbers(6);
|
||||||
|
|
||||||
// 设置验证码
|
// 设置验证码
|
||||||
setCache(key, validateCodes);
|
setCache(key, validateCodes);
|
||||||
@@ -135,22 +135,6 @@ public class CaptchaServiceImpl implements CaptchaService {
|
|||||||
return validate(key, code, delete);
|
return validate(key, code, delete);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成指定数量的随机数验证码
|
|
||||||
*
|
|
||||||
* @param length 数量
|
|
||||||
* @return 随机码
|
|
||||||
*/
|
|
||||||
private String generateValidateCode(Integer length) {
|
|
||||||
String chars = "0123456789"; // 字符范围可以自定义:数字
|
|
||||||
Random random = new Random();
|
|
||||||
StringBuilder code = new StringBuilder();
|
|
||||||
for (int i = 0; i < length; i++) {
|
|
||||||
code.append(chars.charAt(random.nextInt(chars.length())));
|
|
||||||
}
|
|
||||||
return code.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setCache(String key, String value) {
|
private void setCache(String key, String value) {
|
||||||
if (open) {
|
if (open) {
|
||||||
key = RedisKeys.getCaptchaKey(key);
|
key = RedisKeys.getCaptchaKey(key);
|
||||||
|
|||||||
+3
-3
@@ -12,6 +12,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import xiaozhi.common.exception.RenException;
|
import xiaozhi.common.exception.RenException;
|
||||||
import xiaozhi.common.exception.ErrorCode;
|
import xiaozhi.common.exception.ErrorCode;
|
||||||
@@ -21,7 +22,6 @@ import xiaozhi.common.redis.RedisUtils;
|
|||||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
import xiaozhi.common.utils.JsonUtils;
|
import xiaozhi.common.utils.JsonUtils;
|
||||||
import xiaozhi.common.utils.ToolUtil;
|
|
||||||
import xiaozhi.modules.sys.dao.SysDictDataDao;
|
import xiaozhi.modules.sys.dao.SysDictDataDao;
|
||||||
import xiaozhi.modules.sys.dao.SysUserDao;
|
import xiaozhi.modules.sys.dao.SysUserDao;
|
||||||
import xiaozhi.modules.sys.dto.SysDictDataDTO;
|
import xiaozhi.modules.sys.dto.SysDictDataDTO;
|
||||||
@@ -104,13 +104,13 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
|||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void delete(Long[] ids) {
|
public void delete(Long[] ids) {
|
||||||
List<Long> idList = Arrays.asList(ids);
|
List<Long> idList = Arrays.asList(ids);
|
||||||
if (ToolUtil.isNotEmpty(idList)) {
|
if (CollUtil.isNotEmpty(idList)) {
|
||||||
//批量删除redis字典
|
//批量删除redis字典
|
||||||
List<String> redisKeyList = new ArrayList<>();
|
List<String> redisKeyList = new ArrayList<>();
|
||||||
//批量获取字典类型
|
//批量获取字典类型
|
||||||
List<String> dictTypeList = Optional.ofNullable(baseDao.getDictTypesByIdList(idList)).orElseGet(ArrayList::new);
|
List<String> dictTypeList = Optional.ofNullable(baseDao.getDictTypesByIdList(idList)).orElseGet(ArrayList::new);
|
||||||
dictTypeList.forEach(dictType -> redisKeyList.add(RedisKeys.getDictDataByTypeKey(dictType)));
|
dictTypeList.forEach(dictType -> redisKeyList.add(RedisKeys.getDictDataByTypeKey(dictType)));
|
||||||
if (ToolUtil.isNotEmpty(redisKeyList)) {
|
if (CollUtil.isNotEmpty(redisKeyList)) {
|
||||||
//清除缓存
|
//清除缓存
|
||||||
redisUtils.delete(redisKeyList);
|
redisUtils.delete(redisKeyList);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public class TimbreDataDTO {
|
|||||||
|
|
||||||
@Schema(description = "排序")
|
@Schema(description = "排序")
|
||||||
@Min(value = 0, message = "{sort.number}")
|
@Min(value = 0, message = "{sort.number}")
|
||||||
private long sort;
|
private Long sort;
|
||||||
|
|
||||||
@Schema(description = "对应 TTS 模型主键")
|
@Schema(description = "对应 TTS 模型主键")
|
||||||
@NotBlank(message = "{timbre.ttsModelId.require}")
|
@NotBlank(message = "{timbre.ttsModelId.require}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package xiaozhi.modules.timbre.entity;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
|
||||||
@@ -41,7 +42,8 @@ public class TimbreEntity {
|
|||||||
private String referenceText;
|
private String referenceText;
|
||||||
|
|
||||||
@Schema(description = "排序")
|
@Schema(description = "排序")
|
||||||
private long sort;
|
@TableField(updateStrategy = FieldStrategy.NOT_NULL)
|
||||||
|
private Long sort;
|
||||||
|
|
||||||
@Schema(description = "对应 TTS 模型主键")
|
@Schema(description = "对应 TTS 模型主键")
|
||||||
private String ttsModelId;
|
private String ttsModelId;
|
||||||
|
|||||||
+3
@@ -99,6 +99,9 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
|||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void save(TimbreDataDTO dto) {
|
public void save(TimbreDataDTO dto) {
|
||||||
isTtsModelId(dto.getTtsModelId());
|
isTtsModelId(dto.getTtsModelId());
|
||||||
|
if (dto.getSort() == null) {
|
||||||
|
dto.setSort(0L);
|
||||||
|
}
|
||||||
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
|
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
|
||||||
baseDao.insert(timbreEntity);
|
baseDao.insert(timbreEntity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class TimbreDetailsVO implements Serializable {
|
|||||||
private String referenceText;
|
private String referenceText;
|
||||||
|
|
||||||
@Schema(description = "排序")
|
@Schema(description = "排序")
|
||||||
private long sort;
|
private Long sort;
|
||||||
|
|
||||||
@Schema(description = "对应 TTS 模型主键")
|
@Schema(description = "对应 TTS 模型主键")
|
||||||
private String ttsModelId;
|
private String ttsModelId;
|
||||||
|
|||||||
+2
-2
@@ -17,6 +17,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import xiaozhi.common.constant.Constant;
|
import xiaozhi.common.constant.Constant;
|
||||||
@@ -26,7 +27,6 @@ import xiaozhi.common.page.PageData;
|
|||||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
import xiaozhi.common.utils.DateUtils;
|
import xiaozhi.common.utils.DateUtils;
|
||||||
import xiaozhi.common.utils.ToolUtil;
|
|
||||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||||
import xiaozhi.modules.model.service.ModelConfigService;
|
import xiaozhi.modules.model.service.ModelConfigService;
|
||||||
import xiaozhi.modules.sys.dao.SysUserDao;
|
import xiaozhi.modules.sys.dao.SysUserDao;
|
||||||
@@ -120,7 +120,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
|
|||||||
entity.setTrainStatus(0); // 默认训练中
|
entity.setTrainStatus(0); // 默认训练中
|
||||||
batchInsertList.add(entity);
|
batchInsertList.add(entity);
|
||||||
}
|
}
|
||||||
if (ToolUtil.isNotEmpty(batchInsertList)) {
|
if (CollUtil.isNotEmpty(batchInsertList)) {
|
||||||
insertBatch(batchInsertList);
|
insertBatch(batchInsertList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
@@ -2,16 +2,20 @@ package xiaozhi.modules.timbre.service.impl;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import xiaozhi.common.redis.RedisUtils;
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.modules.timbre.dao.TimbreDao;
|
import xiaozhi.modules.timbre.dao.TimbreDao;
|
||||||
|
import xiaozhi.modules.timbre.dto.TimbreDataDTO;
|
||||||
import xiaozhi.modules.timbre.entity.TimbreEntity;
|
import xiaozhi.modules.timbre.entity.TimbreEntity;
|
||||||
|
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
|
||||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||||
|
|
||||||
@@ -54,4 +58,74 @@ class TimbreServiceImplTest {
|
|||||||
|
|
||||||
assertNull(service.getDefaultLanguageById("voice-id"));
|
assertNull(service.getDefaultLanguageById("voice-id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateLeavesSortOutOfTheUpdateWhenRequestOmitsIt() {
|
||||||
|
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||||
|
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||||
|
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, mock(VoiceCloneDao.class), redisUtils);
|
||||||
|
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
|
||||||
|
|
||||||
|
TimbreDataDTO dto = validTimbreData();
|
||||||
|
service.update("voice-id", dto);
|
||||||
|
|
||||||
|
verify(timbreDao, never()).selectById("voice-id");
|
||||||
|
verify(timbreDao).updateById(argThat((TimbreEntity entity) ->
|
||||||
|
"voice-id".equals(entity.getId()) && entity.getSort() == null));
|
||||||
|
verify(redisUtils).delete("timbre:details:voice-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUsesExplicitSortWithoutLoadingExistingTimbre() {
|
||||||
|
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||||
|
TimbreServiceImpl service = new TimbreServiceImpl(
|
||||||
|
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
|
||||||
|
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
|
||||||
|
TimbreDataDTO dto = validTimbreData();
|
||||||
|
dto.setSort(0L);
|
||||||
|
|
||||||
|
service.update("voice-id", dto);
|
||||||
|
|
||||||
|
verify(timbreDao, never()).selectById("voice-id");
|
||||||
|
verify(timbreDao).updateById(argThat((TimbreEntity entity) -> entity.getSort() == 0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveDefaultsOmittedSortToZero() {
|
||||||
|
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||||
|
TimbreServiceImpl service = new TimbreServiceImpl(
|
||||||
|
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
|
||||||
|
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
|
||||||
|
|
||||||
|
service.save(validTimbreData());
|
||||||
|
|
||||||
|
verify(timbreDao).insert(argThat((TimbreEntity entity) ->
|
||||||
|
"测试音色".equals(entity.getName()) && entity.getSort() == 0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSupportsLegacyRowsWithNullSort() {
|
||||||
|
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||||
|
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||||
|
TimbreServiceImpl service = new TimbreServiceImpl(
|
||||||
|
timbreDao, mock(VoiceCloneDao.class), redisUtils);
|
||||||
|
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
|
||||||
|
TimbreEntity entity = new TimbreEntity();
|
||||||
|
entity.setId("voice-id");
|
||||||
|
entity.setSort(null);
|
||||||
|
when(timbreDao.selectById("voice-id")).thenReturn(entity);
|
||||||
|
|
||||||
|
TimbreDetailsVO details = service.get("voice-id");
|
||||||
|
|
||||||
|
assertNull(details.getSort());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimbreDataDTO validTimbreData() {
|
||||||
|
TimbreDataDTO dto = new TimbreDataDTO();
|
||||||
|
dto.setLanguages("中文");
|
||||||
|
dto.setName("测试音色");
|
||||||
|
dto.setTtsModelId("TTS_Test");
|
||||||
|
dto.setTtsVoice("test-voice");
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs",
|
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs src/pages/agent/components/voicePreviewUtils.test.mjs src/pages/device/deviceTimeUtils.test.mjs",
|
||||||
"openapi-ts-request": "openapi-ts",
|
"openapi-ts-request": "openapi-ts",
|
||||||
"prepare": "git init && husky",
|
"prepare": "git init && husky",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ModelOption,
|
ModelOption,
|
||||||
PageData,
|
PageData,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
|
TtsVoice,
|
||||||
} from './types'
|
} from './types'
|
||||||
import { http } from '@/http/request/alova'
|
import { http } from '@/http/request/alova'
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ export function deleteAgent(id: string) {
|
|||||||
|
|
||||||
// 获取TTS音色列表
|
// 获取TTS音色列表
|
||||||
export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
|
export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
|
||||||
return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, {
|
return http.Get<TtsVoice[]>(`/models/${ttsModelId}/voices`, {
|
||||||
params: {
|
params: {
|
||||||
voiceName,
|
voiceName,
|
||||||
},
|
},
|
||||||
@@ -214,7 +215,7 @@ export function updateAgentTags(agentId: string, data) {
|
|||||||
|
|
||||||
// 获取所有语言
|
// 获取所有语言
|
||||||
export function getAllLanguage(modelId: string) {
|
export function getAllLanguage(modelId: string) {
|
||||||
return http.Get<{ id: string, name: string, languages: string }[]>(`/models/${modelId}/voices`, {
|
return http.Get<TtsVoice[]>(`/models/${modelId}/voices`, {
|
||||||
meta: {
|
meta: {
|
||||||
ignoreAuth: false,
|
ignoreAuth: false,
|
||||||
toast: false,
|
toast: false,
|
||||||
@@ -225,6 +226,19 @@ export function getAllLanguage(modelId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取克隆音色的临时播放ID
|
||||||
|
* @param cloneId 克隆音色记录ID
|
||||||
|
*/
|
||||||
|
export function getVoiceCloneAudioId(cloneId: string) {
|
||||||
|
return http.Post<string>(`/voiceClone/audio/${cloneId}`, {}, {
|
||||||
|
meta: {
|
||||||
|
ignoreAuth: false,
|
||||||
|
toast: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取智能体历史版本列表
|
// 获取智能体历史版本列表
|
||||||
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
|
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
|
||||||
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
|
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ export interface CorrectWordFile {
|
|||||||
wordCount?: number
|
wordCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TtsVoice {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
voiceDemo?: string | null
|
||||||
|
languages?: string | null
|
||||||
|
isClone?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
// 角色模板数据类型
|
// 角色模板数据类型
|
||||||
export interface RoleTemplate {
|
export interface RoleTemplate {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface Device {
|
|||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
macAddress: string
|
macAddress: string
|
||||||
lastConnectedAt: string
|
lastConnectedAtTimestamp: string | null
|
||||||
autoUpdate: number
|
autoUpdate: number
|
||||||
board: string
|
board: string
|
||||||
alias?: string
|
alias?: string
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** @param {Record<string, any>} voice */
|
||||||
|
export function hasVoicePreview(voice) {
|
||||||
|
return Boolean(voice?.isClone || voice?.voiceDemo || voice?.voice_demo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVoicePreviewRequestGate() {
|
||||||
|
let sequence = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
begin() {
|
||||||
|
sequence += 1
|
||||||
|
return sequence
|
||||||
|
},
|
||||||
|
invalidate() {
|
||||||
|
sequence += 1
|
||||||
|
},
|
||||||
|
isCurrent(requestId) {
|
||||||
|
return requestId === sequence
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ id: string, isClone?: boolean, voiceDemo?: string | null }} voice
|
||||||
|
* @param {(cloneId: string) => Promise<string>} getCloneAudioId
|
||||||
|
* @param {string} baseUrl
|
||||||
|
*/
|
||||||
|
export async function resolveVoicePreviewUrl(voice, getCloneAudioId, baseUrl) {
|
||||||
|
if (!voice?.isClone) {
|
||||||
|
return typeof voice?.voiceDemo === 'string' ? voice.voiceDemo : ''
|
||||||
|
}
|
||||||
|
if (!voice.id) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = await getCloneAudioId(voice.id)
|
||||||
|
if (!uuid) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseUrl.replace(/\/+$/, '')}/voiceClone/play/${encodeURIComponent(uuid)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './voicePreviewUtils.mjs'
|
||||||
|
|
||||||
|
test('keeps normal voice previews on their direct URL', async () => {
|
||||||
|
let cloneRequestCount = 0
|
||||||
|
const url = await resolveVoicePreviewUrl({
|
||||||
|
id: 'normal-voice',
|
||||||
|
isClone: false,
|
||||||
|
voiceDemo: 'https://cdn.example.test/normal.wav',
|
||||||
|
}, async () => {
|
||||||
|
cloneRequestCount += 1
|
||||||
|
return 'unused'
|
||||||
|
}, 'https://api.example.test')
|
||||||
|
|
||||||
|
assert.equal(url, 'https://cdn.example.test/normal.wav')
|
||||||
|
assert.equal(cloneRequestCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses the clone record id to obtain and construct a temporary play URL', async () => {
|
||||||
|
let requestedCloneId = ''
|
||||||
|
const url = await resolveVoicePreviewUrl({
|
||||||
|
id: 'clone-record-id',
|
||||||
|
isClone: true,
|
||||||
|
voiceDemo: 'provider-speaker-id-must-not-be-played',
|
||||||
|
}, async (cloneId) => {
|
||||||
|
requestedCloneId = cloneId
|
||||||
|
return 'temporary uuid'
|
||||||
|
}, 'https://api.example.test/')
|
||||||
|
|
||||||
|
assert.equal(requestedCloneId, 'clone-record-id')
|
||||||
|
assert.equal(url, 'https://api.example.test/voiceClone/play/temporary%20uuid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows a preview control for cloned voices even without voiceDemo', () => {
|
||||||
|
assert.equal(hasVoicePreview({ id: 'clone-record-id', isClone: true }), true)
|
||||||
|
assert.equal(hasVoicePreview({ id: 'normal-voice', isClone: false, voiceDemo: '' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalidates an older request when the same voice is cancelled and retried', () => {
|
||||||
|
const gate = createVoicePreviewRequestGate()
|
||||||
|
const firstRequest = gate.begin()
|
||||||
|
|
||||||
|
gate.invalidate()
|
||||||
|
const retryRequest = gate.begin()
|
||||||
|
|
||||||
|
assert.equal(gate.isCurrent(firstRequest), false)
|
||||||
|
assert.equal(gate.isCurrent(retryRequest), true)
|
||||||
|
})
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
|
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate, TtsVoice } from '@/api/agent/types'
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent } from '@/api/agent/agent'
|
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, getVoiceCloneAudioId, updateAgent } from '@/api/agent/agent'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
|
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
|
||||||
|
import { getEnvBaseUrl } from '@/utils'
|
||||||
import { toast } from '@/utils/toast'
|
import { toast } from '@/utils/toast'
|
||||||
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
|
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
|
||||||
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
|
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
|
||||||
|
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './components/voicePreviewUtils.mjs'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'AgentEdit',
|
name: 'AgentEdit',
|
||||||
@@ -84,10 +86,20 @@ const modelOptions = ref<{
|
|||||||
TTS: [],
|
TTS: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface VoiceOption {
|
||||||
|
id?: string
|
||||||
|
value: string
|
||||||
|
name: string
|
||||||
|
voiceDemo?: string | null
|
||||||
|
voice_demo?: string | null
|
||||||
|
isClone: boolean
|
||||||
|
train_status?: number
|
||||||
|
}
|
||||||
|
|
||||||
// 音色选项数据
|
// 音色选项数据
|
||||||
const voiceOptions = ref<any[]>([])
|
const voiceOptions = ref<VoiceOption[]>([])
|
||||||
// 保存完整的音色信息
|
// 保存完整的音色信息
|
||||||
const voiceDetails = ref<Record<string, any>>({})
|
const voiceDetails = ref<Record<string, TtsVoice>>({})
|
||||||
|
|
||||||
// 上报模式选项数据
|
// 上报模式选项数据
|
||||||
const reportOptions = [
|
const reportOptions = [
|
||||||
@@ -139,6 +151,7 @@ interface SnapshotRestoreContext {
|
|||||||
// 音频播放相关
|
// 音频播放相关
|
||||||
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
|
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
|
||||||
const playingVoiceId = ref<string>('')
|
const playingVoiceId = ref<string>('')
|
||||||
|
const voicePreviewRequestGate = createVoicePreviewRequestGate()
|
||||||
|
|
||||||
// 使用插件store
|
// 使用插件store
|
||||||
const pluginStore = usePluginStore()
|
const pluginStore = usePluginStore()
|
||||||
@@ -513,8 +526,8 @@ interface TtsSelectionState {
|
|||||||
languageTouched: boolean
|
languageTouched: boolean
|
||||||
voiceTouched: boolean
|
voiceTouched: boolean
|
||||||
optionsModelId: string
|
optionsModelId: string
|
||||||
voiceOptions: any[]
|
voiceOptions: VoiceOption[]
|
||||||
voiceDetails: Record<string, any>
|
voiceDetails: Record<string, TtsVoice>
|
||||||
languageOptions: any[]
|
languageOptions: any[]
|
||||||
displayNames: {
|
displayNames: {
|
||||||
tts: string
|
tts: string
|
||||||
@@ -565,7 +578,7 @@ function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const allVoices = Object.values(voiceDetails.value) as any[]
|
const allVoices = Object.values(voiceDetails.value)
|
||||||
|
|
||||||
// 根据选中的语言筛选音色
|
// 根据选中的语言筛选音色
|
||||||
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
|
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
|
||||||
@@ -624,7 +637,7 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
|
|||||||
throw new Error('No TTS voice metadata is available')
|
throw new Error('No TTS voice metadata is available')
|
||||||
}
|
}
|
||||||
// 保存完整的音色信息
|
// 保存完整的音色信息
|
||||||
voiceDetails.value = res.reduce((acc, voice) => {
|
voiceDetails.value = res.reduce<Record<string, TtsVoice>>((acc, voice) => {
|
||||||
acc[voice.id] = voice
|
acc[voice.id] = voice
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
@@ -863,44 +876,85 @@ function onPickerCancel(type: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 播放音频
|
// 播放音频
|
||||||
function playAudio(voiceDemo: string, voiceId: string, event: Event) {
|
async function playAudio(voice: VoiceOption, event: Event) {
|
||||||
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
|
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
|
||||||
|
|
||||||
if (!voiceDemo) {
|
if (!hasVoicePreview(voice)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果正在播放同一个音频,则停止
|
// 如果正在播放同一个音频,则停止
|
||||||
if (playingVoiceId.value === voiceId) {
|
if (playingVoiceId.value === voice.value) {
|
||||||
stopAudio()
|
stopAudio()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止之前的音频
|
// 停止之前的音频
|
||||||
stopAudio()
|
stopAudio()
|
||||||
|
const requestId = voicePreviewRequestGate.begin()
|
||||||
|
playingVoiceId.value = voice.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioUrl = await resolveVoicePreviewUrl({
|
||||||
|
id: voice.value,
|
||||||
|
isClone: voice.isClone,
|
||||||
|
voiceDemo: voice.voiceDemo || voice.voice_demo,
|
||||||
|
}, getVoiceCloneAudioId, getEnvBaseUrl())
|
||||||
|
|
||||||
|
// 用户可能在等待克隆音色临时地址时取消或切换了音色。
|
||||||
|
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!audioUrl) {
|
||||||
|
toast.error(t('voiceprint.getAudioFailed'))
|
||||||
|
playingVoiceId.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 创建新的音频实例
|
// 创建新的音频实例
|
||||||
audioRef.value = uni.createInnerAudioContext()
|
const audio = uni.createInnerAudioContext()
|
||||||
audioRef.value.src = voiceDemo
|
audioRef.value = audio
|
||||||
playingVoiceId.value = voiceId
|
audio.src = audioUrl
|
||||||
|
|
||||||
// 监听播放结束
|
// 监听播放结束
|
||||||
audioRef.value.onEnded(() => {
|
audio.onEnded(() => {
|
||||||
|
if (
|
||||||
|
voicePreviewRequestGate.isCurrent(requestId)
|
||||||
|
&& audioRef.value === audio
|
||||||
|
&& playingVoiceId.value === voice.value
|
||||||
|
) {
|
||||||
playingVoiceId.value = ''
|
playingVoiceId.value = ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听播放错误
|
// 监听播放错误
|
||||||
audioRef.value.onError(() => {
|
audio.onError(() => {
|
||||||
toast.error('音频播放失败')
|
if (
|
||||||
|
voicePreviewRequestGate.isCurrent(requestId)
|
||||||
|
&& audioRef.value === audio
|
||||||
|
&& playingVoiceId.value === voice.value
|
||||||
|
) {
|
||||||
|
toast.error(t('voiceprint.audioPlayFailed'))
|
||||||
playingVoiceId.value = ''
|
playingVoiceId.value = ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 播放音频
|
// 播放音频
|
||||||
audioRef.value.play()
|
audio.play()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('获取克隆音色试听地址失败:', error)
|
||||||
|
toast.error(t('voiceprint.getAudioFailed'))
|
||||||
|
playingVoiceId.value = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止音频
|
// 停止音频
|
||||||
function stopAudio() {
|
function stopAudio() {
|
||||||
|
voicePreviewRequestGate.invalidate()
|
||||||
if (audioRef.value) {
|
if (audioRef.value) {
|
||||||
audioRef.value.stop()
|
audioRef.value.stop()
|
||||||
audioRef.value.destroy()
|
audioRef.value.destroy()
|
||||||
@@ -1592,10 +1646,10 @@ onMounted(async () => {
|
|||||||
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
|
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
|
||||||
@click="onPickerConfirm('voiceprint', voice.value, voice.name)"
|
@click="onPickerConfirm('voiceprint', voice.value, voice.name)"
|
||||||
>
|
>
|
||||||
<text :class="`flex-1 text-[28rpx] text-[#232338] ${(voice.voiceDemo || voice.voice_demo) ? '' : 'text-center'}`">
|
<text :class="`flex-1 text-[28rpx] text-[#232338] ${hasVoicePreview(voice) ? '' : 'text-center'}`">
|
||||||
{{ voice.name }}
|
{{ voice.name }}
|
||||||
</text>
|
</text>
|
||||||
<view v-if="voice.voiceDemo || voice.voice_demo" class="ml-[20rpx]" @click.stop="playAudio(voice.voiceDemo || voice.voice_demo, voice.value, $event)">
|
<view v-if="hasVoicePreview(voice)" class="ml-[20rpx]" @click.stop="playAudio(voice, $event)">
|
||||||
<wd-icon
|
<wd-icon
|
||||||
:name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'"
|
:name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'"
|
||||||
size="24px"
|
size="24px"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** @param {unknown} timestamp */
|
||||||
|
export function parseDeviceLastConnectedAtTimestamp(timestamp) {
|
||||||
|
if (typeof timestamp !== 'string' || !timestamp.trim()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const milliseconds = Number(timestamp)
|
||||||
|
if (!Number.isFinite(milliseconds)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(milliseconds)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
|
||||||
|
|
||||||
|
test('parses the backend Long timestamp serialized as a string', () => {
|
||||||
|
const timestamp = '1783689702000'
|
||||||
|
assert.equal(parseDeviceLastConnectedAtTimestamp(timestamp)?.getTime(), Number(timestamp))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects missing and malformed device timestamps', () => {
|
||||||
|
assert.equal(parseDeviceLastConnectedAtTimestamp(null), null)
|
||||||
|
assert.equal(parseDeviceLastConnectedAtTimestamp(''), null)
|
||||||
|
assert.equal(parseDeviceLastConnectedAtTimestamp('not-a-timestamp'), null)
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
|||||||
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
|
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { toast } from '@/utils/toast'
|
import { toast } from '@/utils/toast'
|
||||||
|
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'DeviceManage',
|
name: 'DeviceManage',
|
||||||
@@ -131,10 +132,10 @@ function getDeviceTypeName(boardKey: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
function formatTime(timeStr: string) {
|
function formatTime(timestamp: string | null) {
|
||||||
if (!timeStr)
|
const date = parseDeviceLastConnectedAtTimestamp(timestamp)
|
||||||
|
if (!date)
|
||||||
return t('device.neverConnected')
|
return t('device.neverConnected')
|
||||||
const date = new Date(timeStr)
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diff = now.getTime() - date.getTime()
|
const diff = now.getTime() - date.getTime()
|
||||||
|
|
||||||
@@ -410,7 +411,7 @@ defineExpose({
|
|||||||
{{ t('device.firmwareVersion') }}:{{ device.appVersion }}
|
{{ t('device.firmwareVersion') }}:{{ device.appVersion }}
|
||||||
</text>
|
</text>
|
||||||
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
|
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||||
{{ t('device.lastConnection') }}:{{ formatTime(device.lastConnectedAt) }}
|
{{ t('device.lastConnection') }}:{{ formatTime(device.lastConnectedAtTimestamp) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default {
|
|||||||
getFileList(params, callback) {
|
getFileList(params, callback) {
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
page: params.page,
|
page: params.page,
|
||||||
pageSize: params.pageSize
|
limit: params.pageSize
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
RequestService.sendRequest()
|
RequestService.sendRequest()
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
remark: params.remark,
|
remark: params.remark,
|
||||||
referenceAudio: params.referenceAudio,
|
referenceAudio: params.referenceAudio,
|
||||||
referenceText: params.referenceText,
|
referenceText: params.referenceText,
|
||||||
|
sort: params.sort,
|
||||||
ttsModelId: params.ttsModelId,
|
ttsModelId: params.ttsModelId,
|
||||||
ttsVoice: params.voiceCode,
|
ttsVoice: params.voiceCode,
|
||||||
voiceDemo: params.voiceDemo || ''
|
voiceDemo: params.voiceDemo || ''
|
||||||
|
|||||||
@@ -141,23 +141,24 @@
|
|||||||
<p class="section-desc">{{ $t('addressBookManagement.setPermissionDesc', { count: selectedPermissions.length }) }}</p>
|
<p class="section-desc">{{ $t('addressBookManagement.setPermissionDesc', { count: selectedPermissions.length }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-actions">
|
<div class="section-actions">
|
||||||
<CustomButton size="small" @click="handleCancel">{{ $t('common.cancel') }}</CustomButton>
|
<CustomButton size="small" :disabled="permissionsLoading" @click="handleCancel">{{ $t('common.cancel') }}</CustomButton>
|
||||||
<CustomButton size="small" @click="handleToggleSelectAll">{{ isAllSelected ? $t('addressBookManagement.deselectAll') : $t('addressBookManagement.selectAll') }}</CustomButton>
|
<CustomButton size="small" :disabled="permissionsLoading" @click="handleToggleSelectAll">{{ isAllSelected ? $t('addressBookManagement.deselectAll') : $t('addressBookManagement.selectAll') }}</CustomButton>
|
||||||
<CustomButton type="confirm" size="small" @click="handleSavePermissions">{{ $t('addressBookManagement.save') }}</CustomButton>
|
<CustomButton type="confirm" size="small" :disabled="permissionsLoading" @click="handleSavePermissions">{{ $t('addressBookManagement.save') }}</CustomButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="permission-grid">
|
<div v-loading="permissionsLoading" class="permission-grid">
|
||||||
<div
|
<div
|
||||||
v-for="device in allDevices"
|
v-for="device in allDevices"
|
||||||
:key="device.id"
|
:key="device.id"
|
||||||
class="permission-item"
|
class="permission-item"
|
||||||
:class="{ active: selectedPermissions.includes(device.id) }"
|
:class="{ active: selectedPermissions.includes(device.deviceId) }"
|
||||||
>
|
>
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
class="permission-checkbox"
|
class="permission-checkbox"
|
||||||
:value="selectedPermissions.includes(device.id)"
|
:disabled="permissionsLoading"
|
||||||
@change="(checked) => handlePermissionToggle(device.id, checked)"
|
:value="selectedPermissions.includes(device.deviceId)"
|
||||||
|
@change="(checked) => handlePermissionToggle(device.deviceId, checked)"
|
||||||
></el-checkbox>
|
></el-checkbox>
|
||||||
<div class="permission-avatar">
|
<div class="permission-avatar">
|
||||||
<img :src="getDeviceAvatar(device.id)" alt="avatar" />
|
<img :src="getDeviceAvatar(device.id)" alt="avatar" />
|
||||||
@@ -229,7 +230,9 @@ export default {
|
|||||||
editAgentNameValue: '',
|
editAgentNameValue: '',
|
||||||
editingDeviceId: null,
|
editingDeviceId: null,
|
||||||
editingDeviceName: '',
|
editingDeviceName: '',
|
||||||
mqttServiceAvailable: false
|
mqttServiceAvailable: false,
|
||||||
|
permissionRequestSequence: 0,
|
||||||
|
permissionsLoading: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
@@ -363,18 +366,45 @@ export default {
|
|||||||
this.loadAddressBookPermissions(device.deviceId);
|
this.loadAddressBookPermissions(device.deviceId);
|
||||||
},
|
},
|
||||||
loadAddressBookPermissions(macAddress) {
|
loadAddressBookPermissions(macAddress) {
|
||||||
|
const requestId = ++this.permissionRequestSequence;
|
||||||
|
this.permissionsLoading = true;
|
||||||
|
this.selectedPermissions = [];
|
||||||
|
this.originalPermissions = [];
|
||||||
|
this.editingDeviceId = null;
|
||||||
|
this.editingDeviceName = '';
|
||||||
|
this.allDevices.forEach(device => {
|
||||||
|
device.addressBookAlias = '';
|
||||||
|
});
|
||||||
AddressBookApi.getAddressBookList(macAddress, (res) => {
|
AddressBookApi.getAddressBookList(macAddress, (res) => {
|
||||||
|
if (
|
||||||
|
requestId !== this.permissionRequestSequence ||
|
||||||
|
this.selectedDevice?.deviceId !== macAddress
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.permissionsLoading = false;
|
||||||
if (res.data?.code === 0) {
|
if (res.data?.code === 0) {
|
||||||
const permissions = res.data.data || [];
|
const permissions = res.data.data || [];
|
||||||
|
const permissionsByTargetMac = new Map(
|
||||||
|
permissions.map(permission => [
|
||||||
|
(permission.targetMac || '').toLowerCase(),
|
||||||
|
permission
|
||||||
|
])
|
||||||
|
);
|
||||||
// 设置已选择的权限
|
// 设置已选择的权限
|
||||||
this.selectedPermissions = permissions
|
const permittedTargetMacs = new Set(
|
||||||
|
permissions
|
||||||
.filter(p => p.hasPermission)
|
.filter(p => p.hasPermission)
|
||||||
.map(p => p.targetMac);
|
.map(p => (p.targetMac || '').toLowerCase())
|
||||||
|
);
|
||||||
|
this.selectedPermissions = this.allDevices
|
||||||
|
.filter(device => permittedTargetMacs.has((device.deviceId || '').toLowerCase()))
|
||||||
|
.map(device => device.deviceId);
|
||||||
// 保存初始权限状态(用于对比变更)
|
// 保存初始权限状态(用于对比变更)
|
||||||
this.originalPermissions = [...this.selectedPermissions];
|
this.originalPermissions = [...this.selectedPermissions];
|
||||||
// 更新设备的通讯录别名
|
// 更新设备的通讯录别名
|
||||||
this.allDevices.forEach(device => {
|
this.allDevices.forEach(device => {
|
||||||
const addrBook = permissions.find(p => p.targetMac === device.deviceId);
|
const addrBook = permissionsByTargetMac.get((device.deviceId || '').toLowerCase());
|
||||||
if (addrBook) {
|
if (addrBook) {
|
||||||
device.addressBookAlias = addrBook.alias || '';
|
device.addressBookAlias = addrBook.alias || '';
|
||||||
} else {
|
} else {
|
||||||
@@ -385,6 +415,7 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
handleStartEditPermission(device) {
|
handleStartEditPermission(device) {
|
||||||
|
if (this.permissionsLoading) return;
|
||||||
this.editingDeviceId = device.id;
|
this.editingDeviceId = device.id;
|
||||||
this.editingDeviceName = device.addressBookAlias || device.name;
|
this.editingDeviceName = device.addressBookAlias || device.name;
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -412,13 +443,14 @@ export default {
|
|||||||
this.editingDeviceId = null;
|
this.editingDeviceId = null;
|
||||||
this.editingDeviceName = '';
|
this.editingDeviceName = '';
|
||||||
},
|
},
|
||||||
handlePermissionToggle(deviceId, checked) {
|
handlePermissionToggle(targetMac, checked) {
|
||||||
|
if (this.permissionsLoading) return;
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!this.selectedPermissions.includes(deviceId)) {
|
if (!this.selectedPermissions.includes(targetMac)) {
|
||||||
this.selectedPermissions.push(deviceId);
|
this.selectedPermissions.push(targetMac);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const index = this.selectedPermissions.indexOf(deviceId);
|
const index = this.selectedPermissions.indexOf(targetMac);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
this.selectedPermissions.splice(index, 1);
|
this.selectedPermissions.splice(index, 1);
|
||||||
}
|
}
|
||||||
@@ -428,21 +460,22 @@ export default {
|
|||||||
if (this.isAllSelected) {
|
if (this.isAllSelected) {
|
||||||
this.selectedPermissions = [];
|
this.selectedPermissions = [];
|
||||||
} else {
|
} else {
|
||||||
this.selectedPermissions = this.allDevices.map(d => d.id);
|
this.selectedPermissions = this.allDevices.map(d => d.deviceId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleCancel() {
|
handleCancel() {
|
||||||
this.selectedPermissions = [];
|
this.selectedPermissions = [];
|
||||||
},
|
},
|
||||||
handleSavePermissions() {
|
handleSavePermissions() {
|
||||||
|
if (this.permissionsLoading) return;
|
||||||
const promises = this.allDevices
|
const promises = this.allDevices
|
||||||
.filter(device => {
|
.filter(device => {
|
||||||
const isNowSelected = this.selectedPermissions.includes(device.id);
|
const isNowSelected = this.selectedPermissions.includes(device.deviceId);
|
||||||
const wasOriginallySelected = this.originalPermissions.includes(device.id);
|
const wasOriginallySelected = this.originalPermissions.includes(device.deviceId);
|
||||||
return isNowSelected !== wasOriginallySelected;
|
return isNowSelected !== wasOriginallySelected;
|
||||||
})
|
})
|
||||||
.map(device => {
|
.map(device => {
|
||||||
const hasPermission = this.selectedPermissions.includes(device.id);
|
const hasPermission = this.selectedPermissions.includes(device.deviceId);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AddressBookApi.updatePermission({
|
AddressBookApi.updatePermission({
|
||||||
macAddress: this.selectedDevice.deviceId,
|
macAddress: this.selectedDevice.deviceId,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const addressBookSource = await readFile(
|
||||||
|
new URL('../src/views/AddressBookManagement.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const correctWordApiSource = await readFile(
|
||||||
|
new URL('../src/apis/module/correctWord.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('address-book permission state consistently uses the target device MAC', () => {
|
||||||
|
assert.match(
|
||||||
|
addressBookSource,
|
||||||
|
/:value="selectedPermissions\.includes\(device\.deviceId\)"/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
addressBookSource,
|
||||||
|
/@change="\(checked\) => handlePermissionToggle\(device\.deviceId, checked\)"/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
addressBookSource,
|
||||||
|
/this\.selectedPermissions = this\.allDevices\.map\(d => d\.deviceId\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
addressBookSource,
|
||||||
|
/this\.originalPermissions\.includes\(device\.deviceId\)/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
addressBookSource,
|
||||||
|
/selectedPermissions\.includes\(device\.id\)/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
addressBookSource,
|
||||||
|
/originalPermissions\.includes\(device\.id\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
addressBookSource,
|
||||||
|
/requestId !== this\.permissionRequestSequence/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
addressBookSource,
|
||||||
|
/this\.selectedDevice\?\.deviceId !== macAddress/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
addressBookSource,
|
||||||
|
/this\.permissionsLoading = true;\s*this\.selectedPermissions = \[\];\s*this\.originalPermissions = \[\];/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
addressBookSource,
|
||||||
|
/handleSavePermissions\(\) \{\s*if \(this\.permissionsLoading\) return;/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('correct-word pagination maps the UI page size to the backend limit query', () => {
|
||||||
|
assert.match(
|
||||||
|
correctWordApiSource,
|
||||||
|
/new URLSearchParams\(\{\s*page: params\.page,\s*limit: params\.pageSize\s*\}\)/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(correctWordApiSource, /pageSize: params\.pageSize/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const timbreApiSource = await readFile(
|
||||||
|
new URL('../src/apis/module/timbre.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('timbre update sends the current sort value to the backend', () => {
|
||||||
|
const updateStart = timbreApiSource.indexOf('updateVoice(params, callback)');
|
||||||
|
|
||||||
|
assert.notEqual(updateStart, -1);
|
||||||
|
const updateSource = timbreApiSource.slice(updateStart);
|
||||||
|
assert.match(updateSource, /\.method\('PUT'\)/);
|
||||||
|
assert.match(updateSource, /sort:\s*params\.sort/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user