From b216ddaa61044ea014f675a6fe01231dc1f5a118 Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Thu, 4 Dec 2025 13:37:11 +0800 Subject: [PATCH] 1 --- Shared/KBAPI.h | 10 ++ keyBoard/Class/Login/M/KBUser.h | 11 ++- keyBoard/Class/Login/M/KBUser.m | 19 +++- keyBoard/Class/Login/VM/KBLoginVM.h | 8 ++ keyBoard/Class/Login/VM/KBLoginVM.m | 29 ++++++ keyBoard/Class/Me/VC/KBMyKeyBoardVC.m | 9 +- keyBoard/Class/Me/VC/KBPersonInfoVC.m | 106 +++++++++++++++++++++- keyBoard/Class/Me/VM/KBMyVM.h | 15 ++- keyBoard/Class/Me/VM/KBMyVM.m | 66 ++++++++++++++ keyBoard/Class/Network/KBNetworkManager.h | 22 +++++ keyBoard/Class/Network/KBNetworkManager.m | 92 +++++++++++++++++++ 11 files changed, 377 insertions(+), 10 deletions(-) diff --git a/Shared/KBAPI.h b/Shared/KBAPI.h index 29c5cc5..54402c6 100644 --- a/Shared/KBAPI.h +++ b/Shared/KBAPI.h @@ -18,11 +18,21 @@ // 兼容旧命名(如有使用 API_APPLE_LOGIN 的位置,映射到统一命名) #define API_APPLE_LOGIN @"/user/appleLogin" // Apple 登录 +#define API_EMAIL_LOGIN @"/user/login" // email 登录 #define API_LOGOUT @"/user/logout" // 退出登录 + +#define API_UPDATA_INFO @"/user/updateInfo" // 更新用户 + #define KB_API_USER_DETAIL @"/user/detail" // 用户详情 #define KB_API_CHARACTER_LIST @"/character/list" // 排行榜角色列表(综合) #define KB_API_CHARACTER_LIST_BY_TAG @"/character/listByTag" // 根据 tagId 获取角色列表 #define KB_API_TAG_LIST @"/tag/list" // 排行榜标签列表 +#define KB_API_FILE_UPLOAD @"/file/upload" // 上传头像 +#define KB_API_CHARACTER_DETAIL @"/character/detail" // 人设详情 +#define KB_API_CHARACTER_LISTBYUSER @"/character/listByUser" // 人设详情 + + + // 应用配置 #ifndef KB_API_APP_CONFIG diff --git a/keyBoard/Class/Login/M/KBUser.h b/keyBoard/Class/Login/M/KBUser.h index edabd39..f76cc93 100644 --- a/keyBoard/Class/Login/M/KBUser.h +++ b/keyBoard/Class/Login/M/KBUser.h @@ -6,7 +6,11 @@ #import NS_ASSUME_NONNULL_BEGIN - +typedef NS_ENUM(NSInteger, UserSex) { + UserSexMan = 0, // 男 + UserSexWeman = 1, // 女 + UserSexTwoSex = 2, // 两性 +}; @interface KBUser : NSObject // 标识 @@ -14,12 +18,13 @@ NS_ASSUME_NONNULL_BEGIN // 基本信息 @property (nonatomic, copy, nullable) NSString *nickName; @property (nonatomic, copy, nullable) NSString *avatarUrl; // 头像 URL -@property (nonatomic, copy, nullable) NSString *gender; // 性别(后端可能返回 string/int,统一转字符串存) +/// 0 +@property (nonatomic, assign) UserSex gender; // 性别(后端可能返回 string/int,统一转字符串存) @property (nonatomic, copy, nullable) NSString *email; /// 邮箱是否验证 @property (nonatomic, assign) BOOL emailVerified; -// 会话信息 +// token @property (nonatomic, copy, nullable) NSString *token; // token/access_token/accessToken @end diff --git a/keyBoard/Class/Login/M/KBUser.m b/keyBoard/Class/Login/M/KBUser.m index ece90a4..18450e4 100644 --- a/keyBoard/Class/Login/M/KBUser.m +++ b/keyBoard/Class/Login/M/KBUser.m @@ -10,9 +10,26 @@ + (NSDictionary *)mj_replacedKeyFromPropertyName { return @{ @"userId": @[@"uid"], - @"gender": @[@"gender", @"sex"], }; } + ++ (id)mj_newValueFromOldValue:(id)oldValue property:(MJProperty *)property { + if ([property.name isEqualToString:@"gender"]) { + if ([oldValue isKindOfClass:[NSNumber class]]) { + NSInteger intValue = [(NSNumber *)oldValue integerValue]; + if (intValue >= UserSexTwoSex && intValue <= UserSexMan) { + return @(intValue); + } else { + // 如果收到非法值,可以返回默认值 + KBLOG(@"⚠️ 收到非法的userStatus值: %ld", (long)intValue); + return @(UserSexMan); + } + } + return @(UserSexMan); + } + return oldValue; +} + @end diff --git a/keyBoard/Class/Login/VM/KBLoginVM.h b/keyBoard/Class/Login/VM/KBLoginVM.h index 345234e..4846abd 100644 --- a/keyBoard/Class/Login/VM/KBLoginVM.h +++ b/keyBoard/Class/Login/VM/KBLoginVM.h @@ -24,6 +24,14 @@ typedef void(^KBLoginCompletion)(BOOL success, NSError * _Nullable error); - (void)signInWithAppleFromViewController:(UIViewController *)presenter completion:(KBLoginCompletion)completion; + +/// 邮箱登录 +- (void)signInWithAppleFromViewController:(UIViewController *)presenter + completion:(KBLoginCompletion)completion; + +/// 邮箱登录 +- (void)emailLoginEmail:(NSString *)email password:(NSString *)password WithCompletion:(KBLoginCompletion)completion; + /// 是否已登录:由 KBAuthManager 判断(是否存在有效 token) - (BOOL)isLoggedIn; diff --git a/keyBoard/Class/Login/VM/KBLoginVM.m b/keyBoard/Class/Login/VM/KBLoginVM.m index 2d0ec2b..56216d3 100644 --- a/keyBoard/Class/Login/VM/KBLoginVM.m +++ b/keyBoard/Class/Login/VM/KBLoginVM.m @@ -70,6 +70,35 @@ }]; } +/// 邮箱登录 +- (void)emailLoginEmail:(NSString *)email password:(NSString *)password WithCompletion:(KBLoginCompletion)completion; +{ + [KBHUD show]; + NSMutableDictionary *params = [NSMutableDictionary dictionary]; + if (email.length) params[@"email"] = email; + if (password.length) params[@"password"] = password; + // 向服务端发起校验 + [[KBNetworkManager shared] POST:API_EMAIL_LOGIN jsonBody:params headers:nil completion:^(NSDictionary * _Nullable jsonOrData, NSURLResponse * _Nullable response, NSError * _Nullable error) { + [KBHUD dismiss]; + if (error) { if (completion) completion(NO, error); return; } + NSDictionary *dict = jsonOrData[@"data"]; + KBUser *user = [KBUser mj_objectWithKeyValues:dict]; + self.currentUser = user; + if (user.token.length == 0) { + if (completion) completion(NO, [NSError errorWithDomain:@"KBLogin" code:-2 userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"No token returned")}]); + return; + } + [[KBUserSessionManager shared] handleLoginSuccessWithUser:user]; + completion(true,nil); + // 保存登录态到共享钥匙串;供 App 与扩展共享 +// BOOL ok = [[KBAuthManager shared] saveAccessToken:user.token +// refreshToken:nil +// expiryDate:nil +// userIdentifier:cred.user]; +// if (completion) completion(ok, ok ? nil : [NSError errorWithDomain:@"KBLogin" code:-3 userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Failed to save login state")}]); + }]; +} + #pragma mark - Helpers // 宽松解析:token / access_token / accessToken,支持顶层或 data/user 层 diff --git a/keyBoard/Class/Me/VC/KBMyKeyBoardVC.m b/keyBoard/Class/Me/VC/KBMyKeyBoardVC.m index 02acabd..ec1c2de 100644 --- a/keyBoard/Class/Me/VC/KBMyKeyBoardVC.m +++ b/keyBoard/Class/Me/VC/KBMyKeyBoardVC.m @@ -10,6 +10,7 @@ #import "UICollectionViewLeftAlignedLayout.h" #import "KBMyKeyboardCell.h" #import "KBAlert.h" +#import "KBMyVM.h" /// 复用标识 static NSString * const kKBMyKeyboardCellId = @"kKBMyKeyboardCellId"; @@ -29,6 +30,7 @@ static NSString * const kKBMyKeyboardCellId = @"kKBMyKeyboardCellId"; // 数据源(必须是二维数组,库内部会在拖动时直接调整顺序) @property (nonatomic, strong) NSMutableArray *> *dataSourceArray; // {emoji,title} +@property (nonatomic, strong) KBMyVM *viewModel; // 我的页面 VM @end @@ -40,6 +42,7 @@ static NSString * const kKBMyKeyboardCellId = @"kKBMyKeyboardCellId"; - (void)viewDidLoad { [super viewDidLoad]; + self.viewModel = [[KBMyVM alloc] init]; self.view.backgroundColor = [UIColor colorWithHex:0xF6F8F9]; self.kb_navView.backgroundColor = [UIColor clearColor]; self.kb_titleLabel.text = @"My KeyBoard"; @@ -71,7 +74,11 @@ static NSString * const kKBMyKeyboardCellId = @"kKBMyKeyboardCellId"; }]; // 初始数据 - [self buildDefaultData]; +// [self buildDefaultData]; + __weak typeof(self) weakSelf = self; + [self.viewModel fetchCharacterListByUserWithCompletion:^(NSArray * _Nonnull characterArray, NSError * _Nullable error) { + + }]; } - (void)viewWillAppear:(BOOL)animated { diff --git a/keyBoard/Class/Me/VC/KBPersonInfoVC.m b/keyBoard/Class/Me/VC/KBPersonInfoVC.m index e2ef373..5e54e51 100644 --- a/keyBoard/Class/Me/VC/KBPersonInfoVC.m +++ b/keyBoard/Class/Me/VC/KBPersonInfoVC.m @@ -326,17 +326,26 @@ - (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray *)results API_AVAILABLE(ios(14.0)) { [picker dismissViewControllerAnimated:YES completion:nil]; - PHPickerResult *first = results.firstObject; if (!first) return; + + PHPickerResult *first = results.firstObject; + if (!first) return; + NSItemProvider *p = first.itemProvider; if ([p canLoadObjectOfClass:UIImage.class]) { __weak typeof(self) weakSelf = self; [p loadObjectOfClass:UIImage.class completionHandler:^(__kindof id _Nullable object, NSError * _Nullable error) { UIImage *img = ([object isKindOfClass:UIImage.class] ? (UIImage *)object : nil); if (!img) return; + + // 这里就在子线程里压缩,避免卡 UI + // 比如目标 100KB,你自己改成想要的值 + NSUInteger targetKB = 50; + NSData *compressedData = [weakSelf kb_compressImage:img targetKB:targetKB]; + if (!compressedData) return; + UIImage *compressedImage = [UIImage imageWithData:compressedData]; dispatch_async(dispatch_get_main_queue(), ^{ - UIImage *compressed = [weakSelf kb_compressImage:img maxPixel:512 quality:0.85]; - weakSelf.avatarView.image = compressed; - weakSelf.avatarJPEGData = UIImageJPEGRepresentation(compressed, 0.85); + + }); }]; } @@ -378,6 +387,95 @@ UIImage *result = [UIImage imageWithData:jpeg] ?: scaled ?: image; return result; } +/// 按目标大小(KB)压缩图片: +/// 1. 先逐步降低 JPEG 质量; +/// 2. 如果质量已经降到下限仍然太大,再按比例缩小尺寸,并重复尝试。 +- (nullable NSData *)kb_compressImage:(UIImage *)image targetKB:(NSUInteger)targetKB { + if (!image || targetKB == 0) { return nil; } + + NSUInteger maxBytes = targetKB * 1024; + + // 初始质量参数 + CGFloat compression = 0.9f; + CGFloat minCompression = 0.1f; // 不建议再低了,太低会糊 + NSData *data = UIImageJPEGRepresentation(image, compression); + if (!data) return nil; + + // 如果一开始就小于目标大小,直接返回 + if (data.length <= maxBytes) { + return data; + } + + // 1) 先通过降低质量来压缩 + while (data.length > maxBytes && compression > minCompression + 0.01f) { + compression -= 0.1f; + data = UIImageJPEGRepresentation(image, compression); + if (!data) return nil; + } + + if (data.length <= maxBytes) { + return data; + } + + // 2) 质量降到下限还是太大 -> 等比缩放尺寸 + UIImage *currentImage = image; + + // 防止死循环,限定最多缩放几次 + NSInteger maxResizeCount = 6; + NSInteger resizeCount = 0; + + while (data.length > maxBytes && resizeCount < maxResizeCount) { + resizeCount++; + + // 按面积比例来算缩放因子:新的面积约等于 (maxBytes / 当前字节数) * 原面积 + CGFloat ratio = (CGFloat)maxBytes / (CGFloat)data.length; + // 为了更激进一点,可以乘个经验系数,比如 0.8 + ratio *= 0.8f; + if (ratio <= 0.0f) { + ratio = 0.5f; // 兜底,至少缩小一半 + } + + CGFloat scale = sqrt(ratio); + if (scale >= 1.0f) { + scale = 0.5f; // 理论上不会走到这里,兜底 + } + + CGSize newSize = CGSizeMake(currentImage.size.width * scale, + currentImage.size.height * scale); + if (newSize.width < 10 || newSize.height < 10) { + // 太小就没意义了,直接 break,返回目前能做到的最小值 + break; + } + + // 绘制缩小后的图片 + UIGraphicsBeginImageContextWithOptions(newSize, NO, currentImage.scale); + [currentImage drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; + UIImage *resizedImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + if (!resizedImage) { + break; + } + + currentImage = resizedImage; + + // 每次尺寸缩小之后,再重新从稍高一点的质量开始压一轮 + compression = 0.9f; + data = UIImageJPEGRepresentation(currentImage, compression); + if (!data) return nil; + + // 再次在质量范围内往下压 + while (data.length > maxBytes && compression > minCompression + 0.01f) { + compression -= 0.1f; + data = UIImageJPEGRepresentation(currentImage, compression); + if (!data) return nil; + } + } + + // 最终返回当前能做到的最小数据(可能略大于目标,但已经尽力) + return data; +} + - (KBMyVM *)myVM{ if (!_myVM) { diff --git a/keyBoard/Class/Me/VM/KBMyVM.h b/keyBoard/Class/Me/VM/KBMyVM.h index 9b17cb2..cf1a5c1 100644 --- a/keyBoard/Class/Me/VM/KBMyVM.h +++ b/keyBoard/Class/Me/VM/KBMyVM.h @@ -6,18 +6,31 @@ // #import - +#import "KBCharacter.h" @class KBUser; NS_ASSUME_NONNULL_BEGIN typedef void(^KBMyUserDetailCompletion)(KBUser *_Nullable user, NSError *_Nullable error); +typedef void(^KBCharacterListCompletion)(NSArray *characterArray, NSError *_Nullable error); +typedef void(^KBUpLoadAvatarCompletion)(BOOL success, NSError * _Nullable error); +typedef void(^KBUpdateUserInfoCompletion)(BOOL success, NSError * _Nullable error); @interface KBMyVM : NSObject /// 获取当前用户详情(/user/detail) - (void)fetchUserDetailWithCompletion:(KBMyUserDetailCompletion)completion; +/// 用户人设列表(/character/listByUser) +- (void)fetchCharacterListByUserWithCompletion:(KBCharacterListCompletion)completion; + +/// 上传头像 +- (void)upLoadAvatarWithData:(NSData *)avatarData completion:(KBUpLoadAvatarCompletion)completion; + +/// 更新用户信息 +- (void)updateUserInfo:(KBUser *)user completion:(KBUpdateUserInfoCompletion)completion; + + /// 退出登录 - (void)logout; @end diff --git a/keyBoard/Class/Me/VM/KBMyVM.m b/keyBoard/Class/Me/VM/KBMyVM.m index 79f01c7..ce65a77 100644 --- a/keyBoard/Class/Me/VM/KBMyVM.m +++ b/keyBoard/Class/Me/VM/KBMyVM.m @@ -42,6 +42,72 @@ if (completion) completion(user, nil); }]; } + +- (void)fetchCharacterListByUserWithCompletion:(KBCharacterListCompletion)completion{ + [[KBNetworkManager shared] GET:KB_API_CHARACTER_LISTBYUSER + parameters:nil + headers:nil + autoShowBusinessError:NO + completion:^(NSDictionary *jsonOrData, NSURLResponse * _Nullable response, NSError * _Nullable error) { + [KBHUD dismiss]; + + if (error) { + NSString *msg = KBBizMessageFromJSONObject(jsonOrData) ?: error.localizedDescription ?: KBLocalized(@"Network error"); + [KBHUD showInfo:msg]; + if (completion) completion([NSArray new], error); + return; + } + + id dataObj = jsonOrData[KBData] ?: jsonOrData[@"data"]; + if (![dataObj isKindOfClass:[NSArray class]]) { + NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain + code:KBNetworkErrorInvalidResponse + userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid response")}]; + [KBHUD showInfo:e.localizedDescription]; + if (completion) completion([NSArray new], e); + return; + } + NSArray *list = [KBCharacter mj_objectArrayWithKeyValuesArray:(NSArray *)dataObj]; + if (completion) completion(list, nil); + }]; +} + +/// 上传头像 +- (void)upLoadAvatarWithData:(NSData *)avatarData completion:(KBUpLoadAvatarCompletion)completion{ + [KBHUD show]; + [[KBNetworkManager shared] uploadFile:KB_API_FILE_UPLOAD + fileData:avatarData + fileName:@"avatar.jpg" + mimeType:@"image/jpeg" + headers:nil + completion:^(NSDictionary * _Nullable json, + NSURLResponse * _Nullable response, + NSError * _Nullable error) { + [KBHUD dismiss]; + if (error) { + NSLog(@"上传失败: %@", error); + return; + } + NSString *avImageString = json[@"data"]; +// [weakSelf.avatarView kb_setImageURL:[NSURL URLWithString:avImageString] placeholder:KBPlaceholderImage]; + + NSLog(@"上传成功: %@", json); + }]; +} + + +/// 更新用户信息 +- (void)updateUserInfo:(KBUser *)user completion:(KBUpdateUserInfoCompletion)completion{ + /// 获取用户信息 + KBUser *localUser = [KBUserSessionManager shared].currentUser; + NSMutableDictionary *params = [NSMutableDictionary dictionary]; + if (localUser.userId.length) params[@"uid"] = localUser.userId; + if (localUser.nickName.length) params[@"nickName"] = localUser.nickName; + params[@"gender"] = (NSInteger)localUser.gender; + if (localUser.avatarUrl.length) params[@"avatarUrl"] = localUser.avatarUrl; + +} + - (void)logout{ [KBHUD show]; [[KBNetworkManager shared] GET:API_LOGOUT diff --git a/keyBoard/Class/Network/KBNetworkManager.h b/keyBoard/Class/Network/KBNetworkManager.h index 5bd617f..e1b93de 100644 --- a/keyBoard/Class/Network/KBNetworkManager.h +++ b/keyBoard/Class/Network/KBNetworkManager.h @@ -79,6 +79,28 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data, headers:(nullable NSDictionary *)headers completion:(KBNetworkCompletion)completion; +/// 上传文件(multipart/form-data,表单字段名固定为 "file") +/// path: 例如 @"file/upload" 或 @"/file/upload" +/// fileData: 文件二进制数据(比如 UIImageJPEGRepresentation) +/// fileName: 例如 @"avatar.jpg" +/// mimeType: 例如 @"image/jpeg" +- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path + fileData:(NSData *)fileData + fileName:(NSString *)fileName + mimeType:(NSString *)mimeType + headers:(nullable NSDictionary *)headers + autoShowBusinessError:(BOOL)autoShowBusinessError + completion:(KBNetworkCompletion)completion; + +/// 便捷版本:默认 autoShowBusinessError = YES +- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path + fileData:(NSData *)fileData + fileName:(NSString *)fileName + mimeType:(NSString *)mimeType + headers:(nullable NSDictionary *)headers + completion:(KBNetworkCompletion)completion; + + @end NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/Network/KBNetworkManager.m b/keyBoard/Class/Network/KBNetworkManager.m index b2e4148..d832da3 100644 --- a/keyBoard/Class/Network/KBNetworkManager.m +++ b/keyBoard/Class/Network/KBNetworkManager.m @@ -186,6 +186,98 @@ autoShowBusinessError:YES return [self startDataTaskWithRequest:req completion:completion]; } +#pragma mark - Upload (multipart/form-data) + +- (NSURLSessionDataTask *)uploadFile:(NSString *)path + fileData:(NSData *)fileData + fileName:(NSString *)fileName + mimeType:(NSString *)mimeType + headers:(NSDictionary *)headers + autoShowBusinessError:(BOOL)autoShowBusinessError + completion:(KBNetworkCompletion)completion +{ + NSLog(@"[KBNetworkManager] UPLOAD called, enabled=%d, path=%@", self.isEnabled, path); + if (![self ensureEnabled:completion]) return nil; + + NSString *urlString = [self buildURLStringWithPath:path]; + if (!urlString) { + [self fail:KBNetworkErrorInvalidURL completion:completion]; + return nil; + } + + if (!fileData || fileData.length == 0) { + NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain + code:KBNetworkErrorInvalidResponse + userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Empty file data")}]; + if (completion) completion(nil, nil, e); + return nil; + } + + AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer]; + serializer.timeoutInterval = self.timeout; + + NSError *error = nil; + NSMutableURLRequest *req = + [serializer multipartFormRequestWithMethod:@"POST" + URLString:urlString + parameters:nil + constructingBodyWithBlock:^(id _Nonnull formData) { + + // Apifox 后端要求:表单字段名为 "file" + NSString *fieldName = @"file"; + NSString *fname = fileName ?: @"file"; + NSString *type = mimeType ?: @"application/octet-stream"; + + [formData appendPartWithFileData:fileData + name:fieldName + fileName:fname + mimeType:type]; + } error:&error]; + + if (error || !req) { + if (completion) { + completion(nil, nil, + error ?: [NSError errorWithDomain:KBNetworkErrorDomain + code:KBNetworkErrorInvalidURL + userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid upload request")}]); + } + return nil; + } + + // 不强行指定 Content-Type,留给 AFN 里带 boundary 的那一套 + [self applyHeaders:headers toMutableRequest:req contentType:nil]; + +#if DEBUG + KBLOG(@"HTTP UPLOAD (multipart)\nURL: %@\nHeaders: %@\nfileName: %@\nlength: %lu", + req.URL.absoluteString, + req.allHTTPHeaderFields ?: @{}, + fileName, + (unsigned long)fileData.length); +#endif + + // 按 JSON 响应处理(和 POST 一样:解析 {code,message,data,...}) + return [self startJSONTaskWithRequest:req + autoShowBusinessError:autoShowBusinessError + completion:completion]; +} + +- (NSURLSessionDataTask *)uploadFile:(NSString *)path + fileData:(NSData *)fileData + fileName:(NSString *)fileName + mimeType:(NSString *)mimeType + headers:(NSDictionary *)headers + completion:(KBNetworkCompletion)completion +{ + return [self uploadFile:path + fileData:fileData + fileName:fileName + mimeType:mimeType + headers:headers + autoShowBusinessError:YES + completion:completion]; +} + + #pragma mark - Core - (BOOL)ensureEnabled:(KBNetworkCompletion)completion {