feat: 新增前端通用组件

- 新增FriendlyError友好错误提示组件
- 新增MarkdownText Markdown文本渲染组件
This commit is contained in:
iammm0
2026-01-28 12:59:43 +08:00
parent d5d8619f22
commit a80a80ff7d
2 changed files with 573 additions and 0 deletions

View File

@@ -0,0 +1,475 @@
package com.huaga.life_echo.ui.components.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.*
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.huaga.life_echo.config.AppConfig
import com.huaga.life_echo.ui.icons.AppIcons
import com.huaga.life_echo.ui.theme.LightPurple
/**
* 错误类型枚举
* 用于分类不同的错误场景,展示对应的友好提示
*/
enum class ErrorType {
NETWORK, // 网络连接错误
SERVER, // 服务器错误
TIMEOUT, // 请求超时
AUTH, // 认证/授权错误
NOT_FOUND, // 资源不存在
VALIDATION, // 数据验证错误
UNKNOWN // 未知错误
}
/**
* 友好错误信息数据类
*/
data class FriendlyErrorInfo(
val icon: ImageVector,
val title: String,
val message: String,
val actionText: String = "重试",
val iconTint: Color = LightPurple
)
/**
* 错误信息处理工具
* 根据运行模式决定显示原始错误还是友好提示
*/
object ErrorHandler {
/**
* 获取友好错误信息
* 生产模式下返回友好提示,开发模式下可选择显示原始错误
*/
fun getFriendlyError(
errorType: ErrorType,
originalMessage: String? = null
): FriendlyErrorInfo {
return when (errorType) {
ErrorType.NETWORK -> FriendlyErrorInfo(
icon = AppIcons.WifiOff,
title = "网络连接失败",
message = "请检查您的网络连接后重试",
iconTint = Color(0xFFFF9800)
)
ErrorType.SERVER -> FriendlyErrorInfo(
icon = AppIcons.CloudOff,
title = "服务暂时不可用",
message = "服务器正在维护中,请稍后再试",
iconTint = Color(0xFFF44336)
)
ErrorType.TIMEOUT -> FriendlyErrorInfo(
icon = AppIcons.AccessTime,
title = "请求超时",
message = "网络响应较慢,请稍后重试",
iconTint = Color(0xFFFF9800)
)
ErrorType.AUTH -> FriendlyErrorInfo(
icon = AppIcons.Lock,
title = "登录已过期",
message = "请重新登录后继续操作",
actionText = "去登录",
iconTint = Color(0xFF9C27B0)
)
ErrorType.NOT_FOUND -> FriendlyErrorInfo(
icon = AppIcons.SearchOff,
title = "内容不存在",
message = "您访问的内容可能已被删除或移动",
iconTint = Color(0xFF607D8B)
)
ErrorType.VALIDATION -> FriendlyErrorInfo(
icon = AppIcons.Warning,
title = "信息有误",
message = if (AppConfig.isDebugMode && originalMessage != null) {
originalMessage
} else {
"请检查输入的信息是否正确"
},
iconTint = Color(0xFFFF9800)
)
ErrorType.UNKNOWN -> FriendlyErrorInfo(
icon = AppIcons.Error,
title = "出了点小问题",
message = "请稍后重试,如问题持续请联系客服",
iconTint = Color(0xFFF44336)
)
}
}
/**
* 根据异常自动判断错误类型
*/
fun detectErrorType(exception: Throwable): ErrorType {
val message = exception.message?.lowercase() ?: ""
return when {
message.contains("timeout") || message.contains("timed out") -> ErrorType.TIMEOUT
message.contains("network") || message.contains("connect") ||
message.contains("socket") || message.contains("unreachable") -> ErrorType.NETWORK
message.contains("401") || message.contains("unauthorized") ||
message.contains("token") || message.contains("expired") -> ErrorType.AUTH
message.contains("404") || message.contains("not found") -> ErrorType.NOT_FOUND
message.contains("500") || message.contains("502") ||
message.contains("503") || message.contains("server") -> ErrorType.SERVER
message.contains("validation") || message.contains("invalid") -> ErrorType.VALIDATION
else -> ErrorType.UNKNOWN
}
}
/**
* 根据HTTP状态码判断错误类型
*/
fun detectErrorTypeByStatusCode(statusCode: Int): ErrorType {
return when (statusCode) {
401, 403 -> ErrorType.AUTH
404 -> ErrorType.NOT_FOUND
408, 504 -> ErrorType.TIMEOUT
in 500..599 -> ErrorType.SERVER
else -> ErrorType.UNKNOWN
}
}
/**
* 获取用于显示的错误消息
* 生产模式下返回友好提示,开发模式下返回原始错误
*/
fun getDisplayMessage(
errorType: ErrorType,
originalMessage: String?
): String {
return if (AppConfig.isDebugMode && originalMessage != null) {
originalMessage
} else {
getFriendlyError(errorType, originalMessage).message
}
}
/**
* 处理异常并返回显示消息
*/
fun handleException(exception: Throwable): String {
val errorType = detectErrorType(exception)
return getDisplayMessage(errorType, exception.message)
}
}
/**
* 友好错误视图组件(带动画)
*/
@Composable
fun FriendlyErrorView(
errorType: ErrorType,
originalMessage: String? = null,
onRetry: (() -> Unit)? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier,
showAnimation: Boolean = true
) {
val errorInfo = ErrorHandler.getFriendlyError(errorType, originalMessage)
// 动画状态
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
visible = true
}
// 图标呼吸动画
val infiniteTransition = rememberInfiniteTransition(label = "error_animation")
val iconScale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.1f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
),
label = "icon_scale"
)
val iconAlpha by infiniteTransition.animateFloat(
initialValue = 0.7f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
),
label = "icon_alpha"
)
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut()
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 图标容器(带动画)
Box(
modifier = Modifier
.size(100.dp)
.scale(if (showAnimation) iconScale else 1f)
.alpha(if (showAnimation) iconAlpha else 1f)
.clip(CircleShape)
.background(errorInfo.iconTint.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = errorInfo.icon,
contentDescription = null,
tint = errorInfo.iconTint,
modifier = Modifier.size(48.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
// 标题
Text(
text = errorInfo.title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
// 描述信息
Text(
text = errorInfo.message,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 22.sp,
modifier = Modifier.padding(horizontal = 16.dp)
)
// 开发模式下显示原始错误信息
if (AppConfig.isDebugMode && originalMessage != null && errorType != ErrorType.VALIDATION) {
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
)
) {
Text(
text = "[DEBUG] $originalMessage",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(12.dp)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// 操作按钮
val actionHandler = onAction ?: onRetry
if (actionHandler != null) {
Button(
onClick = actionHandler,
colors = ButtonDefaults.buttonColors(
containerColor = errorInfo.iconTint
),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 32.dp),
shape = RoundedCornerShape(24.dp)
) {
Text(
text = errorInfo.actionText,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White
)
}
}
}
}
}
/**
* 友好错误对话框组件
*/
@Composable
fun FriendlyErrorDialog(
errorType: ErrorType,
originalMessage: String? = null,
onDismiss: () -> Unit,
onRetry: (() -> Unit)? = null
) {
val errorInfo = ErrorHandler.getFriendlyError(errorType, originalMessage)
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(errorInfo.iconTint.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = errorInfo.icon,
contentDescription = null,
tint = errorInfo.iconTint,
modifier = Modifier.size(28.dp)
)
}
},
title = {
Text(
text = errorInfo.title,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
},
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = errorInfo.message,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 开发模式下显示原始错误
if (AppConfig.isDebugMode && originalMessage != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "[DEBUG] $originalMessage",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
}
},
confirmButton = {
if (onRetry != null) {
Button(
onClick = {
onDismiss()
onRetry()
},
colors = ButtonDefaults.buttonColors(
containerColor = errorInfo.iconTint
)
) {
Text(errorInfo.actionText, color = Color.White)
}
} else {
TextButton(onClick = onDismiss) {
Text("我知道了", color = errorInfo.iconTint)
}
}
},
dismissButton = if (onRetry != null) {
{
TextButton(onClick = onDismiss) {
Text("取消", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else null
)
}
/**
* 友好错误 Snackbar 消息
*/
@Composable
fun rememberFriendlyErrorSnackbarHostState(): SnackbarHostState {
return remember { SnackbarHostState() }
}
/**
* 显示友好错误 Snackbar
*/
suspend fun SnackbarHostState.showFriendlyError(
errorType: ErrorType,
originalMessage: String? = null,
actionLabel: String? = "重试"
): SnackbarResult {
val errorInfo = ErrorHandler.getFriendlyError(errorType, originalMessage)
return showSnackbar(
message = errorInfo.message,
actionLabel = actionLabel,
duration = SnackbarDuration.Long
)
}
/**
* 内联错误提示组件(用于表单等场景)
*/
@Composable
fun InlineErrorMessage(
message: String,
modifier: Modifier = Modifier
) {
// 生产模式下使用通用提示
val displayMessage = if (AppConfig.isDebugMode) {
message
} else {
// 检查是否是常见的技术性错误信息,如果是则替换为友好提示
when {
message.contains("timeout", ignoreCase = true) -> "请求超时,请重试"
message.contains("network", ignoreCase = true) -> "网络连接失败"
message.contains("server", ignoreCase = true) -> "服务暂时不可用"
message.contains("unauthorized", ignoreCase = true) -> "请重新登录"
message.contains("exception", ignoreCase = true) -> "操作失败,请重试"
message.contains("error", ignoreCase = true) && message.length > 50 -> "操作失败,请重试"
else -> message // 保留业务相关的错误信息
}
}
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = AppIcons.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = displayMessage,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.error,
lineHeight = 18.sp
)
}
}

View File

@@ -0,0 +1,98 @@
package com.huaga.life_echo.ui.components.common
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
/**
* Markdown 文本渲染组件
* 支持常见的 Markdown 语法:标题、粗体、斜体、列表、引用、代码块等
*/
@Composable
fun MarkdownText(
content: String,
modifier: Modifier = Modifier,
textColor: Color = MaterialTheme.colorScheme.onSurface,
linkColor: Color = MaterialTheme.colorScheme.primary,
fontSize: Int = 16,
lineHeight: Int = 28
) {
val typography = markdownTypography(
h1 = MaterialTheme.typography.headlineLarge.copy(
color = textColor,
fontSize = (fontSize + 12).sp
),
h2 = MaterialTheme.typography.headlineMedium.copy(
color = textColor,
fontSize = (fontSize + 8).sp
),
h3 = MaterialTheme.typography.headlineSmall.copy(
color = textColor,
fontSize = (fontSize + 4).sp
),
h4 = MaterialTheme.typography.titleLarge.copy(
color = textColor,
fontSize = (fontSize + 2).sp
),
h5 = MaterialTheme.typography.titleMedium.copy(
color = textColor,
fontSize = (fontSize + 1).sp
),
h6 = MaterialTheme.typography.titleSmall.copy(
color = textColor,
fontSize = fontSize.sp
),
text = TextStyle(
color = textColor,
fontSize = fontSize.sp,
lineHeight = lineHeight.sp
),
paragraph = TextStyle(
color = textColor,
fontSize = fontSize.sp,
lineHeight = lineHeight.sp
),
ordered = TextStyle(
color = textColor,
fontSize = fontSize.sp,
lineHeight = lineHeight.sp
),
bullet = TextStyle(
color = textColor,
fontSize = fontSize.sp,
lineHeight = lineHeight.sp
),
quote = TextStyle(
color = textColor.copy(alpha = 0.8f),
fontSize = fontSize.sp,
lineHeight = lineHeight.sp
),
code = TextStyle(
color = textColor,
fontSize = (fontSize - 2).sp,
lineHeight = (lineHeight - 4).sp
)
)
val colors = markdownColor(
text = textColor,
codeText = textColor,
linkText = linkColor,
codeBackground = MaterialTheme.colorScheme.surfaceVariant,
inlineCodeBackground = MaterialTheme.colorScheme.surfaceVariant,
dividerColor = MaterialTheme.colorScheme.outlineVariant
)
Markdown(
content = content,
modifier = modifier,
colors = colors,
typography = typography
)
}