feat: 新增前端通用组件
- 新增FriendlyError友好错误提示组件 - 新增MarkdownText Markdown文本渲染组件
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user