新增接口,界面

This commit is contained in:
2026-01-26 16:53:41 +08:00
parent 0fa31418f6
commit f9d7579536
13 changed files with 1113 additions and 1 deletions

View File

@@ -0,0 +1,96 @@
//
// KBPersonaModel.h
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// 人设状态
typedef NS_ENUM(NSInteger, KBPersonaStatus) {
KBPersonaStatusDisabled = 0, // 禁用
KBPersonaStatusEnabled = 1 // 启用
};
/// 人设可见性
typedef NS_ENUM(NSInteger, KBPersonaVisibility) {
KBPersonaVisibilityPrivate = 0, // 私有
KBPersonaVisibilityPublic = 1 // 公开
};
/// 人设模型
@interface KBPersonaModel : NSObject
/// 人设 ID
@property (nonatomic, assign) NSInteger personaId;
/// 人设名称
@property (nonatomic, copy) NSString *name;
/// 头像 URL
@property (nonatomic, copy) NSString *avatarUrl;
/// 封面图 URL
@property (nonatomic, copy) NSString *coverImageUrl;
/// 性别
@property (nonatomic, copy) NSString *gender;
/// 年龄范围
@property (nonatomic, copy) NSString *ageRange;
/// 简短描述
@property (nonatomic, copy) NSString *shortDesc;
/// 介绍文本
@property (nonatomic, copy) NSString *introText;
/// 性格标签(逗号分隔的字符串)
@property (nonatomic, copy) NSString *personalityTags;
/// 说话风格
@property (nonatomic, copy) NSString *speakingStyle;
/// 系统提示词
@property (nonatomic, copy) NSString *systemPrompt;
/// 状态0-禁用1-启用)
@property (nonatomic, assign) KBPersonaStatus status;
/// 可见性0-私有1-公开)
@property (nonatomic, assign) KBPersonaVisibility visibility;
/// 排序顺序
@property (nonatomic, assign) NSInteger sortOrder;
/// 热度分数
@property (nonatomic, assign) NSInteger popularityScore;
/// 创建时间
@property (nonatomic, copy) NSString *createdAt;
/// 更新时间
@property (nonatomic, copy) NSString *updatedAt;
#pragma mark - 扩展属性
/// 性格标签数组(从 personalityTags 解析)
@property (nonatomic, strong, readonly) NSArray<NSString *> *tagsArray;
/// 是否启用
@property (nonatomic, assign, readonly) BOOL isEnabled;
/// 是否公开
@property (nonatomic, assign, readonly) BOOL isPublic;
/// 开场白
@property (nonatomic, copy) NSString *prologue;
/// 开场白URL
@property (nonatomic, copy) NSString *prologueAudio;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,68 @@
//
// KBPersonaModel.m
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import "KBPersonaModel.h"
#import <MJExtension/MJExtension.h>
@implementation KBPersonaModel
#pragma mark - MJExtension
//
+ (NSDictionary *)mj_replacedKeyFromPropertyName {
return @{
@"personaId": @"id"
};
}
//
- (void)mj_keyValuesDidFinishConvertingToObject {
// null
if (!self.name) self.name = @"";
if (!self.avatarUrl) self.avatarUrl = @"";
if (!self.coverImageUrl) self.coverImageUrl = @"";
if (!self.gender) self.gender = @"";
if (!self.ageRange) self.ageRange = @"";
if (!self.shortDesc) self.shortDesc = @"";
if (!self.introText) self.introText = @"";
if (!self.personalityTags) self.personalityTags = @"";
if (!self.speakingStyle) self.speakingStyle = @"";
if (!self.systemPrompt) self.systemPrompt = @"";
if (!self.createdAt) self.createdAt = @"";
if (!self.updatedAt) self.updatedAt = @"";
}
#pragma mark -
- (NSArray<NSString *> *)tagsArray {
if (self.personalityTags.length == 0) {
return @[];
}
//
NSString *trimmed = [self.personalityTags stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
NSArray *tags = [trimmed componentsSeparatedByString:@","];
//
NSMutableArray *result = [NSMutableArray array];
for (NSString *tag in tags) {
NSString *trimmedTag = [tag stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if (trimmedTag.length > 0) {
[result addObject:trimmedTag];
}
}
return result;
}
- (BOOL)isEnabled {
return self.status == KBPersonaStatusEnabled;
}
- (BOOL)isPublic {
return self.visibility == KBPersonaVisibilityPublic;
}
@end

View File

@@ -0,0 +1,45 @@
//
// KBPersonaPageModel.h
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import <Foundation/Foundation.h>
#import "KBPersonaModel.h"
NS_ASSUME_NONNULL_BEGIN
/// 排序规则
@interface KBOrderRule : NSObject
@property (nonatomic, copy) NSString *column; // 排序字段
@property (nonatomic, assign) BOOL asc; // 是否升序
@end
/// 分页数据模型
@interface KBPersonaPageModel : NSObject
/// 人设列表
@property (nonatomic, strong) NSArray<KBPersonaModel *> *records;
/// 总记录数
@property (nonatomic, assign) NSInteger total;
/// 每页大小
@property (nonatomic, assign) NSInteger size;
/// 当前页码
@property (nonatomic, assign) NSInteger current;
/// 排序规则
@property (nonatomic, strong, nullable) NSArray<KBOrderRule *> *orders;
/// 总页数
@property (nonatomic, assign) NSInteger pages;
/// 是否还有更多数据
@property (nonatomic, assign, readonly) BOOL hasMore;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,39 @@
//
// KBPersonaPageModel.m
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import "KBPersonaPageModel.h"
#import <MJExtension/MJExtension.h>
@implementation KBOrderRule
@end
@implementation KBPersonaPageModel
#pragma mark - MJExtension
//
+ (NSDictionary *)mj_objectClassInArray {
return @{
@"records": [KBPersonaModel class],
@"orders": [KBOrderRule class]
};
}
//
- (void)mj_keyValuesDidFinishConvertingToObject {
if (!self.records) {
self.records = @[];
}
}
#pragma mark -
- (BOOL)hasMore {
return self.current < self.pages;
}
@end

View File

@@ -0,0 +1,24 @@
//
// KBPersonaChatCell.h
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import <UIKit/UIKit.h>
#import "KBPersonaModel.h"
NS_ASSUME_NONNULL_BEGIN
/// 人设聊天 Cell
@interface KBPersonaChatCell : UICollectionViewCell
/// 人设数据
@property (nonatomic, strong) KBPersonaModel *persona;
/// 预加载数据
- (void)preloadDataIfNeeded;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,224 @@
//
// KBPersonaChatCell.m
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import "KBPersonaChatCell.h"
#import "KBChatTableView.h"
#import "KBAiChatMessage.h"
#import <Masonry/Masonry.h>
#import <SDWebImage/SDWebImage.h>
@interface KBPersonaChatCell () <UITableViewDelegate, UITableViewDataSource>
///
@property (nonatomic, strong) UIImageView *backgroundImageView;
///
@property (nonatomic, strong) UIImageView *avatarImageView;
///
@property (nonatomic, strong) UILabel *nameLabel;
///
@property (nonatomic, strong) UILabel *openingLabel;
///
@property (nonatomic, strong) UITableView *tableView;
///
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
///
@property (nonatomic, assign) BOOL hasLoadedData;
@end
@implementation KBPersonaChatCell
#pragma mark - Lifecycle
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self setupUI];
}
return self;
}
#pragma mark - 1
- (void)setupUI {
//
[self.contentView addSubview:self.backgroundImageView];
[self.backgroundImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
//
UIView *maskView = [[UIView alloc] init];
maskView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.3];
[self.contentView addSubview:maskView];
[maskView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
//
[self.contentView addSubview:self.avatarImageView];
[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.contentView).offset(80);
make.centerX.equalTo(self.contentView);
make.size.mas_equalTo(CGSizeMake(80, 80));
}];
//
[self.contentView addSubview:self.nameLabel];
[self.nameLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.avatarImageView.mas_bottom).offset(12);
make.centerX.equalTo(self.contentView);
}];
//
[self.contentView addSubview:self.openingLabel];
[self.openingLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.nameLabel.mas_bottom).offset(8);
make.left.equalTo(self.contentView).offset(40);
make.right.equalTo(self.contentView).offset(-40);
}];
//
[self.contentView addSubview:self.tableView];
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.openingLabel.mas_bottom).offset(30);
make.left.right.bottom.equalTo(self.contentView);
}];
}
#pragma mark - Setter
- (void)setPersona:(KBPersonaModel *)persona {
_persona = persona;
//
self.hasLoadedData = NO;
self.messages = [NSMutableArray array];
// UI
[self.backgroundImageView sd_setImageWithURL:[NSURL URLWithString:persona.coverImageUrl]
placeholderImage:[UIImage imageNamed:@"placeholder_bg"]];
[self.avatarImageView sd_setImageWithURL:[NSURL URLWithString:persona.avatarUrl]
placeholderImage:[UIImage imageNamed:@"placeholder_avatar"]];
self.nameLabel.text = persona.name;
self.openingLabel.text = persona.shortDesc.length > 0 ? persona.shortDesc : persona.introText;
[self.tableView reloadData];
}
#pragma mark - 2
- (void)preloadDataIfNeeded {
if (self.hasLoadedData) {
return;
}
// TODO: chatId
//
self.hasLoadedData = YES;
if (self.persona.introText.length > 0) {
KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:self.persona.introText];
openingMsg.isComplete = YES;
[self.messages addObject:openingMsg];
[self.tableView reloadData];
}
}
#pragma mark - UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.messages.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"Cell"];
cell.backgroundColor = [UIColor clearColor];
cell.textLabel.textColor = [UIColor whiteColor];
cell.textLabel.numberOfLines = 0;
}
KBAiChatMessage *message = self.messages[indexPath.row];
cell.textLabel.text = message.text;
return cell;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return UITableViewAutomaticDimension;
}
#pragma mark - Lazy Load
- (UIImageView *)backgroundImageView {
if (!_backgroundImageView) {
_backgroundImageView = [[UIImageView alloc] init];
_backgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
_backgroundImageView.clipsToBounds = YES;
}
return _backgroundImageView;
}
- (UIImageView *)avatarImageView {
if (!_avatarImageView) {
_avatarImageView = [[UIImageView alloc] init];
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
_avatarImageView.layer.cornerRadius = 40;
_avatarImageView.layer.borderWidth = 3;
_avatarImageView.layer.borderColor = [UIColor whiteColor].CGColor;
_avatarImageView.clipsToBounds = YES;
}
return _avatarImageView;
}
- (UILabel *)nameLabel {
if (!_nameLabel) {
_nameLabel = [[UILabel alloc] init];
_nameLabel.font = [UIFont boldSystemFontOfSize:20];
_nameLabel.textColor = [UIColor whiteColor];
_nameLabel.textAlignment = NSTextAlignmentCenter;
}
return _nameLabel;
}
- (UILabel *)openingLabel {
if (!_openingLabel) {
_openingLabel = [[UILabel alloc] init];
_openingLabel.font = [UIFont systemFontOfSize:14];
_openingLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.9];
_openingLabel.textAlignment = NSTextAlignmentCenter;
_openingLabel.numberOfLines = 2;
}
return _openingLabel;
}
- (UITableView *)tableView {
if (!_tableView) {
_tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
_tableView.delegate = self;
_tableView.dataSource = self;
_tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
_tableView.backgroundColor = [UIColor clearColor];
_tableView.showsVerticalScrollIndicator = NO;
_tableView.estimatedRowHeight = 60;
_tableView.rowHeight = UITableViewAutomaticDimension;
if (@available(iOS 11.0, *)) {
_tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
}
}
return _tableView;
}
@end

View File

@@ -0,0 +1,16 @@
//
// KBAIHomeVC.h
// keyBoard
//
// Created by Mac on 2026/1/26.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface KBAIHomeVC : BaseViewController
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,244 @@
//
// KBAIHomeVC.m
// keyBoard
//
// Created by Mac on 2026/1/26.
//
#import "KBAIHomeVC.h"
#import "KBPersonaChatCell.h"
#import "KBPersonaModel.h"
#import "AiVM.h"
#import <Masonry/Masonry.h>
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource>
///
@property (nonatomic, strong) UICollectionView *collectionView;
///
@property (nonatomic, strong) NSMutableArray<KBPersonaModel *> *personas;
///
@property (nonatomic, assign) NSInteger currentPage;
///
@property (nonatomic, assign) BOOL hasMore;
///
@property (nonatomic, assign) BOOL isLoading;
///
@property (nonatomic, assign) NSInteger currentIndex;
///
@property (nonatomic, strong) NSMutableSet<NSNumber *> *preloadedIndexes;
/// AiVM
@property (nonatomic, strong) AiVM *aiVM;
@end
@implementation KBAIHomeVC
#pragma mark - Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
self.kb_navView.hidden = true;
//
self.personas = [NSMutableArray array];
self.currentPage = 1;
self.hasMore = YES;
self.isLoading = NO;
self.currentIndex = 0;
self.preloadedIndexes = [NSMutableSet set];
self.aiVM = [[AiVM alloc] init];
[self setupUI];
[self loadPersonas];
}
#pragma mark - 1
- (void)setupUI {
[self.view addSubview:self.collectionView];
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.view);
}];
}
#pragma mark - 2
- (void)loadPersonas {
if (self.isLoading) {
return;
}
self.isLoading = YES;
__weak typeof(self) weakSelf = self;
[self.aiVM fetchPersonasWithPageNum:self.currentPage
pageSize:10
completion:^(KBPersonaPageModel * _Nullable pageModel, NSError * _Nullable error) {
weakSelf.isLoading = NO;
if (error) {
NSLog(@"加载人设列表失败:%@", error.localizedDescription);
// TODO:
return;
}
if (!pageModel || !pageModel.records) {
NSLog(@"人设列表数据为空");
return;
}
//
[weakSelf.personas addObjectsFromArray:pageModel.records];
weakSelf.hasMore = pageModel.hasMore;
// UI
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf.collectionView reloadData];
// 3
if (weakSelf.currentPage == 1) {
[weakSelf preloadDataForIndexes:@[@0, @1, @2]];
}
});
NSLog(@"加载成功:当前 %ld 条,总共 %ld 条,还有更多:%@",
weakSelf.personas.count,
pageModel.total,
pageModel.hasMore ? @"是" : @"否");
}];
}
- (void)loadMorePersonas {
if (!self.hasMore || self.isLoading) {
return;
}
self.currentPage++;
[self loadPersonas];
}
#pragma mark - 3
- (void)preloadAdjacentCellsForIndex:(NSInteger)index {
if (index < 0 || index >= self.personas.count) {
return;
}
NSMutableArray *indexesToPreload = [NSMutableArray array];
//
if (index > 0) {
[indexesToPreload addObject:@(index - 1)];
}
//
[indexesToPreload addObject:@(index)];
//
if (index < self.personas.count - 1) {
[indexesToPreload addObject:@(index + 1)];
}
[self preloadDataForIndexes:indexesToPreload];
}
- (void)preloadDataForIndexes:(NSArray<NSNumber *> *)indexes {
for (NSNumber *indexNum in indexes) {
if ([self.preloadedIndexes containsObject:indexNum]) {
continue;
}
[self.preloadedIndexes addObject:indexNum];
NSInteger index = [indexNum integerValue];
if (index >= self.personas.count) {
continue;
}
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
if (cell) {
[cell preloadDataIfNeeded];
}
NSLog(@"预加载第 %ld 个人设", (long)index);
}
}
#pragma mark - UICollectionViewDataSource
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
return self.personas.count;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath];
cell.persona = self.personas[indexPath.item];
return cell;
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGFloat pageHeight = scrollView.bounds.size.height;
CGFloat offsetY = scrollView.contentOffset.y;
NSInteger currentPage = offsetY / pageHeight;
// 30%
if (fmod(offsetY, pageHeight) > pageHeight * 0.3) {
[self preloadAdjacentCellsForIndex:currentPage + 1];
} else {
[self preloadAdjacentCellsForIndex:currentPage];
}
//
if (offsetY + scrollView.bounds.size.height >= scrollView.contentSize.height - pageHeight) {
[self loadMorePersonas];
}
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
CGFloat pageHeight = scrollView.bounds.size.height;
NSInteger currentPage = scrollView.contentOffset.y / pageHeight;
self.currentIndex = currentPage;
if (currentPage < self.personas.count) {
NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name);
}
}
#pragma mark - Lazy Load
- (UICollectionView *)collectionView {
if (!_collectionView) {
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
layout.scrollDirection = UICollectionViewScrollDirectionVertical;
layout.minimumLineSpacing = 0;
layout.minimumInteritemSpacing = 0;
layout.itemSize = [UIScreen mainScreen].bounds.size;
_collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
_collectionView.pagingEnabled = YES;
_collectionView.showsVerticalScrollIndicator = NO;
_collectionView.backgroundColor = [UIColor whiteColor];
_collectionView.delegate = self;
_collectionView.dataSource = self;
[_collectionView registerClass:[KBPersonaChatCell class] forCellWithReuseIdentifier:@"KBPersonaChatCell"];
if (@available(iOS 11.0, *)) {
_collectionView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
}
}
return _collectionView;
}
@end

View File

@@ -6,6 +6,7 @@
//
#import <Foundation/Foundation.h>
#import "KBPersonaPageModel.h"
NS_ASSUME_NONNULL_BEGIN
@@ -55,6 +56,16 @@ typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL,
- (void)requestAudioWithAudioId:(NSString *)audioId
completion:(AiVMAudioURLCompletion)completion;
#pragma mark - 人设相关接口
/// 分页查询人设列表
/// @param pageNum 页码(从 1 开始)
/// @param pageSize 每页大小
/// @param completion 完成回调
- (void)fetchPersonasWithPageNum:(NSInteger)pageNum
pageSize:(NSInteger)pageSize
completion:(void(^)(KBPersonaPageModel * _Nullable pageModel, NSError * _Nullable error))completion;
@end
NS_ASSUME_NONNULL_END

View File

@@ -212,4 +212,64 @@ autoShowBusinessError:NO
}];
}
#pragma mark -
- (void)fetchPersonasWithPageNum:(NSInteger)pageNum
pageSize:(NSInteger)pageSize
completion:(void (^)(KBPersonaPageModel * _Nullable, NSError * _Nullable))completion {
NSDictionary *params = @{
@"pageNum": @(pageNum),
@"pageSize": @(pageSize)
};
NSLog(@"[AiVM] /ai-companion/page request: %@", params);
[[KBNetworkManager shared]
POST:@"/ai-companion/page"
jsonBody:params
headers:nil
autoShowBusinessError:NO
completion:^(NSDictionary *_Nullable json,
NSURLResponse *_Nullable response,
NSError *_Nullable error) {
if (error) {
NSLog(@"[AiVM] /ai-companion/page failed: %@", error.localizedDescription ?: @"");
if (completion) {
completion(nil, error);
}
return;
}
NSLog(@"[AiVM] /ai-companion/page response: %@", json);
//
NSInteger code = [json[@"code"] integerValue];
if (code != 0) {
NSString *message = json[@"message"] ?: @"请求失败";
NSError *bizError = [NSError errorWithDomain:@"AiVM"
code:code
userInfo:@{NSLocalizedDescriptionKey: message}];
if (completion) {
completion(nil, bizError);
}
return;
}
//
id dataObj = json[@"data"];
if ([dataObj isKindOfClass:[NSDictionary class]]) {
KBPersonaPageModel *pageModel = [KBPersonaPageModel mj_objectWithKeyValues:dataObj];
if (completion) {
completion(pageModel, nil);
}
} else {
NSError *parseError = [NSError errorWithDomain:@"AiVM"
code:-1
userInfo:@{NSLocalizedDescriptionKey: @"数据格式错误"}];
if (completion) {
completion(nil, parseError);
}
}
}];
}
@end

View File

@@ -0,0 +1,260 @@
# 人设列表实现说明
## 📦 架构概览
```
KBAIHomeVC (人设列表容器)
└─ UICollectionView (竖向分页滚动)
└─ KBPersonaChatCell (每个人设占满屏)
├─ 背景图coverImageUrl
├─ 头像avatarUrl
├─ 人设名称name
├─ 简介shortDesc
└─ UITableView (聊天记录 - 待实现)
```
---
## 📂 文件结构
### Model 层keyBoard/Class/AiTalk/M/
- **KBPersonaModel.h/m**:人设模型
- 包含personaId、name、avatarUrl、coverImageUrl、shortDesc、introText 等
- 扩展属性tagsArray、isEnabled、isPublic
- **KBPersonaPageModel.h/m**:分页数据模型
- 包含records人设数组、total、current、pages、hasMore
### View 层keyBoard/Class/AiTalk/V/
- **KBPersonaChatCell.h/m**:人设聊天 Cell
- 展示:背景图、头像、名称、简介
- 支持:预加载数据
### VM 层keyBoard/Class/AiTalk/VM/
- **AiVM.h/m**:网络请求管理
- 新增接口:`fetchPersonasWithPageNum:pageSize:completion:`
- 请求地址:`POST /ai-companion/page`
### VC 层keyBoard/Class/AiTalk/VC/
- **KBAIHomeVC.h/m**:人设列表容器
- 功能:分页加载、竖向翻页、预加载相邻 Cell
---
## 🔧 核心功能
### 1. 竖向分页滚动
```objc
// UICollectionView 配置
layout.scrollDirection = UICollectionViewScrollDirectionVertical;
layout.itemSize = [UIScreen mainScreen].bounds.size; // 每个 cell 占满屏
_collectionView.pagingEnabled = YES; // 开启分页
```
### 2. 预加载机制
- **预加载 3 个 Cell**:上一个、当前、下一个
- **触发时机**:滑动超过 30% 时开始预加载
- **避免重复**:用 `preloadedIndexes` 记录已加载的索引
```objc
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGFloat pageHeight = scrollView.bounds.size.height;
CGFloat offsetY = scrollView.contentOffset.y;
NSInteger currentPage = offsetY / pageHeight;
// 滑动超过 30% 就预加载
if (fmod(offsetY, pageHeight) > pageHeight * 0.3) {
[self preloadAdjacentCellsForIndex:currentPage + 1];
}
}
```
### 3. 分页加载
- **首次加载**pageNum=1, pageSize=10
- **加载更多**:接近底部时自动加载下一页
- **防重复**:用 `isLoading` 标记防止重复请求
```objc
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
// 接近底部时加载更多
if (offsetY + scrollView.bounds.size.height >= scrollView.contentSize.height - pageHeight) {
[self loadMorePersonas];
}
}
```
---
## 🌐 网络请求
### 接口地址
```
POST /ai-companion/page
```
### 请求参数
```json
{
"pageNum": 1,
"pageSize": 10
}
```
### 响应格式
```json
{
"code": 0,
"message": "success",
"data": {
"records": [
{
"id": 1,
"name": "温柔小姐姐",
"avatarUrl": "https://...",
"coverImageUrl": "https://...",
"shortDesc": "温柔体贴的聊天伙伴",
"introText": "你好呀,今天过得怎么样?",
"personalityTags": "温柔,体贴,善解人意",
...
}
],
"total": 100,
"current": 1,
"pages": 10
}
}
```
### 使用示例
```objc
[self.aiVM fetchPersonasWithPageNum:1
pageSize:10
completion:^(KBPersonaPageModel *pageModel, NSError *error) {
if (error) {
NSLog(@"加载失败:%@", error.localizedDescription);
return;
}
[self.personas addObjectsFromArray:pageModel.records];
self.hasMore = pageModel.hasMore;
[self.collectionView reloadData];
}];
```
---
## 🎨 UI 布局
### KBPersonaChatCell 布局
```
┌─────────────────────────────┐
│ 背景图coverImageUrl
│ ┌─────────────────────┐ │
│ │ 半透明遮罩(黑色 0.3 │ │
│ │ │ │
│ │ ┌───┐ │ │
│ │ │头像│ │ │
│ │ └───┘ │ │
│ │ 人设名称 │ │
│ │ 简短描述 │ │
│ │ │ │
│ │ ┌───────────────┐ │ │
│ │ │ 聊天记录列表 │ │ │
│ │ │ (待实现) │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
```
---
## ✅ 已完成功能
1. ✅ Model 层KBPersonaModel、KBPersonaPageModel
2. ✅ VM 层AiVM 新增人设列表接口
3. ✅ View 层KBPersonaChatCell基础 UI
4. ✅ VC 层KBAIHomeVC分页加载、竖向翻页、预加载
5. ✅ MJExtension 配置JSON 自动转 Model
6. ✅ 懒加载:所有控件使用懒加载
7. ✅ Masonry 布局:纯代码布局
---
## 🚧 待实现功能
### 1. 聊天记录展示
- 需要后端提供 `chatId` 字段
-`chatId` 请求聊天记录接口
-`KBPersonaChatCell``tableView` 中展示聊天记录
### 2. 聊天功能
- 点击输入框发送消息
- 接收 AI 回复
- 支持语音消息
### 3. 错误处理
- 网络请求失败提示
- 空数据占位图
- 加载中状态
### 4. 性能优化
- Cell 高度缓存
- 图片缓存策略
- 内存管理(清理不可见 Cell 的数据)
---
## 📝 使用方式
### 1. 在其他 VC 中跳转
```objc
KBAIHomeVC *homeVC = [[KBAIHomeVC alloc] init];
[self.navigationController pushViewController:homeVC animated:YES];
```
### 2. 作为 TabBar 的一个页面
```objc
KBAIHomeVC *homeVC = [[KBAIHomeVC alloc] init];
homeVC.tabBarItem = [[UITabBarItem alloc] initWithTitle:@"AI聊天"
image:[UIImage imageNamed:@"tab_ai"]
selectedImage:[UIImage imageNamed:@"tab_ai_sel"]];
```
---
## 🐛 调试日志
### 网络请求日志
```
[AiVM] /ai-companion/page request: {pageNum: 1, pageSize: 10}
[AiVM] /ai-companion/page response: {...}
加载成功:当前 10 条,总共 100 条,还有更多:是
```
### 预加载日志
```
预加载第 0 个人设
预加载第 1 个人设
预加载第 2 个人设
当前在第 1 个人设:温柔小姐姐
```
---
## 📌 注意事项
1. **接口地址**:需要在 `KBAPI.h` 中定义 `/ai-companion/page`
2. **图片占位图**:需要添加 `placeholder_bg``placeholder_avatar` 图片资源
3. **线程安全**:网络回调后需要回到主线程更新 UI
4. **内存管理**:注意使用 `weakSelf` 避免循环引用
5. **字段兼容**:后端可能返回 `null`,已做容错处理
---
## 🎯 下一步计划
1. 接入真实的聊天记录接口
2. 实现聊天输入和发送功能
3. 优化 Cell 的聊天记录展示(使用现有的 KBChatTableView
4. 添加下拉刷新和上拉加载更多
5. 添加错误提示和空数据占位图

View File

@@ -10,6 +10,7 @@
#import "KBAiMainVC.h"
#import "KBShopVC.h"
#import "MyVC.h"
#import "KBAIHomeVC.h"
// #import "KBCommunityVC.h"
@@ -51,7 +52,7 @@
selectedImg:@"tab_shop_selected"];
// AI
KBAiMainVC *aiMainVC = [[KBAiMainVC alloc] init];
KBAIHomeVC *aiMainVC = [[KBAIHomeVC alloc] init];
// community.title = KBLocalized(@"Circle");
BaseNavigationController *navCommunity =
[[BaseNavigationController alloc] initWithRootViewController:aiMainVC];