From ffba2f4fa393116eaefd11753a925aa1916ce479 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Tue, 23 Sep 2025 09:58:27 +0800 Subject: [PATCH 01/22] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0SM2=E5=9B=BD?= =?UTF-8?q?=E5=AF=86=E5=8A=A0=E5=AF=86=E7=9A=84=E7=99=BB=E5=BD=95=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-api/pom.xml | 6 + .../xiaozhi/common/constant/Constant.java | 10 + .../xiaozhi/common/exception/ErrorCode.java | 4 + .../java/xiaozhi/common/utils/SM2Utils.java | 191 ++++++++++++++++++ .../security/controller/LoginController.java | 48 +++++ .../service/impl/SysParamsServiceImpl.java | 26 +++ .../resources/db/changelog/202509221530.sql | 7 + .../db/changelog/db.changelog-master.yaml | 7 + main/manager-web/package.json | 1 + 9 files changed, 300 insertions(+) create mode 100644 main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java create mode 100644 main/manager-api/src/main/resources/db/changelog/202509221530.sql diff --git a/main/manager-api/pom.xml b/main/manager-api/pom.xml index 874a743a..b3798571 100644 --- a/main/manager-api/pom.xml +++ b/main/manager-api/pom.xml @@ -199,6 +199,12 @@ knife4j-openapi3-jakarta-spring-boot-starter ${knife4j.version} + + + org.bouncycastle + bcprov-jdk18on + 1.78 + org.springdoc diff --git a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java index 55f8fcdb..b80ad6bb 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java +++ b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java @@ -86,6 +86,16 @@ public interface Constant { */ String SERVER_SECRET = "server.secret"; + /** + * SM2公钥 + */ + String SM2_PUBLIC_KEY = "server.public_key"; + + /** + * SM2私钥 + */ + String SM2_PRIVATE_KEY = "server.private_key"; + /** * websocket地址 */ diff --git a/main/manager-api/src/main/java/xiaozhi/common/exception/ErrorCode.java b/main/manager-api/src/main/java/xiaozhi/common/exception/ErrorCode.java index e84fbba8..5761ab7c 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/exception/ErrorCode.java +++ b/main/manager-api/src/main/java/xiaozhi/common/exception/ErrorCode.java @@ -164,4 +164,8 @@ public interface ErrorCode { // 字典相关错误码 int DICT_LABEL_DUPLICATE = 10128; // 字典标签重复 + + // SM2加密相关错误码 + int SM2_KEY_NOT_CONFIGURED = 10129; // SM2密钥未配置 + int SM2_DECRYPT_ERROR = 10130; // SM2解密失败 } diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java b/main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java new file mode 100644 index 00000000..25670ce3 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java @@ -0,0 +1,191 @@ +package xiaozhi.common.utils; + +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.engines.SM2Engine; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECPrivateKeyParameters; +import org.bouncycastle.crypto.params.ECPublicKeyParameters; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey; +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; +import org.bouncycastle.jcajce.provider.asymmetric.util.ECUtil; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.util.encoders.Base64; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; + +/** + * SM2加密工具类 + */ +public class SM2Utils { + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + /** + * 生成SM2密钥对 + * + * @return 密钥对 + */ + public static KeyPair generateKeyPair() { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", "BC"); + ECGenParameterSpec sm2Spec = new ECGenParameterSpec("sm2p256v1"); + keyPairGenerator.initialize(sm2Spec, new SecureRandom()); + return keyPairGenerator.generateKeyPair(); + } catch (Exception e) { + throw new RuntimeException("生成SM2密钥对失败", e); + } + } + + /** + * 获取公钥字符串 + * + * @param publicKey 公钥 + * @return Base64编码的公钥字符串 + */ + public static String getPublicKeyStr(PublicKey publicKey) { + BCECPublicKey bcPublicKey = (BCECPublicKey) publicKey; + return Base64.toBase64String(bcPublicKey.getEncoded()); + } + + /** + * 获取私钥字符串 + * + * @param privateKey 私钥 + * @return Base64编码的私钥字符串 + */ + public static String getPrivateKeyStr(PrivateKey privateKey) { + BCECPrivateKey bcPrivateKey = (BCECPrivateKey) privateKey; + return Base64.toBase64String(bcPrivateKey.getEncoded()); + } + + /** + * 从字符串加载公钥 + * + * @param publicKeyStr Base64编码的公钥字符串 + * @return 公钥对象 + */ + public static PublicKey loadPublicKey(String publicKeyStr) { + try { + byte[] keyBytes = Base64.decode(publicKeyStr); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("EC", "BC"); + return keyFactory.generatePublic(keySpec); + } catch (Exception e) { + throw new RuntimeException("加载公钥失败", e); + } + } + + /** + * 从字符串加载私钥 + * + * @param privateKeyStr Base64编码的私钥字符串 + * @return 私钥对象 + */ + public static PrivateKey loadPrivateKey(String privateKeyStr) { + try { + byte[] keyBytes = Base64.decode(privateKeyStr); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("EC", "BC"); + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new RuntimeException("加载私钥失败", e); + } + } + + /** + * SM2加密 + * + * @param publicKey 公钥 + * @param plainText 明文 + * @return Base64编码的密文 + */ + public static String encrypt(PublicKey publicKey, String plainText) { + try { + BCECPublicKey bcPublicKey = (BCECPublicKey) publicKey; + ECPoint ecPoint = bcPublicKey.getQ(); + X9ECParameters x9ECParameters = ECUtil.getNamedCurveByName("sm2p256v1"); + ECDomainParameters ecDomainParameters = new ECDomainParameters( + x9ECParameters.getCurve(), x9ECParameters.getG(), x9ECParameters.getN()); + ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(ecPoint, ecDomainParameters); + + SM2Engine sm2Engine = new SM2Engine(); + sm2Engine.init(true, new ParametersWithRandom(publicKeyParameters, new SecureRandom())); + + byte[] input = plainText.getBytes(StandardCharsets.UTF_8); + byte[] encrypted = sm2Engine.processBlock(input, 0, input.length); + return Base64.toBase64String(encrypted); + } catch (Exception e) { + throw new RuntimeException("SM2加密失败", e); + } + } + + /** + * SM2解密 + * + * @param privateKey 私钥 + * @param cipherText Base64编码的密文 + * @return 明文 + */ + public static String decrypt(PrivateKey privateKey, String cipherText) { + try { + BCECPrivateKey bcPrivateKey = (BCECPrivateKey) privateKey; + BigInteger privateKeyValue = bcPrivateKey.getD(); + X9ECParameters x9ECParameters = ECUtil.getNamedCurveByName("sm2p256v1"); + ECDomainParameters ecDomainParameters = new ECDomainParameters( + x9ECParameters.getCurve(), x9ECParameters.getG(), x9ECParameters.getN()); + ECPrivateKeyParameters privateKeyParameters = new ECPrivateKeyParameters(privateKeyValue, ecDomainParameters); + + SM2Engine sm2Engine = new SM2Engine(); + sm2Engine.init(false, privateKeyParameters); + + byte[] encrypted = Base64.decode(cipherText); + byte[] decrypted = sm2Engine.processBlock(encrypted, 0, encrypted.length); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("SM2解密失败", e); + } + } + + /** + * 验证SM2密钥对是否匹配 + * + * @param publicKeyStr 公钥字符串 + * @param privateKeyStr 私钥字符串 + * @return 是否匹配 + */ + public static boolean verifyKeyPair(String publicKeyStr, String privateKeyStr) { + try { + String testData = "test_sm2_encryption"; + PublicKey publicKey = loadPublicKey(publicKeyStr); + PrivateKey privateKey = loadPrivateKey(privateKeyStr); + + String encrypted = encrypt(publicKey, testData); + String decrypted = decrypt(privateKey, encrypted); + + return testData.equals(decrypted); + } catch (Exception e) { + return false; + } + } + + /** + * 生成SM2密钥对并返回Base64编码的字符串 + * + * @return 包含公钥和私钥的字符串数组 [公钥, 私钥] + */ + public static String[] generateKeyPairStrings() { + KeyPair keyPair = generateKeyPair(); + String publicKeyStr = getPublicKeyStr(keyPair.getPublic()); + String privateKeyStr = getPrivateKeyStr(keyPair.getPrivate()); + return new String[]{publicKeyStr, privateKeyStr}; + } +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index d7333273..0fe7eafe 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -31,6 +31,8 @@ import xiaozhi.modules.security.password.PasswordUtils; import xiaozhi.modules.security.service.CaptchaService; import xiaozhi.modules.security.service.SysUserTokenService; import xiaozhi.modules.security.user.SecurityUser; +import xiaozhi.common.utils.SM2Utils; +import org.apache.commons.lang3.StringUtils; import xiaozhi.modules.sys.dto.PasswordDTO; import xiaozhi.modules.sys.dto.RetrievePasswordDTO; import xiaozhi.modules.sys.dto.SysUserDTO; @@ -88,6 +90,26 @@ public class LoginController { if (!validate) { throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR); } + + String password = login.getPassword(); + + // 如果密码是SM2加密格式(Base64编码),则进行解密 + if (isSM2Encrypted(password)) { + try { + // 获取SM2私钥 + String privateKeyStr = sysParamsService.getValue(Constant.SM2_PRIVATE_KEY, true); + if (StringUtils.isBlank(privateKeyStr)) { + throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED); + } + + // 使用SM2私钥解密密码 + String decryptedPassword = SM2Utils.decrypt(SM2Utils.loadPrivateKey(privateKeyStr), password); + login.setPassword(decryptedPassword); + } catch (Exception e) { + throw new RenException(ErrorCode.SM2_DECRYPT_ERROR); + } + } + // 按照用户名获取用户 SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); // 判断用户是否存在 @@ -100,6 +122,21 @@ public class LoginController { } return sysUserTokenService.createToken(userDTO.getId()); } + + /** + * 判断字符串是否为SM2加密格式 + * @param str 待判断的字符串 + * @return 是否为SM2加密格式 + */ + private boolean isSM2Encrypted(String str) { + if (StringUtils.isBlank(str)) { + return false; + } + if (str.length() > 100 && str.matches("^[A-Za-z0-9+/=]+$")) { + return true; + } + return false; + } @PostMapping("/register") @Operation(summary = "注册") @@ -211,4 +248,15 @@ public class LoginController { return new Result>().ok(config); } + + @GetMapping("/sm2-public-key") + @Operation(summary = "获取SM2公钥") + public Result getSM2PublicKey() { + // 获取SM2公钥 + String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true); + if (StringUtils.isBlank(publicKey)) { + throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED); + } + return new Result().ok(publicKey); + } } \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java index 02f595d2..31daf08f 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java @@ -21,6 +21,7 @@ import xiaozhi.common.page.PageData; import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.JsonUtils; +import xiaozhi.common.utils.SM2Utils; import xiaozhi.modules.sys.dao.SysParamsDao; import xiaozhi.modules.sys.dto.SysParamsDTO; import xiaozhi.modules.sys.entity.SysParamsEntity; @@ -206,6 +207,31 @@ public class SysParamsServiceImpl extends BaseServiceImpl Date: Tue, 23 Sep 2025 20:22:12 +0800 Subject: [PATCH 02/22] =?UTF-8?q?update=EF=BC=9A=E5=89=8D=E7=AB=AFSM2?= =?UTF-8?q?=E9=9D=9E=E5=AF=B9=E7=A7=B0=E5=8A=A0=E5=AF=86=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xiaozhi/common/utils/SM2Utils.java | 205 ++++++------------ .../modules/security/config/ShiroConfig.java | 1 + .../security/controller/LoginController.java | 27 ++- .../service/impl/SysParamsServiceImpl.java | 6 +- main/manager-web/package.json | 1 + main/manager-web/src/apis/module/user.js | 19 ++ main/manager-web/src/i18n/en.js | 10 +- main/manager-web/src/i18n/zh_CN.js | 11 +- main/manager-web/src/i18n/zh_TW.js | 12 +- main/manager-web/src/utils/index.js | 72 ++++++ main/manager-web/src/views/login.vue | 120 +++++++++- 11 files changed, 340 insertions(+), 144 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java b/main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java index 25670ce3..cf0ed9c5 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java +++ b/main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java @@ -1,5 +1,6 @@ package xiaozhi.common.utils; +import org.bouncycastle.asn1.gm.GMNamedCurves; import org.bouncycastle.asn1.x9.X9ECParameters; import org.bouncycastle.crypto.engines.SM2Engine; import org.bouncycastle.crypto.params.ECDomainParameters; @@ -8,184 +9,122 @@ import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.crypto.params.ParametersWithRandom; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; -import org.bouncycastle.jcajce.provider.asymmetric.util.ECUtil; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.math.ec.ECPoint; -import org.bouncycastle.util.encoders.Base64; +import org.bouncycastle.util.encoders.Hex; import java.math.BigInteger; -import java.nio.charset.StandardCharsets; import java.security.*; import java.security.spec.ECGenParameterSpec; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; /** - * SM2加密工具类 + * SM2加密工具类(采用十六进制格式,与chancheng-archive-service项目保持一致) */ public class SM2Utils { + /** + * 公钥常量 + */ + public static final String KEY_PUBLIC_KEY = "publicKey"; + /** + * 私钥返回值常量 + */ + public static final String KEY_PRIVATE_KEY = "privateKey"; + static { Security.addProvider(new BouncyCastleProvider()); } /** - * 生成SM2密钥对 + * SM2加密算法 * - * @return 密钥对 + * @param publicKey 十六进制公钥 + * @param data 明文数据 + * @return 十六进制密文 */ - public static KeyPair generateKeyPair() { + public static String encrypt(String publicKey, String data) { try { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", "BC"); - ECGenParameterSpec sm2Spec = new ECGenParameterSpec("sm2p256v1"); - keyPairGenerator.initialize(sm2Spec, new SecureRandom()); - return keyPairGenerator.generateKeyPair(); - } catch (Exception e) { - throw new RuntimeException("生成SM2密钥对失败", e); - } - } + // 获取一条SM2曲线参数 + X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1"); + // 构造ECC算法参数,曲线方程、椭圆曲线G点、大整数N + ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN()); + //提取公钥点 + ECPoint pukPoint = sm2ECParameters.getCurve().decodePoint(Hex.decode(publicKey)); + // 公钥前面的02或者03表示是压缩公钥,04表示未压缩公钥, 04的时候,可以去掉前面的04 + ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(pukPoint, domainParameters); - /** - * 获取公钥字符串 - * - * @param publicKey 公钥 - * @return Base64编码的公钥字符串 - */ - public static String getPublicKeyStr(PublicKey publicKey) { - BCECPublicKey bcPublicKey = (BCECPublicKey) publicKey; - return Base64.toBase64String(bcPublicKey.getEncoded()); - } - - /** - * 获取私钥字符串 - * - * @param privateKey 私钥 - * @return Base64编码的私钥字符串 - */ - public static String getPrivateKeyStr(PrivateKey privateKey) { - BCECPrivateKey bcPrivateKey = (BCECPrivateKey) privateKey; - return Base64.toBase64String(bcPrivateKey.getEncoded()); - } - - /** - * 从字符串加载公钥 - * - * @param publicKeyStr Base64编码的公钥字符串 - * @return 公钥对象 - */ - public static PublicKey loadPublicKey(String publicKeyStr) { - try { - byte[] keyBytes = Base64.decode(publicKeyStr); - X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); - KeyFactory keyFactory = KeyFactory.getInstance("EC", "BC"); - return keyFactory.generatePublic(keySpec); - } catch (Exception e) { - throw new RuntimeException("加载公钥失败", e); - } - } - - /** - * 从字符串加载私钥 - * - * @param privateKeyStr Base64编码的私钥字符串 - * @return 私钥对象 - */ - public static PrivateKey loadPrivateKey(String privateKeyStr) { - try { - byte[] keyBytes = Base64.decode(privateKeyStr); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); - KeyFactory keyFactory = KeyFactory.getInstance("EC", "BC"); - return keyFactory.generatePrivate(keySpec); - } catch (Exception e) { - throw new RuntimeException("加载私钥失败", e); - } - } - - /** - * SM2加密 - * - * @param publicKey 公钥 - * @param plainText 明文 - * @return Base64编码的密文 - */ - public static String encrypt(PublicKey publicKey, String plainText) { - try { - BCECPublicKey bcPublicKey = (BCECPublicKey) publicKey; - ECPoint ecPoint = bcPublicKey.getQ(); - X9ECParameters x9ECParameters = ECUtil.getNamedCurveByName("sm2p256v1"); - ECDomainParameters ecDomainParameters = new ECDomainParameters( - x9ECParameters.getCurve(), x9ECParameters.getG(), x9ECParameters.getN()); - ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(ecPoint, ecDomainParameters); - - SM2Engine sm2Engine = new SM2Engine(); + SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2); + // 设置sm2为加密模式 sm2Engine.init(true, new ParametersWithRandom(publicKeyParameters, new SecureRandom())); - byte[] input = plainText.getBytes(StandardCharsets.UTF_8); - byte[] encrypted = sm2Engine.processBlock(input, 0, input.length); - return Base64.toBase64String(encrypted); + byte[] in = data.getBytes(StandardCharsets.UTF_8); + byte[] arrayOfBytes = sm2Engine.processBlock(in, 0, in.length); + return Hex.toHexString(arrayOfBytes); } catch (Exception e) { throw new RuntimeException("SM2加密失败", e); } } /** - * SM2解密 + * SM2解密算法 * - * @param privateKey 私钥 - * @param cipherText Base64编码的密文 + * @param privateKey 十六进制私钥 + * @param cipherData 十六进制密文数据 * @return 明文 */ - public static String decrypt(PrivateKey privateKey, String cipherText) { + public static String decrypt(String privateKey, String cipherData) { try { - BCECPrivateKey bcPrivateKey = (BCECPrivateKey) privateKey; - BigInteger privateKeyValue = bcPrivateKey.getD(); - X9ECParameters x9ECParameters = ECUtil.getNamedCurveByName("sm2p256v1"); - ECDomainParameters ecDomainParameters = new ECDomainParameters( - x9ECParameters.getCurve(), x9ECParameters.getG(), x9ECParameters.getN()); - ECPrivateKeyParameters privateKeyParameters = new ECPrivateKeyParameters(privateKeyValue, ecDomainParameters); + // 使用BC库加解密时密文以04开头,传入的密文前面没有04则补上 + if (!cipherData.startsWith("04")) { + cipherData = "04" + cipherData; + } + byte[] cipherDataByte = Hex.decode(cipherData); + BigInteger privateKeyD = new BigInteger(privateKey, 16); + //获取一条SM2曲线参数 + X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1"); + //构造domain参数 + ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN()); + ECPrivateKeyParameters privateKeyParameters = new ECPrivateKeyParameters(privateKeyD, domainParameters); - SM2Engine sm2Engine = new SM2Engine(); + SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2); + // 设置sm2为解密模式 sm2Engine.init(false, privateKeyParameters); - byte[] encrypted = Base64.decode(cipherText); - byte[] decrypted = sm2Engine.processBlock(encrypted, 0, encrypted.length); - return new String(decrypted, StandardCharsets.UTF_8); + byte[] arrayOfBytes = sm2Engine.processBlock(cipherDataByte, 0, cipherDataByte.length); + return new String(arrayOfBytes, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("SM2解密失败", e); } } /** - * 验证SM2密钥对是否匹配 - * - * @param publicKeyStr 公钥字符串 - * @param privateKeyStr 私钥字符串 - * @return 是否匹配 + * 生成密钥对 */ - public static boolean verifyKeyPair(String publicKeyStr, String privateKeyStr) { + public static Map createKey() { try { - String testData = "test_sm2_encryption"; - PublicKey publicKey = loadPublicKey(publicKeyStr); - PrivateKey privateKey = loadPrivateKey(privateKeyStr); - - String encrypted = encrypt(publicKey, testData); - String decrypted = decrypt(privateKey, encrypted); - - return testData.equals(decrypted); + ECGenParameterSpec sm2Spec = new ECGenParameterSpec("sm2p256v1"); + // 获取一个椭圆曲线类型的密钥对生成器 + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", new BouncyCastleProvider()); + // 使用SM2参数初始化生成器 + kpg.initialize(sm2Spec); + // 获取密钥对 + KeyPair keyPair = kpg.generateKeyPair(); + PublicKey publicKey = keyPair.getPublic(); + BCECPublicKey p = (BCECPublicKey) publicKey; + PrivateKey privateKey = keyPair.getPrivate(); + BCECPrivateKey s = (BCECPrivateKey) privateKey; + + Map result = new HashMap<>(); + result.put(KEY_PUBLIC_KEY, Hex.toHexString(p.getQ().getEncoded(false))); + result.put(KEY_PRIVATE_KEY, Hex.toHexString(s.getD().toByteArray())); + return result; } catch (Exception e) { - return false; + throw new RuntimeException("生成SM2密钥对失败", e); } } - /** - * 生成SM2密钥对并返回Base64编码的字符串 - * - * @return 包含公钥和私钥的字符串数组 [公钥, 私钥] - */ - public static String[] generateKeyPairStrings() { - KeyPair keyPair = generateKeyPair(); - String publicKeyStr = getPublicKeyStr(keyPair.getPublic()); - String privateKeyStr = getPrivateKeyStr(keyPair.getPrivate()); - return new String[]{publicKeyStr, privateKeyStr}; - } + } \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java index c015240f..8fb1d775 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java @@ -82,6 +82,7 @@ public class ShiroConfig { filterMap.put("/user/captcha", "anon"); filterMap.put("/user/smsVerification", "anon"); filterMap.put("/user/login", "anon"); + filterMap.put("/user/sm2-public-key", "anon"); filterMap.put("/user/pub-config", "anon"); filterMap.put("/user/register", "anon"); filterMap.put("/user/retrieve-password", "anon"); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index 0fe7eafe..1a9fdd6c 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -17,6 +17,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 lombok.extern.slf4j.Slf4j; import xiaozhi.common.constant.Constant; import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.RenException; @@ -44,6 +45,7 @@ import xiaozhi.modules.sys.vo.SysDictDataItem; /** * 登录控制层 */ +@Slf4j @AllArgsConstructor @RestController @RequestMapping("/user") @@ -91,9 +93,27 @@ public class LoginController { throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR); } + String username = login.getUsername(); String password = login.getPassword(); - // 如果密码是SM2加密格式(Base64编码),则进行解密 + // 如果用户名是SM2加密格式,则进行解密 + if (isSM2Encrypted(username)) { + try { + // 获取SM2私钥 + String privateKeyStr = sysParamsService.getValue(Constant.SM2_PRIVATE_KEY, true); + if (StringUtils.isBlank(privateKeyStr)) { + throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED); + } + + // 使用SM2私钥解密用户名(十六进制格式) + String decryptedUsername = SM2Utils.decrypt(privateKeyStr, username); + login.setUsername(decryptedUsername); + } catch (Exception e) { + throw new RenException(ErrorCode.SM2_DECRYPT_ERROR); + } + } + + // 如果密码是SM2加密格式,则进行解密 if (isSM2Encrypted(password)) { try { // 获取SM2私钥 @@ -103,7 +123,7 @@ public class LoginController { } // 使用SM2私钥解密密码 - String decryptedPassword = SM2Utils.decrypt(SM2Utils.loadPrivateKey(privateKeyStr), password); + String decryptedPassword = SM2Utils.decrypt(privateKeyStr, password); login.setPassword(decryptedPassword); } catch (Exception e) { throw new RenException(ErrorCode.SM2_DECRYPT_ERROR); @@ -132,7 +152,8 @@ public class LoginController { if (StringUtils.isBlank(str)) { return false; } - if (str.length() > 100 && str.matches("^[A-Za-z0-9+/=]+$")) { + // 十六进制格式检测(长度较长且只包含0-9,a-f,A-F字符) + if (str.length() > 100 && str.matches("^[0-9a-fA-F]+$")) { return true; } return false; diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java index 31daf08f..b22dd2b9 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java @@ -224,9 +224,9 @@ public class SysParamsServiceImpl extends BaseServiceImpl keyPair = SM2Utils.createKey(); + String newPublicKey = keyPair.get(SM2Utils.KEY_PUBLIC_KEY); + String newPrivateKey = keyPair.get(SM2Utils.KEY_PRIVATE_KEY); // 更新数据库中的密钥对 updateValueByCode(Constant.SM2_PUBLIC_KEY, newPublicKey); diff --git a/main/manager-web/package.json b/main/manager-web/package.json index e9f0d65c..1ffd1c33 100644 --- a/main/manager-web/package.json +++ b/main/manager-web/package.json @@ -13,6 +13,7 @@ "dotenv": "^16.5.0", "element-ui": "^2.15.14", "flyio": "^0.6.14", + "jsbn": "^1.1.0", "normalize.css": "^8.0.1", "opus-decoder": "^0.7.7", "opus-recorder": "^8.0.5", diff --git a/main/manager-web/src/apis/module/user.js b/main/manager-web/src/apis/module/user.js index 6c16c3e6..78477cd1 100755 --- a/main/manager-web/src/apis/module/user.js +++ b/main/manager-web/src/apis/module/user.js @@ -192,5 +192,24 @@ export default { this.retrievePassword(passwordData, callback, failCallback); }); }).send() + }, + // 获取SM2公钥 + getSM2PublicKey(callback, failCallback) { + RequestService.sendRequest() + .url(`${getServiceUrl()}/user/sm2-public-key`) + .method('GET') + .success((res) => { + RequestService.clearRequestTime(); + callback(res); + }) + .fail((err) => { + RequestService.clearRequestTime(); + failCallback(err); + }) + .networkFail(() => { + RequestService.reAjaxFun(() => { + this.getSM2PublicKey(callback, failCallback); + }); + }).send() } } diff --git a/main/manager-web/src/i18n/en.js b/main/manager-web/src/i18n/en.js index 14f4d5a6..1e981a53 100644 --- a/main/manager-web/src/i18n/en.js +++ b/main/manager-web/src/i18n/en.js @@ -1045,5 +1045,13 @@ export default { 'templateQuickConfig.templateNotFound': 'Template not found', 'warning': 'Warning', 'info': 'Info', - 'common.networkError': 'Network request failed' + 'common.networkError': 'Network request failed', + 'sm2.publicKeyNotConfigured': 'SM2 public key not configured, please contact administrator', + 'sm2.failedToGetPublicKey': 'Failed to get SM2 public key', + 'sm2.encryptionFailed': 'Password encryption failed', + 'sm2.keyGenerationFailed': 'Key pair generation failed', + 'sm2.invalidPublicKey': 'Invalid public key format', + 'sm2.encryptionError': 'Error occurred during encryption', + 'sm2.publicKeyRetry': 'Retrying to get public key...', + 'sm2.publicKeyRetryFailed': 'Public key retrieval retry failed' } \ No newline at end of file diff --git a/main/manager-web/src/i18n/zh_CN.js b/main/manager-web/src/i18n/zh_CN.js index 27b0b9e0..3b9e6c24 100644 --- a/main/manager-web/src/i18n/zh_CN.js +++ b/main/manager-web/src/i18n/zh_CN.js @@ -1044,5 +1044,14 @@ export default { 'templateQuickConfig.resetSuccess': '重置成功', 'warning': '警告', 'info': '提示', - 'common.networkError': '网络请求失败' + 'common.networkError': '网络请求失败', + // SM2加密相关错误消息 + 'sm2.publicKeyNotConfigured': 'SM2公钥未配置,请联系管理员', + 'sm2.failedToGetPublicKey': '获取SM2公钥失败', + 'sm2.encryptionFailed': '密码加密失败', + 'sm2.keyGenerationFailed': '密钥对生成失败', + 'sm2.invalidPublicKey': '无效的公钥格式', + 'sm2.encryptionError': '加密过程中发生错误', + 'sm2.publicKeyRetry': '正在重试获取公钥...', + 'sm2.publicKeyRetryFailed': '公钥获取重试失败' } \ No newline at end of file diff --git a/main/manager-web/src/i18n/zh_TW.js b/main/manager-web/src/i18n/zh_TW.js index a9f224f1..39916bec 100644 --- a/main/manager-web/src/i18n/zh_TW.js +++ b/main/manager-web/src/i18n/zh_TW.js @@ -1045,5 +1045,15 @@ export default { 'error': '錯誤', 'warning': '警告', 'info': '提示', - 'common.networkError': '網路請求失敗' + 'common.networkError': '網路請求失敗', + + // SM2加密相關錯誤消息 + 'sm2.publicKeyNotConfigured': 'SM2公鑰未配置,請聯繫管理員', + 'sm2.failedToGetPublicKey': '獲取SM2公鑰失敗', + 'sm2.encryptionFailed': '密碼加密失敗', + 'sm2.keyGenerationFailed': '金鑰對生成失敗', + 'sm2.invalidPublicKey': '無效的公鑰格式', + 'sm2.encryptionError': '加密過程中發生錯誤', + 'sm2.publicKeyRetry': '正在重試獲取公鑰...', + 'sm2.publicKeyRetryFailed': '公鑰獲取重試失敗' } \ No newline at end of file diff --git a/main/manager-web/src/utils/index.js b/main/manager-web/src/utils/index.js index aef8de0a..1954df09 100755 --- a/main/manager-web/src/utils/index.js +++ b/main/manager-web/src/utils/index.js @@ -200,3 +200,75 @@ export function validateMobile(mobile, areaCode) { } } + +/** + * 生成SM2密钥对(十六进制格式) + * @returns {Object} 包含公钥和私钥的对象 + */ +export function generateSm2KeyPairHex() { + // 使用sm-crypto库生成SM2密钥对 + const sm2 = require('sm-crypto').sm2; + const keypair = sm2.generateKeyPairHex(); + + return { + publicKey: keypair.publicKey, + privateKey: keypair.privateKey, + clientPublicKey: keypair.publicKey, // 客户端公钥 + clientPrivateKey: keypair.privateKey // 客户端私钥 + }; +} + +/** + * SM2公钥加密 + * @param {string} publicKey 公钥(十六进制格式) + * @param {string} plainText 明文 + * @returns {string} 加密后的密文(十六进制格式) + */ +export function sm2Encrypt(publicKey, plainText) { + if (!publicKey) { + throw new Error('公钥不能为null或undefined'); + } + + if (!plainText) { + throw new Error('明文不能为空'); + } + + const sm2 = require('sm-crypto').sm2; + // SM2加密,添加04前缀表示未压缩公钥 + const encrypted = sm2.doEncrypt(plainText, publicKey, 1); + // 转换为十六进制格式(与后端保持一致,添加04前缀) + const result = "04" + encrypted; + + return result; +} + +/** + * SM2私钥解密 + * @param {string} privateKey 私钥(十六进制格式) + * @param {string} cipherText 密文(十六进制格式) + * @returns {string} 解密后的明文 + */ +export function sm2Decrypt(privateKey, cipherText) { + const sm2 = require('sm-crypto').sm2; + // 移除04前缀(与后端保持一致) + const dataWithoutPrefix = cipherText.startsWith("04") ? cipherText.substring(2) : cipherText; + // SM2解密 + return sm2.doDecrypt(dataWithoutPrefix, privateKey, 1); +} + +/** + * 判断字符串是否为Base64编码 + * @param {string} str 待判断的字符串 + * @returns {boolean} 是否为Base64编码 + */ +export function isBase64(str) { + if (typeof str !== 'string' || str.trim() === '') { + return false; + } + try { + return btoa(atob(str)) === str; + } catch (e) { + return false; + } +} + diff --git a/main/manager-web/src/views/login.vue b/main/manager-web/src/views/login.vue index 1958c915..eeb6a89b 100644 --- a/main/manager-web/src/views/login.vue +++ b/main/manager-web/src/views/login.vue @@ -148,8 +148,9 @@ import Api from "@/apis/api"; import VersionFooter from "@/components/VersionFooter.vue"; import i18n, { changeLanguage } from "@/i18n"; -import { getUUID, goToPage, showDanger, showSuccess, validateMobile } from "@/utils"; +import { getUUID, goToPage, showDanger, showSuccess, validateMobile, generateSm2KeyPairHex, sm2Encrypt, isBase64 } from "@/utils"; import { mapState } from "vuex"; +import Constant from "@/utils/constant"; export default { name: "login", @@ -196,6 +197,8 @@ export default { captchaUrl: "", isMobileLogin: false, languageDropdownVisible: false, + serverPublicKey: "", // 服务器公钥 + clientKeyPair: null, // 客户端密钥对 }; }, mounted() { @@ -204,8 +207,49 @@ export default { // 根据配置决定默认登录方式 this.isMobileLogin = this.enableMobileRegister; }); + // 获取服务器公钥 + this.getServerPublicKey(); + // 生成客户端密钥对 + this.generateClientKeyPair(); }, methods: { + // 获取服务器公钥 + getServerPublicKey() { + console.log('开始获取服务器公钥...'); + // 先从本地存储获取 + const storedPublicKey = localStorage.getItem(Constant.STORAGE_KEY.PUBLIC_KEY); + if (storedPublicKey) { + console.log('从本地存储获取到公钥,长度:', storedPublicKey.length); + this.serverPublicKey = storedPublicKey; + return; + } + + console.log('本地存储无公钥,从服务器获取...'); + // 从服务器获取公钥 + Api.user.getSM2PublicKey( + (res) => { + if (res.data && res.data.data) { + console.log('获取到服务器公钥,长度:', res.data.data.length); + this.serverPublicKey = res.data.data; + // 存储到本地 + localStorage.setItem(Constant.STORAGE_KEY.PUBLIC_KEY, this.serverPublicKey); + console.log('公钥已存储到本地'); + } else { + console.error('服务器返回数据格式异常:', res); + } + }, + (err) => { + console.error("获取SM2公钥失败:", err); + showDanger(this.$t('sm2.failedToGetPublicKey')); + } + ); + }, + + // 生成客户端密钥对 + generateClientKeyPair() { + this.clientKeyPair = generateSm2KeyPairHex(); + }, + fetchCaptcha() { if (this.$store.getters.getToken) { if (this.$route.path !== "/home") { @@ -285,9 +329,68 @@ export default { return; } + // 检查服务器公钥是否已获取,如果未获取则重新获取 + if (!this.serverPublicKey) { + try { + // 等待公钥获取完成 + await new Promise((resolve, reject) => { + this.getServerPublicKey(); + // 设置超时检查,最多等待3秒 + const checkInterval = setInterval(() => { + if (this.serverPublicKey) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + + setTimeout(() => { + clearInterval(checkInterval); + if (!this.serverPublicKey) { + reject(new Error('获取公钥超时')); + } + }, 3000); + }); + } catch (error) { + showDanger(this.$t('sm2.failedToGetPublicKey')); + return; + } + } + + // 加密密码 + let encryptedPassword = this.form.password; + if (!this.isSM2Encrypted(this.form.password)) { + try { + encryptedPassword = sm2Encrypt(this.serverPublicKey, this.form.password); + } catch (error) { + console.error("密码加密失败:", error); + showDanger(this.$t('sm2.encryptionFailed')); + return; + } + } + + // 加密用户名 + let encryptedUsername = this.form.username; + if (!this.isSM2Encrypted(this.form.username)) { + try { + encryptedUsername = sm2Encrypt(this.serverPublicKey, this.form.username); + } catch (error) { + console.error("用户名加密失败:", error); + showDanger(this.$t('sm2.encryptionFailed')); + return; + } + } + this.form.captchaId = this.captchaUuid; + + // 使用加密后的用户名和密码 + const loginData = { + ...this.form, + username: encryptedUsername, + password: encryptedPassword + }; + Api.user.login( - this.form, + loginData, ({ data }) => { showSuccess(this.$t('login.loginSuccess')); this.$store.commit("setToken", JSON.stringify(data.data)); @@ -320,6 +423,19 @@ export default { goToForgetPassword() { goToPage("/retrieve-password"); }, + + /** + * 判断字符串是否为SM2加密格式(十六进制格式) + * @param {string} str 待判断的字符串 + * @returns {boolean} 是否为SM2加密格式 + */ + isSM2Encrypted(str) { + if (typeof str !== 'string' || str.trim() === '') { + return false; + } + // 长度大于100且只包含0-9,a-f,A-F字符 + return str.length > 100 && /^[0-9a-fA-F]+$/.test(str); + } }, }; From 7ac5113fefc5e7cc4832dddaa485432c689daa5b Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Thu, 25 Sep 2025 09:15:41 +0800 Subject: [PATCH 03/22] =?UTF-8?q?update:=E9=9D=9E=E5=AF=B9=E7=A7=B0?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E6=B3=A8=E5=86=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/controller/LoginController.java | 38 +++++ main/manager-web/src/views/register.vue | 130 +++++++++++++++++- 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index 1a9fdd6c..10d74045 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -165,6 +165,44 @@ public class LoginController { if (!sysUserService.getAllowUserRegister()) { throw new RenException(ErrorCode.USER_REGISTER_DISABLED); } + + String username = login.getUsername(); + String password = login.getPassword(); + + // 如果用户名是SM2加密格式,则进行解密 + if (isSM2Encrypted(username)) { + try { + // 获取SM2私钥 + String privateKeyStr = sysParamsService.getValue(Constant.SM2_PRIVATE_KEY, true); + if (StringUtils.isBlank(privateKeyStr)) { + throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED); + } + + // 使用SM2私钥解密用户名(十六进制格式) + String decryptedUsername = SM2Utils.decrypt(privateKeyStr, username); + login.setUsername(decryptedUsername); + } catch (Exception e) { + throw new RenException(ErrorCode.SM2_DECRYPT_ERROR); + } + } + + // 如果密码是SM2加密格式,则进行解密 + if (isSM2Encrypted(password)) { + try { + // 获取SM2私钥 + String privateKeyStr = sysParamsService.getValue(Constant.SM2_PRIVATE_KEY, true); + if (StringUtils.isBlank(privateKeyStr)) { + throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED); + } + + // 使用SM2私钥解密密码 + String decryptedPassword = SM2Utils.decrypt(privateKeyStr, password); + login.setPassword(decryptedPassword); + } catch (Exception e) { + throw new RenException(ErrorCode.SM2_DECRYPT_ERROR); + } + } + // 是否开启手机注册 Boolean isMobileRegister = sysParamsService .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class); diff --git a/main/manager-web/src/views/register.vue b/main/manager-web/src/views/register.vue index c839abc8..d8c4b83e 100644 --- a/main/manager-web/src/views/register.vue +++ b/main/manager-web/src/views/register.vue @@ -121,8 +121,9 @@ @@ -456,18 +457,18 @@ async function stopAudio() { - 选中网络: {{ props.selectedNetwork.ssid }} + {{ t('deviceConfig.selectedNetwork') }}: {{ props.selectedNetwork.ssid }} - 信号: {{ props.selectedNetwork.rssi }}dBm + {{ t('deviceConfig.signal') }}: {{ props.selectedNetwork.rssi }}dBm - {{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }} + {{ props.selectedNetwork.authmode === 0 ? t('deviceConfig.openNetwork') : t('deviceConfig.encryptedNetwork') }} - 密码: {{ '*'.repeat(props.password.length) }} + {{ t('deviceConfig.password') }}: {{ '*'.repeat(props.password.length) }} @@ -482,81 +483,81 @@ async function stopAudio() { :disabled="!canGenerate" @click="generateAndPlay" > - {{ generating ? '生成中...' : '🎵 生成并播放声波' }} - + {{ generating ? t('deviceConfig.generating') : '🎵 ' + t('deviceConfig.generateAndPlaySoundWave') }} + - - {{ playing ? '播放中...' : '🔊 播放声波' }} - + + {{ playing ? t('deviceConfig.playing') : '🔊 ' + t('deviceConfig.playSoundWave') }} + - - ⏹️ 停止播放 - + + ⏹️ {{ t('deviceConfig.stopPlaying') }} + - - 自动循环播放声波 - - + + {{ t('deviceConfig.autoLoopPlaySoundWave') }} + + - 配网音频文件 + {{ t('deviceConfig.configAudioFile') }} - 时长: {{ audioLengthText }} + {{ t('deviceConfig.duration') }}: {{ audioLengthText }} - - 超声波配网说明 + + {{ t('deviceConfig.ultrasonicConfigInstructions') }} + + + + 1. {{ t('deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered') }} + + + 2. {{ t('deviceConfig.clickGenerateAndPlaySoundWave') }} + + + 3. {{ t('deviceConfig.bringPhoneCloseToXiaozhiDevice') }} + + + 4. {{ t('deviceConfig.duringAudioPlaybackXiaozhiWillReceive') }} + + + 5. {{ t('deviceConfig.afterConfigSuccessDeviceWillConnect') }} + + + {{ t('deviceConfig.usesAfskModulation') }} + + + {{ t('deviceConfig.ensureModeratePhoneVolume') }} + + - - - 1. 确保已选择WiFi网络并输入密码 - - - 2. 点击生成并播放声波,系统会将配网信息编码为音频 - - - 3. 将手机靠近xiaozhi设备(距离1-2米) - - - 4. 音频播放时,xiaozhi会接收并解码配网信息 - - - 5. 配网成功后设备会自动连接WiFi网络 - - - 使用AFSK调制技术,通过1800Hz和1500Hz频率传输数据 - - - 请确保手机音量适中,避免环境噪音干扰 - - - diff --git a/main/manager-mobile/src/pages/device-config/components/wifi-config.vue b/main/manager-mobile/src/pages/device-config/components/wifi-config.vue index ac54b6b6..e8a4d6b7 100644 --- a/main/manager-mobile/src/pages/device-config/components/wifi-config.vue +++ b/main/manager-mobile/src/pages/device-config/components/wifi-config.vue @@ -1,6 +1,7 @@ @@ -285,10 +286,10 @@ defineExpose({ - 暂无设备 + {{ t('device.noDevice') }} - 点击右下角 + 号绑定您的第一个设备 + {{ t('device.clickToBindFirstDevice') }} diff --git a/main/manager-mobile/src/pages/index/index.vue b/main/manager-mobile/src/pages/index/index.vue index bb74ab1c..8678b80c 100644 --- a/main/manager-mobile/src/pages/index/index.vue +++ b/main/manager-mobile/src/pages/index/index.vue @@ -17,6 +17,7 @@ import { useMessage } from 'wot-design-uni' import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js' import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent' import { toast } from '@/utils/toast' +import { t } from '@/i18n' defineOptions({ name: 'Home', @@ -78,11 +79,11 @@ async function handleCreateAgent(agentName: string) { await createAgent({ agentName: agentName.trim() }) // 创建成功后刷新列表 pagingRef.value.reload() - toast.success(`智能体"${agentName}"创建成功!`) + toast.success(`${t('home.agentName')}"${agentName}"${t('message.saveSuccess')}`) } catch (error: any) { console.error('创建智能体失败:', error) - const errorMessage = error?.message || '创建失败,请重试' + const errorMessage = error?.message || t('message.saveFail') toast.error(errorMessage) } } @@ -93,11 +94,11 @@ async function handleDeleteAgent(agent: Agent) { await deleteAgent(agent.id) // 删除成功后刷新列表 pagingRef.value.reload() - toast.success(`智能体"${agent.agentName}"已删除`) + toast.success(`${t('home.agentName')}${t('message.deleteSuccess')}`) } catch (error: any) { console.error('删除智能体失败:', error) - const errorMessage = error?.message || '删除失败,请重试' + const errorMessage = error?.message || t('message.deleteFail') toast.error(errorMessage) } } @@ -119,13 +120,13 @@ function handleCardClick(agent: Agent) { function openCreateDialog() { message .prompt({ - title: '创建智能体', + title: t('home.dialogTitle'), msg: '', - inputPlaceholder: '例如:客服助手、语音助理、知识问答', + inputPlaceholder: t('home.inputPlaceholder'), inputValue: '', inputPattern: /^[\u4E00-\u9FA5a-z0-9\s]{1,50}$/i, - confirmButtonText: '立即创建', - cancelButtonText: '取消', + confirmButtonText: t('home.createNow'), + cancelButtonText: t('common.cancel'), }) .then(async (result: any) => { if (result.value && String(result.value).trim()) { @@ -144,12 +145,12 @@ function formatTime(timeStr: string) { const diff = now.getTime() - date.getTime() if (diff < 60000) - return '刚刚' + return t('home.justNow') if (diff < 3600000) - return `${Math.floor(diff / 60000)}分钟前` + return `${Math.floor(diff / 60000)}${t('home.minutesAgo')}` if (diff < 86400000) - return `${Math.floor(diff / 3600000)}小时前` - return `${Math.floor(diff / 86400000)}天前` + return `${Math.floor(diff / 3600000)}${t('home.hoursAgo')}` + return `${Math.floor(diff / 86400000)}${t('home.daysAgo')}` } // 页面显示时刷新列表 @@ -159,12 +160,20 @@ onShow(() => { pagingRef.value.reload() } }) + +// 在组件挂载后设置导航栏标题 +import { onMounted } from 'vue' +onMounted(() => { + uni.setNavigationBarTitle({ + title: t('home.pageTitle') + }) +}) @@ -260,10 +266,10 @@ onShow(() => { - 暂无智能体 + {{ t('home.emptyState') }} - 点击右下角 + 号创建您的第一个智能体 + {{ t('home.createFirstAgent') }} diff --git a/main/manager-mobile/src/pages/login/index.vue b/main/manager-mobile/src/pages/login/index.vue index 71f53cce..00912902 100644 --- a/main/manager-mobile/src/pages/login/index.vue +++ b/main/manager-mobile/src/pages/login/index.vue @@ -3,7 +3,7 @@ "layout": "default", "style": { "navigationStyle": "custom", - "navigationBarTitleText": "登陆" + "navigationBarTitleText": "Login" } } @@ -15,6 +15,9 @@ import { login } from '@/api/auth' import { useConfigStore } from '@/store' import { getEnvBaseUrl } from '@/utils' import { toast } from '@/utils/toast' +// 导入国际化相关功能 +import { t, changeLanguage, getSupportedLanguages, initI18n } from '@/i18n' +import type { Language } from '@/store/lang' // 获取屏幕边界到安全区域距离 let safeAreaInsets @@ -136,28 +139,28 @@ async function handleLogin() { // 表单验证 if (loginType.value === 'username') { if (!formData.value.username) { - toast.warning('请输入用户名') + toast.warning(t('login.enterUsername')) return } } else { if (!formData.value.mobile) { - toast.warning('请输入手机号') + toast.warning(t('login.enterPhone')) return } // 手机号格式验证 const phoneRegex = /^1[3-9]\d{9}$/ if (!phoneRegex.test(formData.value.mobile)) { - toast.warning('请输入正确的手机号') + toast.warning(t('login.enterPhone')) return } } if (!formData.value.password) { - toast.warning('请输入密码') + toast.warning(t('login.enterPassword')) return } if (!formData.value.captcha) { - toast.warning('请输入验证码') + toast.warning(t('login.enterCaptcha')) return } @@ -177,7 +180,7 @@ async function handleLogin() { uni.setStorageSync('token', response.token) uni.setStorageSync('expire', response.expire) - toast.success('登录成功') + toast.success(t('message.loginSuccess')) // 跳转到主页 setTimeout(() => { @@ -189,6 +192,10 @@ async function handleLogin() { catch (error: any) { // 登录失败重新获取验证码 refreshCaptcha() + // 处理验证码错误 - 从error.message中解析错误码 + if (error.message.includes('请求错误[10067]')) { + toast.warning(t('login.captchaError')) + } } finally { loading.value = false @@ -200,14 +207,26 @@ onLoad(() => { refreshCaptcha() }) +// 语言切换相关 +const showLanguageSheet = ref(false) +const supportedLanguages = getSupportedLanguages() + +// 初始化国际化 +initI18n() + +// 切换语言 +function handleLanguageChange(lang: Language) { + changeLanguage(lang) + showLanguageSheet.value = false +} + // 组件挂载时确保配置已加载 onMounted(async () => { if (!configStore.config.name) { try { await configStore.fetchPublicConfig() - } - catch (error) { - console.error('获取配置失败:', error) + } catch (error) { + console.error(t('login.fetchConfigError'), error) } } }) @@ -219,21 +238,25 @@ onMounted(async () => { - - - + + + + + 🌐 + + + + + + @@ -253,7 +276,7 @@ onMounted(async () => { v-model="formData.mobile" custom-class="styled-input" no-border - placeholder="请输入手机号码" + :placeholder="t('login.enterPhone')" type="number" :maxlength="11" /> @@ -270,7 +293,7 @@ onMounted(async () => { v-model="formData.username" custom-class="styled-input" no-border - placeholder="请输入用户名" + :placeholder="t('login.enterUsername')" /> @@ -282,7 +305,7 @@ onMounted(async () => { v-model="formData.password" custom-class="styled-input" no-border - placeholder="请输入密码" + :placeholder="t('login.enterPassword')" clearable show-password :maxlength="20" @@ -296,7 +319,7 @@ onMounted(async () => { v-model="formData.captcha" custom-class="styled-input" no-border - placeholder="请输入验证码" + :placeholder="t('login.enterCaptcha')" :maxlength="6" /> @@ -307,7 +330,7 @@ onMounted(async () => { - 忘记密码? + {{ t('login.forgotPassword') }} @@ -315,15 +338,15 @@ onMounted(async () => { class="login-btn" @click="handleLogin" > - {{ loading ? '登录中...' : '登录' }} + {{ loading ? t('login.loggingIn') : t('login.loginButton') }} - 还没有账户? + {{ t('login.noAccount') }} - 立即注册 + {{ t('login.registerNow') }} @@ -352,7 +375,7 @@ onMounted(async () => { @@ -386,11 +409,33 @@ onMounted(async () => { custom-class="confirm-btn" @click="closeAreaCodeSheet" > - 确认 + {{ t('login.confirm') }} + + + + + + + + {{ lang.name }} + + + + + @@ -793,20 +838,52 @@ onMounted(async () => { } } } -.server-btn { +// 右上角按钮组 +.top-right-buttons { position: absolute; - right: 20rpx; // 距离右边距 - top: 40rpx; // 顶部稍微下移,不贴状态栏 + right: 20rpx; + display: flex; + gap: 20rpx; + z-index: 999; +} + +// 语言切换按钮 +.lang-btn { width: 48rpx; height: 48rpx; display: flex; align-items: center; justify-content: center; - z-index: 999; cursor: pointer; - background: rgba(255, 255, 255, 0.15); // 半透明背景,更好看 - border-radius: 24rpx; // 圆形按钮 - box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2); // 阴影 + background: rgba(255, 255, 255, 0.15); + border-radius: 24rpx; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2); + + &:active { + transform: scale(0.95); + } + + .lang-text-icon { + font-size: 28rpx; + color: #FFFFFF; + } + + &:hover { + background: rgba(255, 255, 255, 0.25); + } +} + +// 服务端设置按钮 +.server-btn { + width: 48rpx; + height: 48rpx; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: rgba(255, 255, 255, 0.15); + border-radius: 24rpx; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2); &:active { transform: scale(0.95); @@ -814,11 +891,45 @@ onMounted(async () => { .server-icon { font-size: 28rpx; - color: #FFFFFF; // 白色图标 + color: #FFFFFF; } &:hover { - background: rgba(255, 255, 255, 0.25); // 悬停效果 + background: rgba(255, 255, 255, 0.25); + } +} + +// 语言选择弹窗样式 +.language-sheet { + background: #ffffff; + border-radius: 24rpx 24rpx 0 0; + overflow: hidden; + + .language-list { + max-height: 60vh; + padding: 0 40rpx; + + .language-item { + display: flex; + align-items: center; + padding: 32rpx 0; + border-bottom: 1rpx solid #f8f9fa; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #f8f9fa; + } + + &:last-child { + border-bottom: none; + } + + .language-name { + font-size: 32rpx; + color: #333333; + } + } } } diff --git a/main/manager-mobile/src/pages/register/index.vue b/main/manager-mobile/src/pages/register/index.vue index 91463e1d..b78d61a6 100644 --- a/main/manager-mobile/src/pages/register/index.vue +++ b/main/manager-mobile/src/pages/register/index.vue @@ -14,6 +14,8 @@ import { register, sendSmsCode } from '@/api/auth' import { useConfigStore } from '@/store' import { getEnvBaseUrl } from '@/utils' import { toast } from '@/utils/toast' +// 导入国际化相关功能 +import { t, initI18n } from '@/i18n' // 获取屏幕边界到安全区域距离 let safeAreaInsets @@ -134,18 +136,18 @@ async function refreshCaptcha() { // 发送短信验证码 async function sendSmsVerification() { if (!formData.value.mobile) { - toast.warning('请输入手机号') + toast.warning(t('register.enterPhone')) return } if (!formData.value.captcha) { - toast.warning('请输入图形验证码') + toast.warning(t('register.enterCode')) return } // 手机号格式验证 const phoneRegex = /^1[3-9]\d{9}$/ if (!phoneRegex.test(formData.value.mobile)) { - toast.warning('请输入正确的手机号') + toast.warning(t('register.enterPhone')) return } @@ -157,7 +159,7 @@ async function sendSmsVerification() { captchaId: formData.value.captchaId, }) - toast.success('验证码发送成功') + toast.success(t('message.registerSuccess')) // 开始倒计时 smsCountdown.value = 60 @@ -169,6 +171,10 @@ async function sendSmsVerification() { }, 1000) } catch (error: any) { + // 处理验证码错误 - 从error.message中解析错误码 + if (error.message.includes('请求错误[10067]')) { + toast.warning(t('login.captchaError')) + } // 发送失败重新获取图形验证码 refreshCaptcha() } @@ -182,44 +188,44 @@ async function handleRegister() { // 表单验证 if (registerType.value === 'username') { if (!formData.value.username) { - toast.warning('请输入用户名') + toast.warning(t('register.enterUsername')) return } } else { if (!formData.value.mobile) { - toast.warning('请输入手机号') + toast.warning(t('register.enterPhone')) return } // 手机号格式验证 const phoneRegex = /^1[3-9]\d{9}$/ if (!phoneRegex.test(formData.value.mobile)) { - toast.warning('请输入正确的手机号') + toast.warning(t('register.enterPhone')) return } if (!formData.value.mobileCaptcha) { - toast.warning('请输入短信验证码') + toast.warning(t('register.enterCode')) return } } if (!formData.value.password) { - toast.warning('请输入密码') + toast.warning(t('register.enterPassword')) return } if (!formData.value.confirmPassword) { - toast.warning('请确认密码') + toast.warning(t('register.confirmPassword')) return } if (formData.value.password !== formData.value.confirmPassword) { - toast.warning('两次输入的密码不一致') + toast.warning(t('register.confirmPassword')) return } if (!formData.value.captcha) { - toast.warning('请输入验证码') + toast.warning(t('register.enterCode')) return } @@ -239,7 +245,7 @@ async function handleRegister() { } await register(registerData) - toast.success('注册成功') + toast.success(t('message.registerSuccess')) // 跳转到登录页 setTimeout(() => { @@ -247,6 +253,10 @@ async function handleRegister() { }, 1000) } catch (error: any) { + // 处理验证码错误 - 从error.message中解析错误码 + if (error.message.includes('请求错误[10067]')) { + toast.warning(t('login.captchaError')) + } // 注册失败重新获取验证码 refreshCaptcha() } @@ -275,7 +285,11 @@ onMounted(async () => { console.error('获取配置失败:', error) } } + // 初始化国际化 + initI18n() }) + + diff --git a/main/manager-mobile/src/pages/settings/index.vue b/main/manager-mobile/src/pages/settings/index.vue index 220d2338..5a099bf9 100644 --- a/main/manager-mobile/src/pages/settings/index.vue +++ b/main/manager-mobile/src/pages/settings/index.vue @@ -11,6 +11,8 @@ import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, se import { isMp } from '@/utils/platform' import { computed, onMounted, reactive, ref } from 'vue' import { useToast } from 'wot-design-uni' +import { t, changeLanguage, getSupportedLanguages, getCurrentLanguage } from '@/i18n' +import type { Language } from '@/store/lang' defineOptions({ name: 'SettingsPage', @@ -62,7 +64,7 @@ function validateUrl() { } if (!/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) { - urlError.value = '请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)' + urlError.value = t('settings.validServerUrl') } } @@ -85,12 +87,12 @@ async function testServerBaseUrl() { if (response.statusCode === 200) { return true } else { - toast.error('无效地址,请检查服务端是否启动或网络连接是否正常') + toast.error(t('message.invalidAddress')) return false } } catch (error) { console.error('测试服务端地址失败:', error) - toast.error('无效地址,请检查服务端是否启动或网络连接是否正常') + toast.error(t('message.invalidAddress')) return false } } @@ -98,7 +100,7 @@ async function testServerBaseUrl() { // 保存服务端地址 async function saveServerBaseUrl() { if (!baseUrlInput.value || !/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) { - toast.warning('请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)') + toast.warning(t('settings.validServerUrl')) return } @@ -113,21 +115,32 @@ async function saveServerBaseUrl() { clearAllCacheAfterUrlChange() uni.showModal({ - title: '重启应用', - content: '服务端地址已保存并清空缓存,是否立即重启生效?', - confirmText: '立即重启', - cancelText: '稍后', + title: t('settings.restartApp'), + content: t('settings.serverUrlSavedAndCacheCleared'), + confirmText: t('settings.restartNow'), + cancelText: t('settings.restartLater'), success: (res) => { if (res.confirm) { restartApp() } else { - toast.success('已保存,可稍后手动重启应用') + toast.success(t('settings.restartSuccess')) } }, }) } +// 语言切换 +const supportedLanguages = getSupportedLanguages() +const currentLanguage = ref(getCurrentLanguage()) +const showLanguageSheet = ref(false) + +function handleLanguageChange(lang: Language) { + changeLanguage(lang) + showLanguageSheet.value = false + toast.success(t('settings.languageChanged')) +} + // 重置为 env 默认 function resetServerBaseUrl() { clearServerBaseUrlOverride() @@ -137,16 +150,16 @@ function resetServerBaseUrl() { clearAllCacheAfterUrlChange() uni.showModal({ - title: '重启应用', - content: '已重置为默认地址并清空缓存,是否立即重启生效?', - confirmText: '立即重启', - cancelText: '稍后', + title: t('settings.restartApp'), + content: t('settings.resetToDefaultAndCacheCleared'), + confirmText: t('settings.restartNow'), + cancelText: t('settings.restartLater'), success: (res) => { if (res.confirm) { restartApp() } else { - toast.success('已重置,可稍后手动重启应用') + toast.success(t('settings.resetSuccess')) } }, }) @@ -185,8 +198,7 @@ function clearAllCacheAfterUrlChange() { // 重新获取缓存信息 getCacheInfo() - } - catch (error) { + } catch (error) { console.error('清除缓存失败:', error) } } @@ -195,12 +207,14 @@ function clearAllCacheAfterUrlChange() { async function clearCache() { try { uni.showModal({ - title: '确认清除', - content: '确定要清除所有缓存吗?这将删除所有数据包括登录状态,需要重新登录。', + title: t('settings.confirmClear'), + content: t('settings.confirmClearMessage'), + confirmText: t('common.confirm'), + cancelText: t('common.cancel'), success: (res) => { if (res.confirm) { clearAllCacheAfterUrlChange() - toast.success('缓存清除成功,即将跳转到登录页') + toast.success(t('settings.cacheCleared')) // 延迟跳转到登录页 setTimeout(() => { @@ -209,22 +223,22 @@ async function clearCache() { } }, }) - } - catch (error) { + } catch (error) { console.error('清除缓存失败:', error) - toast.error('清除缓存失败') + toast.error(t('settings.clearCacheFailed')) } } // 关于我们 function showAbout() { uni.showModal({ - title: `关于${import.meta.env.VITE_APP_TITLE}`, - content: `${import.meta.env.VITE_APP_TITLE}\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server`, - title: `关于小智智控台`, - content: `小智智控台\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server 0.8.3`, + title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }), + content: t('settings.aboutContent', { + appName: import.meta.env.VITE_APP_TITLE, + version: '0.8.3' + }), showCancel: false, - confirmText: '确定', + confirmText: t('common.confirm'), }) } @@ -234,19 +248,24 @@ onMounted(async () => { loadServerBaseUrl() } getCacheInfo() + + // 动态设置导航栏标题为国际化文本 + uni.setNavigationBarTitle({ + title: t('settings.title') + }) }) +// 保持与 edit.vue 一致的风格,样式主要通过类名控制 + +// 语言选择弹窗样式 +.language-sheet { + .language-list { + max-height: 50vh; + .language-item { + padding: 30rpx 0; + text-align: center; + border-bottom: 1rpx solid #f0f0f0; + .language-name { + font-size: 28rpx; + color: #333; + } + &:last-child { + border-bottom: none; + } + &:active { + background-color: #f5f7fb; + } + } + } +} + diff --git a/main/manager-mobile/src/pages/voiceprint/index.vue b/main/manager-mobile/src/pages/voiceprint/index.vue index 5fe8bbad..3cca4442 100644 --- a/main/manager-mobile/src/pages/voiceprint/index.vue +++ b/main/manager-mobile/src/pages/voiceprint/index.vue @@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue' import { useMessage } from 'wot-design-uni' import { useToast } from 'wot-design-uni/components/wd-toast' import { createVoicePrint, deleteVoicePrint, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint' +import { t } from '@/i18n' defineOptions({ name: 'VoicePrintManage', @@ -81,7 +82,7 @@ async function loadVoicePrintList() { // 检查是否有当前选中的智能体 if (!currentAgentId.value) { - console.warn('没有选中的智能体') + console.warn(t('voiceprint.noSelectedAgent')) voicePrintList.value = [] return } @@ -117,7 +118,7 @@ async function refresh() { async function loadChatHistory() { try { if (!currentAgentId.value) { - toast.error('请先选择智能体') + toast.error(t('voiceprint.pleaseSelectAgent')) return } @@ -133,24 +134,47 @@ async function loadChatHistory() { } catch (error) { console.error('获取对话记录失败:', error) - toast.error('获取对话记录失败') + toast.error(t('voiceprint.fetchHistoryFailed')) } } // 打开添加弹窗 function openAddDialog() { if (!currentAgentId.value) { - toast.error('请先选择智能体') + toast.error(t('voiceprint.pleaseSelectAgent')) return } - addForm.value = { - agentId: currentAgentId.value, - audioId: '', - sourceName: '', - introduce: '', + // 检查声纹接口是否配置(通过尝试获取声纹列表来检测) + const checkVoicePrintConfig = async () => { + try { + await getVoicePrintList(currentAgentId.value) + // 接口正常,继续打开添加弹窗 + addForm.value = { + agentId: currentAgentId.value, + audioId: '', + sourceName: '', + introduce: '', + } + showAddDialog.value = true + } catch (error: any) { + // 捕捉声纹接口未配置错误 + if (error.message && error.message.includes('请求错误[10054]')) { + toast.error(t('voiceprint.voiceprintInterfaceNotConfigured')) + } else { + // 其他错误,继续打开弹窗 + addForm.value = { + agentId: currentAgentId.value, + audioId: '', + sourceName: '', + introduce: '', + } + showAddDialog.value = true + } + } } - showAddDialog.value = true + + checkVoicePrintConfig() } // 打开编辑弹窗 @@ -162,7 +186,7 @@ function openEditDialog(item: VoicePrint) { // 获取选中音频的显示内容 function getSelectedAudioContent(audioId: string) { if (!audioId) - return '点击选择声纹向量' + return t('voiceprint.clickToSelectVector') const chatItem = chatHistoryList.value.find(item => item.audioId === audioId) return chatItem ? chatItem.content : `已选择: ${audioId.substring(0, 8)}...` } @@ -181,34 +205,34 @@ function selectAudioId({ item }: { item: any }) { // 提交添加说话人 async function submitAdd() { if (!addForm.value.sourceName.trim()) { - toast.error('请输入姓名') + toast.error(t('voiceprint.pleaseInputName')) return } if (!addForm.value.audioId) { - toast.error('请选择声纹向量') + toast.error(t('voiceprint.pleaseSelectVector')) return } try { await createVoicePrint(addForm.value) - toast.success('添加成功') + toast.success(t('voiceprint.addSuccess')) showAddDialog.value = false await loadVoicePrintList() } catch (error) { console.error('添加说话人失败:', error) - toast.error('添加说话人失败') + toast.error(t('voiceprint.addFailed')) } } // 提交编辑说话人 async function submitEdit() { if (!editForm.value.sourceName.trim()) { - toast.error('请输入姓名') + toast.error(t('voiceprint.pleaseInputName')) return } if (!editForm.value.audioId) { - toast.error('请选择声纹向量') + toast.error(t('voiceprint.pleaseSelectVector')) return } @@ -220,13 +244,13 @@ async function submitEdit() { introduce: editForm.value.introduce, createDate: editForm.value.createDate, }) - toast.success('编辑成功') + toast.success(t('voiceprint.editSuccess')) showEditDialog.value = false await loadVoicePrintList() } catch (error) { console.error('编辑说话人失败:', error) - toast.error('编辑说话人失败') + toast.error(t('voiceprint.editFailed')) } } @@ -239,11 +263,11 @@ function handleEdit(item: VoicePrint) { // 删除声纹 async function handleDelete(id: string) { message.confirm({ - msg: '确定要删除这个说话人吗?', - title: '确认删除', + msg: t('voiceprint.deleteConfirmMsg'), + title: t('voiceprint.deleteConfirmTitle'), }).then(async () => { await deleteVoicePrint(id) - toast.success('删除成功') + toast.success(t('voiceprint.deleteSuccess')) await loadVoicePrintList() }).catch(() => { console.log('点击了取消按钮') @@ -268,7 +292,7 @@ defineExpose({ - 加载中... + {{ t('voiceprint.loading') }} @@ -302,7 +326,7 @@ defineExpose({ @click="handleDelete(item.id)" > - 删除 + {{ t('voiceprint.delete') }} @@ -315,11 +339,11 @@ defineExpose({ - - 暂无声纹数据 + + {{ t('voiceprint.emptyTitle') }} - 点击右下角 + 号添加您的第一个说话人 + {{ t('voiceprint.emptyDesc') }} @@ -341,7 +365,7 @@ defineExpose({ - 添加说话人 + {{ t('voiceprint.addSpeaker') }} @@ -349,7 +373,7 @@ defineExpose({ - * 声纹向量 + * {{ t('voiceprint.voiceVector') }} - * 姓名 + * {{ t('voiceprint.name') }} - * 描述 + * {{ t('voiceprint.description') }}