Merge pull request #66 from Kevinlinpr/atm2

feat: 新增AI智能体主页
This commit is contained in:
2025-11-11 14:26:43 +08:00
committed by GitHub
6 changed files with 696 additions and 2 deletions

View File

@@ -64,6 +64,11 @@ data class AccountProfile(
val aiAccount: Boolean,
val chatAIId: String,
// AI角色背景图
val aiRoleAvatar: String? = null,
val aiRoleAvatarMedium: String? = null,
val aiRoleAvatarLarge: String? = null,
) {
/**
* 转换为Entity
@@ -89,7 +94,16 @@ data class AccountProfile(
chatToken = openImToken,
aiAccount = aiAccount,
rawAvatar = avatar,
chatAIId = chatAIId
chatAIId = chatAIId,
aiRoleAvatar = aiRoleAvatar?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
},
aiRoleAvatarMedium = aiRoleAvatarMedium?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
},
aiRoleAvatarLarge = aiRoleAvatarLarge?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
}
)
}
}

View File

@@ -67,6 +67,11 @@ data class AccountProfileEntity(
val rawAvatar: String,
val chatAIId: String,
// AI角色背景图
val aiRoleAvatar: String? = null,
val aiRoleAvatarMedium: String? = null,
val aiRoleAvatarLarge: String? = null,
)
/**

View File

@@ -76,6 +76,7 @@ import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
import com.aiosman.ravenow.ui.post.NewPostScreen
import com.aiosman.ravenow.ui.post.PostScreen
import com.aiosman.ravenow.ui.profile.AccountProfileV2
import com.aiosman.ravenow.ui.profile.AiProfileWrap
import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage
import com.aiosman.ravenow.ui.notification.NotificationScreen
import com.aiosman.ravenow.ui.scan.ScanQrScreen
@@ -345,7 +346,13 @@ fun NavigationController(
) {
val id = it.arguments?.getString("id")!!
val isAiAccount = it.arguments?.getBoolean("isAiAccount") ?: false
AccountProfileV2(id, isAiAccount)
// 根据isAiAccount参数分发到不同的Profile页面
if (isAiAccount) {
AiProfileWrap(id)
} else {
AccountProfileV2(id, isAiAccount)
}
}
}
composable(

View File

@@ -0,0 +1,456 @@
package com.aiosman.ravenow.ui.profile
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.GuestLoginCheckOut
import com.aiosman.ravenow.GuestLoginCheckOutScene
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
import java.text.NumberFormat
import java.util.Locale
@Composable
fun AiProfileV3(
profile: AccountProfileEntity?,
moments: List<MomentEntity>,
postCount: Long? = null,
isSelf: Boolean = false,
onFollowClick: () -> Unit = {},
onChatClick: () -> Unit = {},
onShareClick: () -> Unit = {},
onLoadMore: () -> Unit = {},
onComment: (MomentEntity) -> Unit = {},
) {
val appColors = LocalAppTheme.current
val numberFormat = remember { NumberFormat.getNumberInstance(Locale.getDefault()) }
Box(
modifier = Modifier
.fillMaxSize()
.background(appColors.background)
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
// 顶部状态栏间距
item {
val statusBarPadding = WindowInsets.systemBars.asPaddingValues()
Spacer(modifier = Modifier.height(statusBarPadding.calculateTopPadding()))
}
// AI大卡片
item {
AiProfileCard(
profile = profile,
postCount = postCount,
numberFormat = numberFormat
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
// 三按钮交互区
if (!isSelf) {
item {
AiProfileActions(
profile = profile,
onFollowClick = onFollowClick,
onChatClick = onChatClick,
onShareClick = onShareClick
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
// 动态Grid - 使用items来渲染每个动态
item {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier
.fillMaxWidth()
.height(((moments.size / 3 + 1) * 130).dp) // 动态计算高度
.padding(horizontal = 16.dp),
userScrollEnabled = false // 禁用内部滚动
) {
itemsIndexed(moments) { idx, moment ->
if (moment.images.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.padding(2.dp)
.noRippleClickable {
onComment(moment)
}
) {
CustomAsyncImage(
imageUrl = moment.images[0].thumbnail,
contentDescription = "",
modifier = Modifier.fillMaxSize(),
context = LocalContext.current
)
}
}
}
}
}
// 底部间距
item {
Spacer(modifier = Modifier.height(100.dp))
}
}
// 顶部返回按钮
TopBar()
}
}
@Composable
private fun TopBar() {
val navController = LocalNavController.current
val appColors = LocalAppTheme.current
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
) {
StatusBarSpacer()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.statusBarsPadding(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 返回按钮
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.3f))
.noRippleClickable {
navController.popBackStack()
},
contentAlignment = Alignment.Center
) {
Text(
text = "",
fontSize = 24.sp,
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
}
@Composable
private fun AiProfileCard(
profile: AccountProfileEntity?,
postCount: Long?,
numberFormat: NumberFormat
) {
val appColors = LocalAppTheme.current
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp) // 添加四边边距
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.clip(RoundedCornerShape(16.dp)) // 添加圆角
) {
// 背景图 - 使用aiRoleAvatar
val backgroundUrl = profile?.aiRoleAvatarLarge
?: profile?.aiRoleAvatarMedium
?: profile?.aiRoleAvatar
?: profile?.avatar
CustomAsyncImage(
context = LocalContext.current,
imageUrl = backgroundUrl ?: "",
contentDescription = "AI Background",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// 底部渐变遮罩
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
),
startY = 200f,
endY = 1200f
)
)
)
// 左下角内容
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(20.dp),
horizontalAlignment = Alignment.Start
) {
// AI昵称 - 左对齐
Text(
text = profile?.nickName ?: "",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(6.dp))
// AI简介 - 左对齐
if (!profile?.bio.isNullOrEmpty()) {
Text(
text = profile?.bio ?: "",
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.85f),
textAlign = TextAlign.Start,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp
)
Spacer(modifier = Modifier.height(12.dp))
}
// 统计数据行 - 左对齐
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StatItem(
count = postCount ?: 0L,
label = stringResource(R.string.posts),
numberFormat = numberFormat
)
StatItem(
count = profile?.followerCount?.toLong() ?: 0L,
label = stringResource(R.string.followers_upper),
numberFormat = numberFormat
)
StatItem(
count = profile?.followingCount?.toLong() ?: 0L,
label = stringResource(R.string.following_upper),
numberFormat = numberFormat
)
}
}
}
}
}
@Composable
private fun StatItem(
count: Long,
label: String,
numberFormat: NumberFormat
) {
Column(
horizontalAlignment = Alignment.Start
) {
Text(
text = formatNumber(count, numberFormat),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = label,
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.8f)
)
}
}
private fun formatNumber(count: Long, numberFormat: NumberFormat): String {
return when {
count >= 1_000_000 -> String.format("%.1fM", count / 1_000_000.0)
count >= 1_000 -> String.format("%.1fK", count / 1_000.0)
else -> numberFormat.format(count)
}
}
@Composable
private fun AiProfileActions(
profile: AccountProfileEntity?,
onFollowClick: () -> Unit,
onChatClick: () -> Unit,
onShareClick: () -> Unit
) {
val appColors = LocalAppTheme.current
val navController = LocalNavController.current
// 定义渐变色
val followGradient = Brush.horizontalGradient(
colors = listOf(
Color(0xFF7c45ed),
Color(0x777c68ef)
)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
// Follow按钮
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.let { modifier ->
if (profile?.isFollowing == true) {
// 已关注状态 - 透明背景
modifier.background(Color.Transparent)
} else {
// 未关注状态 - 渐变背景
modifier.background(brush = followGradient)
}
}
.let { modifier ->
if (profile?.isFollowing == true) {
modifier.border(
width = 1.dp,
color = appColors.text.copy(alpha = 0.3f),
shape = RoundedCornerShape(8.dp)
)
} else {
modifier
}
}
.padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable {
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) {
navController.navigate(NavigationRoute.Login.route)
} else {
onFollowClick()
}
}
) {
Text(
text = if (profile?.isFollowing == true)
stringResource(R.string.follow_upper_had)
else
stringResource(R.string.follow_upper),
fontSize = 14.sp,
fontWeight = FontWeight.W900,
color = if (profile?.isFollowing == true) {
appColors.text.copy(alpha = 0.6f)
} else {
Color.White
},
)
}
// Chat按钮
if (AppState.enableChat) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(appColors.nonActive)
.padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable {
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
onChatClick()
}
}
) {
Text(
text = stringResource(R.string.chat_upper),
fontSize = 14.sp,
fontWeight = FontWeight.W900,
color = appColors.text,
)
}
}
// Share按钮
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(appColors.nonActive)
.padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable {
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
onShareClick()
}
}
) {
Text(
text = stringResource(R.string.share),
fontSize = 14.sp,
fontWeight = FontWeight.W900,
color = appColors.text,
)
}
}
}

View File

@@ -0,0 +1,122 @@
package com.aiosman.ravenow.ui.profile
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentLoader
import com.aiosman.ravenow.entity.MomentLoaderExtraArgs
import com.aiosman.ravenow.event.FollowChangeEvent
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
class AiProfileViewModel : ViewModel() {
var profileId by mutableStateOf(0)
val accountService: AccountService = AccountServiceImpl()
val userService = UserServiceImpl()
var profile by mutableStateOf<AccountProfileEntity?>(null)
var refreshing by mutableStateOf(false)
var momentLoader = MomentLoader().apply {
pageSize = 20
onListChanged = {
moments = it
}
}
var moments by mutableStateOf<List<MomentEntity>>(listOf())
init {
EventBus.getDefault().register(this)
}
fun loadProfile(id: String, pullRefresh: Boolean = false) {
viewModelScope.launch {
if (pullRefresh) {
refreshing = true
}
try {
profile = userService.getUserProfile(id)
profileId = id.toInt()
} catch (e: Exception) {
Log.e("AiProfileViewModel", "Error loading profile", e)
e.printStackTrace()
}
refreshing = false
profile?.let {
try {
momentLoader.loadData(MomentLoaderExtraArgs(authorId = it.id))
} catch (e: Exception) {
Log.e("AiProfileViewModel", "Error loading moments", e)
e.printStackTrace()
}
}
}
}
fun loadMoreMoment() {
viewModelScope.launch {
profile?.let { profileData ->
try {
Log.d("AiProfileViewModel", "loadMoreMoment: 开始加载更多, 当前moments数量: ${moments.size}, hasNext: ${momentLoader.hasNext}")
momentLoader.loadMore(extra = MomentLoaderExtraArgs(authorId = profileData.id))
Log.d("AiProfileViewModel", "loadMoreMoment: 加载完成, 新的moments数量: ${moments.size}")
} catch (e: Exception) {
Log.e("AiProfileViewModel", "loadMoreMoment: ", e)
}
} ?: Log.w("AiProfileViewModel", "loadMoreMoment: profile为null无法加载更多")
}
}
@Subscribe
fun onFollowChangeEvent(event: FollowChangeEvent) {
if (event.userId == profile?.id) {
profile = profile?.copy(
followerCount = profile!!.followerCount + if (event.isFollow) 1 else -1,
isFollowing = event.isFollow
)
}
}
fun followUser(userId: String) {
viewModelScope.launch {
try {
userService.followUser(userId)
EventBus.getDefault().post(FollowChangeEvent(userId.toInt(), true))
} catch (e: Exception) {
Log.e("AiProfileViewModel", "Error following user", e)
}
}
}
fun unFollowUser(userId: String) {
viewModelScope.launch {
try {
userService.unFollowUser(userId)
EventBus.getDefault().post(FollowChangeEvent(userId.toInt(), false))
} catch (e: Exception) {
Log.e("AiProfileViewModel", "Error unfollowing user", e)
}
}
}
val bio get() = profile?.bio ?: ""
val nickName get() = profile?.nickName ?: ""
val avatar get() = profile?.avatar
override fun onCleared() {
super.onCleared()
EventBus.getDefault().unregister(this)
}
}

View File

@@ -0,0 +1,90 @@
package com.aiosman.ravenow.ui.profile
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.GuestLoginCheckOut
import com.aiosman.ravenow.GuestLoginCheckOutScene
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.exp.viewModelFactory
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.ravenow.ui.navigateToChatAi
import com.aiosman.ravenow.ui.navigateToPost
import kotlinx.coroutines.launch
@Composable
fun AiProfileWrap(id: String) {
val model: AiProfileViewModel = viewModel(factory = viewModelFactory {
AiProfileViewModel()
}, key = "aiViewModel_${id}")
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
model.loadProfile(id)
MyProfileViewModel.loadProfile()
}
val isSelf = id == MyProfileViewModel.profile?.id.toString()
AiProfileV3(
profile = model.profile,
moments = model.moments,
postCount = model.momentLoader.total,
isSelf = isSelf,
onFollowClick = {
model.profile?.let {
if (it.isFollowing) {
model.unFollowUser(id)
} else {
model.followUser(id)
}
}
},
onChatClick = {
// 检查游客模式
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
model.profile?.let { profile ->
scope.launch {
try {
// 参考主页逻辑使用chatAIId作为openId创建单聊并导航
// 创建单聊
MineAgentViewModel.createSingleChat(profile.chatAIId)
// 通过chatAIId获取完整的AI profile类似主页的goToChatAi逻辑
val userService = UserServiceImpl()
val aiProfile = userService.getUserProfileByOpenId(profile.chatAIId)
// 导航到AI聊天页面
navController.navigateToChatAi(aiProfile.id.toString())
} catch (e: Exception) {
Log.e("AiProfileWrap", "Error navigating to AI chat", e)
// 如果获取失败直接使用当前profile的id
navController.navigateToChatAi(profile.id.toString())
}
}
}
}
},
onShareClick = {
// TODO: 实现分享逻辑
Log.d("AiProfileWrap", "分享功能待实现")
},
onLoadMore = {
Log.d("AiProfileWrap", "onLoadMore被调用")
model.loadMoreMoment()
},
onComment = { moment ->
navController.navigateToPost(moment.id)
}
)
}