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 77d7be12..0da1699b 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 @@ -161,9 +161,8 @@ public interface ErrorCode { int MQTT_SECRET_LENGTH_INSECURE = 10123; // mqtt密钥长度不安全 int MQTT_SECRET_CHARACTER_INSECURE = 10124; // mqtt密钥必须同时包含大小写字母 int MQTT_SECRET_WEAK_PASSWORD = 10125; // mqtt密钥包含弱密码 - - // 字典相关错误码 int DICT_LABEL_DUPLICATE = 10128; // 字典标签重复 - // 模型相关错误码 - int MODEL_TYPE_PROVIDE_CODE_NOT_NULL = 10129; // modelType和provideCode不能为空 + int SM2_KEY_NOT_CONFIGURED = 10129; // SM2密钥未配置 + int SM2_DECRYPT_ERROR = 10130; // SM2解密失败 + int MODEL_TYPE_PROVIDE_CODE_NOT_NULL = 10131; // modelType和provideCode不能为空 } 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..cf0ed9c5 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/common/utils/SM2Utils.java @@ -0,0 +1,130 @@ +package xiaozhi.common.utils; + +import org.bouncycastle.asn1.gm.GMNamedCurves; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.engines.SM2Engine; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECPrivateKeyParameters; +import org.bouncycastle.crypto.params.ECPublicKeyParameters; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey; +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.util.encoders.Hex; + +import java.math.BigInteger; +import java.security.*; +import java.security.spec.ECGenParameterSpec; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * SM2加密工具类(采用十六进制格式,与chancheng-archive-service项目保持一致) + */ +public class SM2Utils { + + /** + * 公钥常量 + */ + public static final String KEY_PUBLIC_KEY = "publicKey"; + /** + * 私钥返回值常量 + */ + public static final String KEY_PRIVATE_KEY = "privateKey"; + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + /** + * SM2加密算法 + * + * @param publicKey 十六进制公钥 + * @param data 明文数据 + * @return 十六进制密文 + */ + public static String encrypt(String publicKey, String data) { + try { + // 获取一条SM2曲线参数 + X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1"); + // 构造ECC算法参数,曲线方程、椭圆曲线G点、大整数N + ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN()); + //提取公钥点 + ECPoint pukPoint = sm2ECParameters.getCurve().decodePoint(Hex.decode(publicKey)); + // 公钥前面的02或者03表示是压缩公钥,04表示未压缩公钥, 04的时候,可以去掉前面的04 + ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(pukPoint, domainParameters); + + SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2); + // 设置sm2为加密模式 + sm2Engine.init(true, new ParametersWithRandom(publicKeyParameters, new SecureRandom())); + + byte[] in = data.getBytes(StandardCharsets.UTF_8); + byte[] arrayOfBytes = sm2Engine.processBlock(in, 0, in.length); + return Hex.toHexString(arrayOfBytes); + } catch (Exception e) { + throw new RuntimeException("SM2加密失败", e); + } + } + + /** + * SM2解密算法 + * + * @param privateKey 十六进制私钥 + * @param cipherData 十六进制密文数据 + * @return 明文 + */ + public static String decrypt(String privateKey, String cipherData) { + try { + // 使用BC库加解密时密文以04开头,传入的密文前面没有04则补上 + if (!cipherData.startsWith("04")) { + cipherData = "04" + cipherData; + } + byte[] cipherDataByte = Hex.decode(cipherData); + BigInteger privateKeyD = new BigInteger(privateKey, 16); + //获取一条SM2曲线参数 + X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1"); + //构造domain参数 + ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN()); + ECPrivateKeyParameters privateKeyParameters = new ECPrivateKeyParameters(privateKeyD, domainParameters); + + SM2Engine sm2Engine = new SM2Engine(SM2Engine.Mode.C1C3C2); + // 设置sm2为解密模式 + sm2Engine.init(false, privateKeyParameters); + + byte[] arrayOfBytes = sm2Engine.processBlock(cipherDataByte, 0, cipherDataByte.length); + return new String(arrayOfBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("SM2解密失败", e); + } + } + + /** + * 生成密钥对 + */ + public static Map createKey() { + try { + ECGenParameterSpec sm2Spec = new ECGenParameterSpec("sm2p256v1"); + // 获取一个椭圆曲线类型的密钥对生成器 + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", new BouncyCastleProvider()); + // 使用SM2参数初始化生成器 + kpg.initialize(sm2Spec); + // 获取密钥对 + KeyPair keyPair = kpg.generateKeyPair(); + PublicKey publicKey = keyPair.getPublic(); + BCECPublicKey p = (BCECPublicKey) publicKey; + PrivateKey privateKey = keyPair.getPrivate(); + BCECPrivateKey s = (BCECPrivateKey) privateKey; + + Map result = new HashMap<>(); + result.put(KEY_PUBLIC_KEY, Hex.toHexString(p.getQ().getEncoded(false))); + result.put(KEY_PRIVATE_KEY, Hex.toHexString(s.getD().toByteArray())); + return result; + } catch (Exception e) { + throw new RuntimeException("生成SM2密钥对失败", e); + } + } + + +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/Sm2DecryptUtil.java b/main/manager-api/src/main/java/xiaozhi/common/utils/Sm2DecryptUtil.java new file mode 100644 index 00000000..5dd1a312 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/common/utils/Sm2DecryptUtil.java @@ -0,0 +1,61 @@ +package xiaozhi.common.utils; + +import org.apache.commons.lang3.StringUtils; +import xiaozhi.common.constant.Constant; +import xiaozhi.common.exception.ErrorCode; +import xiaozhi.common.exception.RenException; +import xiaozhi.modules.security.service.CaptchaService; +import xiaozhi.modules.sys.service.SysParamsService; + +/** + * SM2解密和验证码验证工具类 + * 封装了重复的SM2解密、验证码提取和验证逻辑 + */ +public class Sm2DecryptUtil { + + /** + * 验证码长度 + */ + private static final int CAPTCHA_LENGTH = 5; + + /** + * 解密SM2加密内容,提取验证码并验证 + * @param encryptedPassword SM2加密的密码字符串 + * @param captchaId 验证码ID + * @param captchaService 验证码服务 + * @param sysParamsService 系统参数服务 + * @return 解密后的实际密码 + */ + public static String decryptAndValidateCaptcha(String encryptedPassword, String captchaId, + CaptchaService captchaService, SysParamsService sysParamsService) { + // 获取SM2私钥 + String privateKeyStr = sysParamsService.getValue(Constant.SM2_PRIVATE_KEY, true); + if (StringUtils.isBlank(privateKeyStr)) { + throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED); + } + + // 使用SM2私钥解密密码 + String decryptedContent; + try { + decryptedContent = SM2Utils.decrypt(privateKeyStr, encryptedPassword); + } catch (Exception e) { + throw new RenException(ErrorCode.SM2_DECRYPT_ERROR); + } + + // 分离验证码和密码:前5位是验证码,后面是密码 + if (decryptedContent.length() > CAPTCHA_LENGTH) { + String embeddedCaptcha = decryptedContent.substring(0, CAPTCHA_LENGTH); + String actualPassword = decryptedContent.substring(CAPTCHA_LENGTH); + + // 验证嵌入的验证码是否正确 + boolean embeddedCaptchaValid = captchaService.validate(captchaId, embeddedCaptcha, true); + if (!embeddedCaptchaValid) { + throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR); + } + + return actualPassword; + } else { + throw new RenException(ErrorCode.SM2_DECRYPT_ERROR); + } + } +} \ 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..a008c25b 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; @@ -31,6 +32,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.Sm2DecryptUtil; +import org.apache.commons.lang3.StringUtils; import xiaozhi.modules.sys.dto.PasswordDTO; import xiaozhi.modules.sys.dto.RetrievePasswordDTO; import xiaozhi.modules.sys.dto.SysUserDTO; @@ -42,6 +45,7 @@ import xiaozhi.modules.sys.vo.SysDictDataItem; /** * 登录控制层 */ +@Slf4j @AllArgsConstructor @RestController @RequestMapping("/user") @@ -66,10 +70,11 @@ public class LoginController { @Operation(summary = "短信验证码") public Result smsVerification(@RequestBody SmsVerificationDTO dto) { // 验证图形验证码 - boolean validate = captchaService.validate(dto.getCaptchaId(), dto.getCaptcha(), true); + boolean validate = captchaService.validate(dto.getCaptchaId(), dto.getCaptcha(), false); if (!validate) { throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR); } + Boolean isMobileRegister = sysParamsService .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class); if (!isMobileRegister) { @@ -83,11 +88,14 @@ public class LoginController { @PostMapping("/login") @Operation(summary = "登录") public Result login(@RequestBody LoginDTO login) { - // 验证是否正确输入验证码 - boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true); - if (!validate) { - throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR); - } + String password = login.getPassword(); + + // 使用工具类解密并验证验证码 + String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( + password, login.getCaptchaId(), captchaService, sysParamsService); + + login.setPassword(actualPassword); + // 按照用户名获取用户 SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); // 判断用户是否存在 @@ -100,6 +108,8 @@ public class LoginController { } return sysUserTokenService.createToken(userDTO.getId()); } + + @PostMapping("/register") @Operation(summary = "注册") @@ -107,6 +117,15 @@ public class LoginController { if (!sysUserService.getAllowUserRegister()) { throw new RenException(ErrorCode.USER_REGISTER_DISABLED); } + + String password = login.getPassword(); + + // 使用工具类解密并验证验证码 + String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( + password, login.getCaptchaId(), captchaService, sysParamsService); + + login.setPassword(actualPassword); + // 是否开启手机注册 Boolean isMobileRegister = sysParamsService .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class); @@ -122,12 +141,6 @@ public class LoginController { if (!validate) { throw new RenException(ErrorCode.SMS_CODE_ERROR); } - } else { - // 验证是否正确输入验证码 - validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true); - if (!validate) { - throw new RenException(ErrorCode.SMS_CAPTCHA_ERROR); - } } // 按照用户名获取用户 @@ -190,6 +203,14 @@ public class LoginController { throw new RenException(ErrorCode.SMS_CODE_ERROR); } + String password = dto.getPassword(); + + // 使用工具类解密并验证验证码 + String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( + password, dto.getCaptchaId(), captchaService, sysParamsService); + + dto.setPassword(actualPassword); + sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword()); return new Result<>(); } @@ -208,6 +229,13 @@ public class LoginController { config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true)); config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true)); config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true)); + + // SM2公钥 + String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true); + if (StringUtils.isBlank(publicKey)) { + throw new RenException(ErrorCode.SM2_KEY_NOT_CONFIGURED); + } + config.put("sm2PublicKey", publicKey); return new Result>().ok(config); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/dto/LoginDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/security/dto/LoginDTO.java index 12b99d32..df13f23d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/dto/LoginDTO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/dto/LoginDTO.java @@ -21,10 +21,6 @@ public class LoginDTO implements Serializable { @NotBlank(message = "{sysuser.password.require}") private String password; - @Schema(description = "验证码") - @NotBlank(message = "{sysuser.captcha.require}") - private String captcha; - @Schema(description = "手机验证码") private String mobileCaptcha; diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/dto/RetrievePasswordDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/dto/RetrievePasswordDTO.java index efac8099..5475cf67 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/dto/RetrievePasswordDTO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/dto/RetrievePasswordDTO.java @@ -25,6 +25,10 @@ public class RetrievePasswordDTO implements Serializable { @NotBlank(message = "{sysuser.password.require}") private String password; + @Schema(description = "图形验证码ID") + @NotBlank(message = "{sysuser.uuid.require}") + private String captchaId; + } \ 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..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 @@ -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 keyPair = SM2Utils.createKey(); + String newPublicKey = keyPair.get(SM2Utils.KEY_PUBLIC_KEY); + String newPrivateKey = keyPair.get(SM2Utils.KEY_PRIVATE_KEY); + + // 更新数据库中的密钥对 + updateValueByCode(Constant.SM2_PUBLIC_KEY, newPublicKey); + updateValueByCode(Constant.SM2_PRIVATE_KEY, newPrivateKey); + } } /** diff --git a/main/manager-api/src/main/resources/db/changelog/202509221530.sql b/main/manager-api/src/main/resources/db/changelog/202509221530.sql new file mode 100644 index 00000000..078fbfae --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202509221530.sql @@ -0,0 +1,7 @@ +-- 添加SM2国密算法密钥参数 +-- 用于服务器端SM2加密解密功能 + +-- 添加SM2密钥参数 +INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES +(120, 'server.public_key', '', 'string', 1, '服务器SM2公钥'), +(121, 'server.private_key', '', 'string', 1, '服务器SM2私钥'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index b5163d92..3289d69f 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -359,6 +359,13 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202509161701.sql + - changeSet: + id: 202509221530 + author: cgd + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202509221530.sql - changeSet: id: 202509191545 author: RanChen @@ -366,8 +373,6 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202509191545.sql - - - changeSet: id: 202509220926 author: fyb @@ -382,4 +387,4 @@ databaseChangeLog: changes: - sqlFile: encoding: utf8 - path: classpath:db/changelog/202509220958.sql \ No newline at end of file + path: classpath:db/changelog/202509220958.sql diff --git a/main/manager-api/src/main/resources/i18n/messages_en_US.properties b/main/manager-api/src/main/resources/i18n/messages_en_US.properties index 2b66a9fe..a566cab3 100644 --- a/main/manager-api/src/main/resources/i18n/messages_en_US.properties +++ b/main/manager-api/src/main/resources/i18n/messages_en_US.properties @@ -135,4 +135,6 @@ 10124=Your MQTT secret is not secure, MQTT secret must contain both uppercase and lowercase letters 10125=Your MQTT secret contains weak password 10128=Dictionary label is duplicated -10129=modelType and provideCode cannot be empty \ No newline at end of file +10129=SM2 key not configured +10130=SM2 decryption failed +10131=modelType and provideCode cannot be empty \ No newline at end of file diff --git a/main/manager-api/src/main/resources/i18n/messages_zh_CN.properties b/main/manager-api/src/main/resources/i18n/messages_zh_CN.properties index 63c9403d..119326d0 100644 --- a/main/manager-api/src/main/resources/i18n/messages_zh_CN.properties +++ b/main/manager-api/src/main/resources/i18n/messages_zh_CN.properties @@ -135,4 +135,6 @@ 10124=您的mqtt密钥长度不安全,mqtt密钥必须同时包含大小写字母 10125=您的mqtt密钥包含弱密码 10128=字典标签重复 -10129=modelType和provideCode不能为空 \ No newline at end of file +10129=SM2密钥未配置 +10130=SM2解密失败 +10131=modelType和provideCode不能为空 \ No newline at end of file diff --git a/main/manager-api/src/main/resources/i18n/messages_zh_TW.properties b/main/manager-api/src/main/resources/i18n/messages_zh_TW.properties index eed81085..5ad7bb6f 100644 --- a/main/manager-api/src/main/resources/i18n/messages_zh_TW.properties +++ b/main/manager-api/src/main/resources/i18n/messages_zh_TW.properties @@ -135,4 +135,6 @@ 10124=您的MQTT密鑰不安全,MQTT密鑰必須同時包含大小寫字母 10125=您的MQTT密鑰包含弱密碼 10128=字典標籤重複 -10129=modelType和provideCode不能為空 \ No newline at end of file +10129=SM2密鑰未配置 +10130=SM2解密失敗 +10131=modelType和provideCode不能為空 \ No newline at end of file diff --git a/main/manager-api/src/test/java/xiaozhi/modules/sys/loginControllerTest.java b/main/manager-api/src/test/java/xiaozhi/modules/sys/loginControllerTest.java index 5cc39967..1175223b 100644 --- a/main/manager-api/src/test/java/xiaozhi/modules/sys/loginControllerTest.java +++ b/main/manager-api/src/test/java/xiaozhi/modules/sys/loginControllerTest.java @@ -24,7 +24,6 @@ class loginControllerTest { LoginDTO loginDTO = new LoginDTO(); loginDTO.setUsername("手机号码"); loginDTO.setPassword("密码"); - loginDTO.setCaptcha("123456"); loginController.register(loginDTO); } diff --git a/main/manager-mobile/package.json b/main/manager-mobile/package.json index 62fae5f0..9c4ed55f 100644 --- a/main/manager-mobile/package.json +++ b/main/manager-mobile/package.json @@ -100,6 +100,7 @@ "js-cookie": "^3.0.5", "pinia": "2.0.36", "pinia-plugin-persistedstate": "3.2.1", + "sm-crypto": "^0.3.13", "vue": "^3.4.21", "wot-design-uni": "^1.9.1", "z-paging": "2.8.7" diff --git a/main/manager-mobile/src/App.vue b/main/manager-mobile/src/App.vue index a9faa6e7..d31d32d7 100644 --- a/main/manager-mobile/src/App.vue +++ b/main/manager-mobile/src/App.vue @@ -1,12 +1,16 @@ @@ -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..d51ddb91 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,14 @@ 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: '取消', + inputError: t('home.createError'), + confirmButtonText: t('home.createNow'), + cancelButtonText: t('common.cancel'), }) .then(async (result: any) => { if (result.value && String(result.value).trim()) { @@ -144,12 +146,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 +161,20 @@ onShow(() => { pagingRef.value.reload() } }) + +// 在组件挂载后设置导航栏标题 +import { onMounted } from 'vue' +onMounted(() => { + uni.setNavigationBarTitle({ + title: t('home.pageTitle') + }) +}) @@ -260,10 +267,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..0a87707e 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,11 @@ 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' +// 导入SM2加密工具 +import { sm2Encrypt } from '@/utils' // 获取屏幕边界到安全区域距离 let safeAreaInsets @@ -39,7 +44,7 @@ systemInfo = uni.getSystemInfoSync() safeAreaInsets = systemInfo.safeAreaInsets // #endif // 表单数据 -const formData = ref({ +const formData = ref({ username: '', password: '', captcha: '', @@ -73,6 +78,11 @@ const areaCodeList = computed(() => { return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }] }) +// SM2公钥 +const sm2PublicKey = computed(() => { + return configStore.config.sm2PublicKey +}) + // 切换登录方式 function toggleLoginType() { loginType.value = loginType.value === 'username' ? 'mobile' : 'username' @@ -136,40 +146,64 @@ 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 + } + + // 检查SM2公钥是否配置 + if (!sm2PublicKey.value) { + toast.warning(t('sm2.publicKeyNotConfigured')) return } try { loading.value = true + // 加密密码 + let encryptedPassword + try { + // 拼接验证码和密码 + const captchaAndPassword = formData.value.captcha + formData.value.password + encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword) + } catch (error) { + console.error('密码加密失败:', error) + toast.warning(t('sm2.encryptionFailed')) + return + } + // 构建登录数据 - const loginData = { ...formData.value } + const loginData: LoginData = { + username: '', + password: encryptedPassword, + captchaId: formData.value.captchaId + } // 如果是手机号登录,将区号+手机号拼接到username字段 if (loginType.value === 'mobile') { loginData.username = `${selectedAreaCode.value}${formData.value.mobile}` + } else { + loginData.username = formData.value.username } const response = await login(loginData) @@ -177,7 +211,7 @@ async function handleLogin() { uni.setStorageSync('token', response.token) uni.setStorageSync('expire', response.expire) - toast.success('登录成功') + toast.success(t('message.loginSuccess')) // 跳转到主页 setTimeout(() => { @@ -189,6 +223,14 @@ async function handleLogin() { catch (error: any) { // 登录失败重新获取验证码 refreshCaptcha() + // 处理验证码错误 - 从error.message中解析错误码 + if (error.message.includes('请求错误[10067]')) { + toast.warning(t('login.captchaError')) + } + // 处理账号或密码错误 + else if (error.message.includes('请求错误[10004]')) { + toast.warning(t('message.passwordError')) + } } finally { loading.value = false @@ -200,14 +242,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 +273,25 @@ onMounted(async () => { - - - + + + + + {{ t('login.selectLanguageTip') }} + + + + + + @@ -253,7 +311,7 @@ onMounted(async () => { v-model="formData.mobile" custom-class="styled-input" no-border - placeholder="请输入手机号码" + :placeholder="t('login.enterPhone')" type="number" :maxlength="11" /> @@ -270,7 +328,7 @@ onMounted(async () => { v-model="formData.username" custom-class="styled-input" no-border - placeholder="请输入用户名" + :placeholder="t('login.enterUsername')" /> @@ -282,7 +340,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 +354,7 @@ onMounted(async () => { v-model="formData.captcha" custom-class="styled-input" no-border - placeholder="请输入验证码" + :placeholder="t('login.enterCaptcha')" :maxlength="6" /> @@ -304,26 +362,19 @@ onMounted(async () => { - - - - 忘记密码? - - - - 还没有账户? + {{ t('login.noAccount') }} - 立即注册 + {{ t('login.registerNow') }} @@ -352,7 +403,7 @@ onMounted(async () => { @@ -386,11 +437,33 @@ onMounted(async () => { custom-class="confirm-btn" @click="closeAreaCodeSheet" > - 确认 + {{ t('login.confirm') }} + + + + + + + + {{ lang.name }} + + + + + @@ -498,7 +571,7 @@ onMounted(async () => { &.captcha-wrapper { .captcha-image { margin-left: 20rpx; - width: 120rpx; + width: 150rpx; height: 60rpx; border-radius: 8rpx; overflow: hidden; @@ -793,20 +866,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: 18rpx; + color: #FFFFFF; + } + + &:hover { + background: rgba(255, 255, 255, 0.25); + } +} + +// 服务端设置按钮 +.server-btn { + width: 48rpx; + height: 48rpx; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: rgba(255, 255, 255, 0.15); + border-radius: 24rpx; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2); &:active { transform: scale(0.95); @@ -814,11 +919,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..e70cf75c 100644 --- a/main/manager-mobile/src/pages/register/index.vue +++ b/main/manager-mobile/src/pages/register/index.vue @@ -14,6 +14,10 @@ import { register, sendSmsCode } from '@/api/auth' import { useConfigStore } from '@/store' import { getEnvBaseUrl } from '@/utils' import { toast } from '@/utils/toast' +// 导入国际化相关功能 +import { t, initI18n } from '@/i18n' +// 导入SM2加密工具 +import { sm2Encrypt } from '@/utils' // 获取屏幕边界到安全区域距离 let safeAreaInsets @@ -88,6 +92,11 @@ const areaCodeList = computed(() => { return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }] }) +// SM2公钥 +const sm2PublicKey = computed(() => { + return configStore.config.sm2PublicKey +}) + // 切换注册方式 function toggleRegisterType() { registerType.value = registerType.value === 'username' ? 'mobile' : 'username' @@ -134,18 +143,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 +166,7 @@ async function sendSmsVerification() { captchaId: formData.value.captchaId, }) - toast.success('验证码发送成功') + toast.success(t('register.captchaSendSuccess')) // 开始倒计时 smsCountdown.value = 60 @@ -169,6 +178,10 @@ async function sendSmsVerification() { }, 1000) } catch (error: any) { + // 处理验证码错误 - 从error.message中解析错误码 + if (error.message.includes('请求错误[10067]')) { + toast.warning(t('login.captchaError')) + } // 发送失败重新获取图形验证码 refreshCaptcha() } @@ -180,58 +193,76 @@ async function sendSmsVerification() { // 注册 async function handleRegister() { // 表单验证 - if (registerType.value === 'username') { - if (!formData.value.username) { - toast.warning('请输入用户名') - return - } - } - else { + if (enableMobileRegister.value) { + // 手机号注册验证 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 + } + } + else { + // 用户名注册验证 + if (!formData.value.username) { + toast.warning(t('register.enterUsername')) 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 + } + + // 检查SM2公钥是否配置 + if (!sm2PublicKey.value) { + toast.warning(t('sm2.publicKeyNotConfigured')) return } try { loading.value = true + // 加密密码 + let encryptedPassword + try { + // 拼接验证码和密码 + const captchaAndPassword = formData.value.captcha + formData.value.password + encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword) + } catch (error) { + console.error('密码加密失败:', error) + toast.warning(t('sm2.encryptionFailed')) + return + } + // 构建注册数据 const registerData = { - username: registerType.value === 'mobile' ? `${selectedAreaCode.value}${formData.value.mobile}` : formData.value.username, - password: formData.value.password, - confirmPassword: formData.value.confirmPassword, - captcha: formData.value.captcha, + username: enableMobileRegister.value ? `${selectedAreaCode.value}${formData.value.mobile}` : formData.value.username, + password: encryptedPassword, captchaId: formData.value.captchaId, areaCode: formData.value.areaCode, mobile: formData.value.mobile, @@ -239,14 +270,24 @@ async function handleRegister() { } await register(registerData) - toast.success('注册成功') + toast.success(t('message.registerSuccess')) // 跳转到登录页 setTimeout(() => { - uni.navigateBack() + uni.redirectTo({ + url: '/pages/login/index' + }) }, 1000) } catch (error: any) { + // 处理验证码错误 - 从error.message中解析错误码 + if (error.message.includes('请求错误[10067]')) { + toast.warning(t('login.captchaError')) + } + // 处理手机号码已注册错误 + else if (error.message.includes('请求错误[10070]')) { + toast.warning(t('message.phoneRegistered')) + } // 注册失败重新获取验证码 refreshCaptcha() } @@ -257,7 +298,9 @@ async function handleRegister() { // 返回登录 function goBack() { - uni.navigateBack() + uni.redirectTo({ + url: '/pages/login/index' + }) } // 页面加载时获取验证码 @@ -275,7 +318,11 @@ onMounted(async () => { console.error('获取配置失败:', error) } } + // 初始化国际化 + initI18n() }) + +